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/.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 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/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/biome.json b/biome.json index 497f75e..cc68b63 100644 --- a/biome.json +++ b/biome.json @@ -7,7 +7,13 @@ }, "files": { "ignoreUnknown": false, - "includes": ["**", "!**/node_modules", "!**/dist"] + "includes": [ + "**", + "!**/node_modules", + "!**/dist", + "!src/client/vendor/ghostty-web", + "!src/client/vendor/ghostty-vt" + ] }, "formatter": { "enabled": true, diff --git a/bun.lock b/bun.lock index 9728941..d8ddd6a 100644 --- a/bun.lock +++ b/bun.lock @@ -6,13 +6,13 @@ "name": "webtty", "dependencies": { "@lydell/node-pty": "1.2.0-beta.15", - "ghostty-web": "0.4.0-next.20.g1858a59", "ws": "8.21.3", }, "devDependencies": { "@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", @@ -171,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=="], @@ -201,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=="], @@ -257,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=="], @@ -301,8 +307,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=="], @@ -311,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=="], @@ -615,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/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/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/013.client.cursor-style.md b/docs/adrs/013.client.cursor-style.md index 7ada93d..5c02d74 100644 --- a/docs/adrs/013.client.cursor-style.md +++ b/docs/adrs/013.client.cursor-style.md @@ -34,6 +34,10 @@ 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. ([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, 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 **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/adrs/017.client.mouse-scroll.md b/docs/adrs/017.client.mouse-scroll.md index 3dd0938..1d01202 100644 --- a/docs/adrs/017.client.mouse-scroll.md +++ b/docs/adrs/017.client.mouse-scroll.md @@ -89,6 +89,10 @@ 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/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/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/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/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/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/adrs/040.client.pixel-smooth-scroll.md b/docs/adrs/040.client.pixel-smooth-scroll.md index 9655643..f9fe4d3 100644 --- a/docs/adrs/040.client.pixel-smooth-scroll.md +++ b/docs/adrs/040.client.pixel-smooth-scroll.md @@ -21,6 +21,12 @@ 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. + +**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 - Trackpad scrolling through scrollback follows the fingers pixel by pixel, with macOS momentum, and no easing lag. diff --git a/docs/adrs/041.client.libghostty-vt.md b/docs/adrs/041.client.libghostty-vt.md new file mode 100644 index 0000000..9b8be3b --- /dev/null +++ b/docs/adrs/041.client.libghostty-vt.md @@ -0,0 +1,122 @@ +# 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** ([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. (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. + +**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. (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. (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): + +- **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. Settled in [ADR 042](042.client.official-vt-engine.md): ReleaseSmall, as ReleaseFast is 0–8% faster on a 20 MiB `cat`. + +## 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, 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. +- **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 (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. + +## 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) +- [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 new file mode 100644 index 0000000..9b5def5 --- /dev/null +++ b/docs/adrs/042.client.official-vt-engine.md @@ -0,0 +1,178 @@ +# 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) | +| 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) | + +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 + +**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.** + +``` +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) + 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 (engine.test.ts) +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`. After a resize it drains once `onResize` has fired, so the PTY gets its new size before the mode 2048 report. + +**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. 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` 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`. + - `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 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, 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`. +- 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); 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. (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. + +**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. 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; 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) | +| 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). (Changed by ADR 047: the Coder engine was deleted without that release; rollback is git revert.) + +### 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. +- **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. + +## 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. 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. +- The default stays `coder`, and nothing changes for users who do not add `?engine=official`. + +## Acceptance record + +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/043.client.scroll-copy-and-overscan.md b/docs/adrs/043.client.scroll-copy-and-overscan.md new file mode 100644 index 0000000..d0cf6ed --- /dev/null +++ b/docs/adrs/043.client.scroll-copy-and-overscan.md @@ -0,0 +1,150 @@ +# ADR 043: client — Copy rows on scroll, then overscan + +**SPEC:** [client](../specs/client.md) +**Status:** Accepted +**Date:** 2026-10-13 (step 2: 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, 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. 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. + +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. +- 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. + +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.** 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 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. +- 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 | +|---|---|---| +| 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, 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. +- 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). +- 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 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 + +- [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/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/adrs/045.client.smooth-mouse-wheel.md b/docs/adrs/045.client.smooth-mouse-wheel.md new file mode 100644 index 0000000..8be67d8 --- /dev/null +++ b/docs/adrs/045.client.smooth-mouse-wheel.md @@ -0,0 +1,65 @@ +# 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. + +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 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. +- **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. 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. +- 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` 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. + +## 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 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. +- 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 + +- **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. + +## 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) diff --git a/docs/adrs/046.client.scrollback-lines.md b/docs/adrs/046.client.scrollback-lines.md new file mode 100644 index 0000000..f1cc878 --- /dev/null +++ b/docs/adrs/046.client.scrollback-lines.md @@ -0,0 +1,64 @@ +# 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). (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 + +- 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, 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. + +## 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) 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..ad0e67b --- /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 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` 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.** + +- `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`. + +**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 | 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. + +**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. ([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 + +- **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/adrs/048.webtty.output-flow-control.md b/docs/adrs/048.webtty.output-flow-control.md new file mode 100644 index 0000000..93c8574 --- /dev/null +++ b/docs/adrs/048.webtty.output-flow-control.md @@ -0,0 +1,90 @@ +# 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, 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. 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. + +## 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. 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. 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 + +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 (`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. + +## 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/adrs/049.client.official-mouse-cursor-paste.md b/docs/adrs/049.client.official-mouse-cursor-paste.md new file mode 100644 index 0000000..af2c538 --- /dev/null +++ b/docs/adrs/049.client.official-mouse-cursor-paste.md @@ -0,0 +1,97 @@ +# 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. +- **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` + +- `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) 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. + +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: 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 + +- **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. +- **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 + +- [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/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) 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..0b660b9 --- /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, 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. + +### 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` 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 + +- 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 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 + +- **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) 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) 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..305208c --- /dev/null +++ b/docs/adrs/053.client.focus-reports-title-and-bell.md @@ -0,0 +1,88 @@ +# 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`. 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 + +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. 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 + +`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.** 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 + +`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. +- 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 + +- **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. +- **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. + +## 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 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..13f6b65 --- /dev/null +++ b/docs/adrs/054.client.copy-paste-clear-keys.md @@ -0,0 +1,93 @@ +# 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, `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. + +## 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, 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 + +- 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. +- 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`. + +## 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. +- **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 + +- [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 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..92508d8 --- /dev/null +++ b/docs/adrs/055.client.find-in-scrollback.md @@ -0,0 +1,108 @@ +# 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. 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. + +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. +- **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 + +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` diff --git a/docs/adrs/056.client.desktop-notifications.md b/docs/adrs/056.client.desktop-notifications.md new file mode 100644 index 0000000..978c375 --- /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 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 + +`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 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..59a5d67 --- /dev/null +++ b/docs/adrs/057.client.shell-integration-prompt-jumps.md @@ -0,0 +1,97 @@ +# 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. + +**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 + +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` 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 diff --git a/docs/adrs/059.architecture.code-organisation.md b/docs/adrs/059.architecture.code-organisation.md new file mode 100644 index 0000000..64af50c --- /dev/null +++ b/docs/adrs/059.architecture.code-organisation.md @@ -0,0 +1,78 @@ +# 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`. + +## 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 new file mode 100644 index 0000000..9eb60d5 --- /dev/null +++ b/docs/adrs/060.client.feature-modules.md @@ -0,0 +1,87 @@ +# ADR 060: client — A composition root and feature modules + +**SPEC:** [architecture](../specs/architecture.md), [client](../specs/client.md) +**Status:** Accepted +**Date:** 2026-10-14 (root order, ClientEnv and per-socket ack revised after review) + +--- + +## 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`. `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. + +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 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. 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. 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 + +`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`. 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). +- `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. + +## 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 new file mode 100644 index 0000000..f48c4cf --- /dev/null +++ b/docs/adrs/061.client.vendor-split.md @@ -0,0 +1,97 @@ +# 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-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 + +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. + +## 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 new file mode 100644 index 0000000..cc2fae4 --- /dev/null +++ b/docs/adrs/062.server.composition.md @@ -0,0 +1,66 @@ +# 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), 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 + +- 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. + +## 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 new file mode 100644 index 0000000..e7a6370 --- /dev/null +++ b/docs/adrs/063.cli.command-modules.md @@ -0,0 +1,36 @@ +# 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, 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 `loadConfig().port` never resolves 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. + +## 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 new file mode 100644 index 0000000..3d0b1ce --- /dev/null +++ b/docs/adrs/064.architecture.test-strategy.md @@ -0,0 +1,48 @@ +# ADR 064: architecture — Test strategy and coverage targets + +**SPEC:** [architecture](../specs/architecture.md) +**Status:** Accepted +**Date:** 2026-10-14 (coverage enforcement and listener snapshots revised after review) + +--- + +## 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 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 + +- **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. + +## 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 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. +- `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 new file mode 100644 index 0000000..74106eb --- /dev/null +++ b/docs/adrs/065.client.listener-teardown.md @@ -0,0 +1,39 @@ +# 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. + +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 + +- 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/adrs/066.client.output-keeps-scrolled-view.md b/docs/adrs/066.client.output-keeps-scrolled-view.md new file mode 100644 index 0000000..214b1bb --- /dev/null +++ b/docs/adrs/066.client.output-keeps-scrolled-view.md @@ -0,0 +1,44 @@ +# ADR 066: client — Output keeps a scrolled-back view on its text + +**SPEC:** [client](../specs/client.md) +**Status:** Accepted +**Date:** 2026-10-15 + +--- + +## 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. 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. + +## 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. 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 new file mode 100644 index 0000000..d942437 --- /dev/null +++ b/docs/adrs/067.client.synchronized-output.md @@ -0,0 +1,44 @@ +# ADR 067: client — Hold painting during an app's synchronized update + +**SPEC:** [client](../specs/client.md) +**Status:** Accepted +**Date:** 2026-10-15 + +--- + +## 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. 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 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. + +## 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. +- **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 + +- 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, 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/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..e870e32 --- /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; 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. 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.** 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 (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 + +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 new file mode 100644 index 0000000..392fa4a --- /dev/null +++ b/docs/specs/architecture.md @@ -0,0 +1,351 @@ +# SPEC: Architecture + +**Last Updated:** 2026-10-15 (`core/sync-hold.ts` and `core/sync-scan.ts`, ADR 067; `scripts/scroll-bench/`, ADR 068) + +--- + +## 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 | +| `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`) 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 | + +**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 + +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/ + version.ts util: package version lookup + 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, 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 + 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 + 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) + 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 + 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) + 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) + 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/ + 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; 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, 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, + 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, fake-window.ts, fake-resize-observer.ts, fake-context.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 + 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" +scripts/ + build.ts unchanged outputs + 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 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) +``` + +### 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 + 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) + 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 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, 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 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 + 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 +``` + +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. `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 + +`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`; 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`. +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 | + +`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. + +**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 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 | +|---|---|---| +| 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 | + +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 + +- **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 (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 + +- **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). 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). +- **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. 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. + +## 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 (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, 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/cli.md b/docs/specs/cli.md index d1c8a9e..7286878 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, 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 | Feature | Description | ADR | Done? | diff --git a/docs/specs/client.md b/docs/specs/client.md index 8e089c3..a782c39 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-15 --- @@ -19,30 +19,61 @@ 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-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 ← 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. 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 +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: 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 + 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); + 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; + 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/engine/official-replay.test.ts +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 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 `GET /s/:id` returns `dist/client.html` — a static HTML shell. On load, `client-browser.js`: 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) -5. Connects to `ws://<host>/ws/:id?cols=<cols>&rows=<rows>` over WebSocket +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 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` @@ -53,9 +84,14 @@ src/client/ ```ts { - cols, rows, fontSize, fontFamily, cursorStyle, cursorStyleBlink, scrollback, + 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, // used by the custom wheel handler, not passed to Terminal constructor + 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 } @@ -94,6 +130,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.` | — | @@ -134,10 +171,171 @@ 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. | -| `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, 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. + +**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`). 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)). + +## 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. `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: + +- 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 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. + +## 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 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). +- **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. + +`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 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. + +**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** 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 + ``` -Both can be active simultaneously. The canvas-based terminal has no DOM text selection — these are the only reliable copy paths. + ```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 + +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: + +| Sequence | Result | +|---|---| +| `52;c;<base64>`, `52;;<base64>`, 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 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 + +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` (`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 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 + +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 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; +- 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 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 @@ -159,20 +357,41 @@ 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 via client-side intercept | [ADR 013](../adrs/013.client.cursor-style.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) | ✅ | +| 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) | ✅ | +| 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 | 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) | ✅ | -| 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) | ✅ | -| 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) | ✅ | +| 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) | ✅ | +| 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 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) | ✅ | +| ~~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 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) | ✅ | | 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) | ✅ | +| 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. 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/docs/specs/config.md b/docs/specs/config.md index d6171ec..2ba2787 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-15 --- @@ -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`, `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. @@ -107,21 +107,32 @@ 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 | +| `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. | -| `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`. | +| `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 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). | +| `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. | | `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 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. + ### Theme keys All theme keys are optional; omitted keys fall back to the Campbell (Windows Terminal default) palette. @@ -133,6 +144,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 | @@ -162,14 +177,18 @@ 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", "cursorStyleBlink": true, "copyOnSelect": true, - "rightClickBehavior": "default", + "rightClickBehavior": "contextMenu", + "clipboardWrite": true, + "bellTitle": true, + "desktopNotifications": true, "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" } @@ -200,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? | @@ -209,9 +232,14 @@ 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) | ✅ | +| 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 | [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) | ✅ | | 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<string,string>` 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/deep-link.md b/docs/specs/deep-link.md index b94d6ae..5ef8429 100644 --- a/docs/specs/deep-link.md +++ b/docs/specs/deep-link.md @@ -121,13 +121,13 @@ 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/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`. @@ -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/docs/specs/key-bindings.md b/docs/specs/key-bindings.md index 0c8e88d..21862a5 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), 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` The `chars` value is the byte sequence the target TUI app expects for that key combo. Two encoding approaches exist: diff --git a/docs/specs/kitty-graphics.md b/docs/specs/kitty-graphics.md index 96f3418..f41c7c9 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-14 --- @@ -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 @@ -23,7 +24,7 @@ the rejected alternatives. ``` PTY ──► server ──► WebSocket ──► ws.onmessage │ - KittyGraphics.feed(data) (src/client/kitty.ts splitter) + KittyGraphics.feed(data) (features/kitty/graphics/kitty-graphics.ts) │ │ text segments APC commands │ │ @@ -31,10 +32,13 @@ 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 + (features/kitty/graphics/overlay-canvas.ts) +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=<id>[,p=<pid>] ; OK ESC \` — success, when the command carried an image id (`i` or `I`) and `q` did not suppress it. - `ESC _ G i=<id> ; E<CODE>:<message> ESC \` — failure. -- `CSI 4 ; <h> ; <w> t` (answer to `CSI 14 t`) — text-area size in pixels. -- `CSI 6 ; <ch> ; <cw> t` (answer to `CSI 16 t`) — cell size in pixels. -- `CSI 8 ; <rows> ; <cols> 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 `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 @@ -118,19 +125,40 @@ reuses the same image id. ``` src/client/ - kitty.ts ← pure: APC splitter, key=value parser, reply builders (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/ + 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/ ← 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 + 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 - **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 @@ -156,10 +184,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/docs/specs/scroll-benchmark.md b/docs/specs/scroll-benchmark.md new file mode 100644 index 0000000..2a29d5e --- /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, 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 + +| 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 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, 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 + +| 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` 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** (`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): + +| 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 (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 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): + +| 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, 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 + +| 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 | — | ⬜ | diff --git a/docs/specs/webtty.md b/docs/specs/webtty.md index fde0c25..07f716b 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-14 --- @@ -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. @@ -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":<total>}` 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 (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. + Session IDs appear directly in the URL path (`/s/:id`), so they must be valid URL path segments: - Allowed characters: `a-z`, `0-9`, `-`, `_`, `.` @@ -72,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? | @@ -83,3 +101,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 <path>` 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) | ✅ | diff --git a/package.json b/package.json index 203837d..f93496a 100644 --- a/package.json +++ b/package.json @@ -19,24 +19,25 @@ "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", "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" }, "dependencies": { "@lydell/node-pty": "1.2.0-beta.15", - "ghostty-web": "0.4.0-next.20.g1858a59", "ws": "8.21.3" }, "devDependencies": { "@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", 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..a3fd525 --- /dev/null +++ b/scripts/architecture/import-rules.test.ts @@ -0,0 +1,243 @@ +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/index.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/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'), +]; + +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 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', + "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';", + [], + ], + [ + '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', + "import { Terminal } from '../terminal';", + ['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';", + [], + ], + [ + '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..ea9241d --- /dev/null +++ b/scripts/architecture/import-rules.ts @@ -0,0 +1,186 @@ +// 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, 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}`, + ), +); + +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']; + +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: '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-import', + appliesToTests: false, + broken: ({ from, target }) => + VENDOR_PART_RE.test(from) && VENDOR_FACADES.has(target) + ? `imports the facade ${target}; 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..c1dac6f --- /dev/null +++ b/scripts/architecture/import-scan.test.ts @@ -0,0 +1,164 @@ +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 }], + ], + [ + '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';", + [{ 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..d027102 --- /dev/null +++ b/scripts/architecture/import-scan.ts @@ -0,0 +1,102 @@ +// 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, `*`, `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; + +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.replace(COMMENT_RE, ' ').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..b5aa620 --- /dev/null +++ b/scripts/architecture/ratchet-allowlist.ts @@ -0,0 +1,12 @@ +// 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'; + +/** The allowlist the real tree is checked against. */ +export const RATCHET_ALLOWLIST: Allowlist = { + strict: true, + sizes: {}, + rules: {}, +}; diff --git a/scripts/architecture/ratchet.test.ts b/scripts/architecture/ratchet.test.ts new file mode 100644 index 0000000..f0c797c --- /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('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', () => { + 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..f3fb482 --- /dev/null +++ b/scripts/architecture/size-limits.test.ts @@ -0,0 +1,74 @@ +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/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], + ['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..db2a686 --- /dev/null +++ b/scripts/architecture/size-limits.ts @@ -0,0 +1,73 @@ +// 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; + +/** + * 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, + [`${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: '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' }; +} + +/** 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..44bcd3b --- /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/index.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..60a7ea9 --- /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' + | 'below-feature' + | 'facade-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 { + 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/build.ts b/scripts/build.ts index 35c1e5c..6c63840 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -1,16 +1,18 @@ +// 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 { 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 +28,6 @@ const clientResult = await Bun.build({ target: 'browser', format: 'esm', minify: true, - external: ['ghostty-web'], naming: 'client-browser.js', }); @@ -36,25 +37,55 @@ 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'), -); -fs.copyFileSync( - path.join(ghosttyWebRoot, 'ghostty-vt.wasm'), - path.resolve('./dist/ghostty-vt.wasm'), +// 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'), +) 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 the wasm ship here instead. +const vendorDir = './src/client/vendor/ghostty-web'; +fs.writeFileSync( + path.resolve('./dist/THIRD_PARTY_LICENSES'), + [ + 'ghostty-web (TypeScript bundled in client-browser.js)', + 'https://github.com/coder/ghostty-web', + '', + fs.readFileSync(path.resolve(vendorDir, 'LICENSE'), 'utf8').trim(), + '', + '---', + '', + `Ghostty libghostty-vt (${officialSidecar.file}, unmodified official build)`, + 'https://github.com/ghostty-org/ghostty', + '', + 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'), ); const totalFiles = serverResult.outputs.length + clientResult.outputs.length + 4; diff --git a/scripts/check-architecture.test.ts b/scripts/check-architecture.test.ts new file mode 100644 index 0000000..e68fd4d --- /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/tests/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, '..'))); +} diff --git a/scripts/check-coverage.test.ts b/scripts/check-coverage.test.ts new file mode 100644 index 0000000..c6ec0a4 --- /dev/null +++ b/scripts/check-coverage.test.ts @@ -0,0 +1,273 @@ +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, + coverageLayerOf, + type FileCoverage, + formatLayer, + LAYER_TARGETS, + NOT_LOADED, + PURE_MODULES, + parseLcov, + ratio, + runCoverageCheck, + sourceFiles, + 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('coverageLayerOf', () => { + 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(coverageLayerOf(file)?.name).toBe(layer); + }); + } +}); + +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(coverageLayerOf(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('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[] = []; + 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..7d3a605 --- /dev/null +++ b/scripts/check-coverage.ts @@ -0,0 +1,245 @@ +#!/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'; +import { isTestCode } from './architecture/source-file'; + +/** 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/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', + '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/page-zoom.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', +]; + +/** + * 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. */ +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; +} + +/** 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)); +} + +/** 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 = coverageLayerOf(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, 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; + 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%`); + } + } + 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, + 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, sourceFiles(root)); + 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, '..'))); +} 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); } diff --git a/scripts/ghostty-vt/inspect-wasm.test.ts b/scripts/ghostty-vt/inspect-wasm.test.ts new file mode 100644 index 0000000..27d6876 --- /dev/null +++ b/scripts/ghostty-vt/inspect-wasm.test.ts @@ -0,0 +1,43 @@ +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, IMPORTING_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 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) => { + await expect(inspectWasm(bytes)).rejects.toThrow(message); + }); +}); diff --git a/scripts/ghostty-vt/inspect-wasm.ts b/scripts/ghostty-vt/inspect-wasm.ts new file mode 100644 index 0000000..59d8110 --- /dev/null +++ b/scripts/ghostty-vt/inspect-wasm.ts @@ -0,0 +1,108 @@ +// 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, 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)); + 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, + 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.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/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.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/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.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/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/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..cf9efda --- /dev/null +++ b/scripts/ghostty-vt/test-support/tiny-wasm.ts @@ -0,0 +1,53 @@ +// 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]); + +/** 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]), +]); diff --git a/scripts/scroll-bench/bench-args.test.ts b/scripts/scroll-bench/bench-args.test.ts new file mode 100644 index 0000000..3dc7523 --- /dev/null +++ b/scripts/scroll-bench/bench-args.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from 'bun:test'; +import { + basesForRun, + DEFAULT_CDP_PORT, + DEFAULT_RUNS, + parseBase, + parseBenchArgs, + USAGE, +} 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'); + }); + 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', () => { + 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'], + ['a repeated input', ['--inputs', 'wheel,wheel'], 'repeated'], + ['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..5ce6434 --- /dev/null +++ b/scripts/scroll-bench/bench-args.ts @@ -0,0 +1,114 @@ +// 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, INPUT_PROFILES, 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; + /** 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. */ +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] + [--dump DIR] + + --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}) + --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')}`; + +/** 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 { + 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}`); + } + 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 { + 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(', ')}`); + } + } + 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[] = []; + let scenariosFile = ''; + 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]; + 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 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, ...(dump ? { dump } : {}) }; +} diff --git a/scripts/scroll-bench/bench.ts b/scripts/scroll-bench/bench.ts new file mode 100644 index 0000000..cbe5044 --- /dev/null +++ b/scripts/scroll-bench/bench.ts @@ -0,0 +1,141 @@ +#!/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. +// 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: +// +// "/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 +// +// Start each webtty build to compare on its own port, then: +// +// 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 +// 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 { 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, 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'; + +/** 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, + 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); + 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, scenario.pointer, sink)), + }; + } + 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. + await cdp.navigate('about:blank').catch(() => {}); + await deleteSession(session); + } +} + +async function main(argv: readonly string[]): Promise<number> { + if (argv.includes('--help') || argv.includes('-h')) { + console.log(USAGE); + return 0; + } + 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 basesForRun(args.bases, run)) { + try { + 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) { + failed++; + const message = error instanceof Error ? error.message : String(error); + console.error( + `# failed ${base.label} ${scenario.name} ${input} run ${run}: ${message}`, + ); + } + await sleep(500); + } + } + } + } + } finally { + cdp.close(); + } + console.log(`\n${summaryTable(results)}`); + if (failed) console.error(`# ${failed} run(s) failed; the table leaves them out`); + return failed ? 1 : 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); + 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 new file mode 100644 index 0000000..1d8d3be --- /dev/null +++ b/scripts/scroll-bench/cdp.ts @@ -0,0 +1,168 @@ +// 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. 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 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, 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>; + /** 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>; + /** The page as a PNG, base64. */ + screenshot(): Promise<string>; + close(): void; +} + +/** The keys the benchmark presses, with what CDP needs to send each. */ +const KEYS = { + Enter: { code: 'Enter', windowsVirtualKeyCode: 13, text: '\r' }, +} 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)); + +/** 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; +} + +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; + let closed: Error | null = null; + 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); + } + }); + 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; + 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(); + 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) { + let off = () => {}; + const loaded = new Promise<void>((resolve) => { + off = on('Page.loadEventFired', () => resolve()); + }); + 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 }); + }, + 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 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/inputs.test.ts b/scripts/scroll-bench/inputs.test.ts new file mode 100644 index 0000000..80a232d --- /dev/null +++ b/scripts/scroll-bench/inputs.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from 'bun:test'; +import { + type Clock, + 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 has positive deltas and gaps, and a frame step window', () => { + for (const name of INPUT_NAMES) { + const { events, maxFramePx } = INPUT_PROFILES[name]; + expect(events.every((e) => e.deltaPx > 0 && e.gapMs > 0)).toBe(true); + 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', () => { + 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]); + }); +}); + +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..3cc4fcf --- /dev/null +++ b/scripts/scroll-bench/inputs.ts @@ -0,0 +1,134 @@ +// 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. 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'; + +/** 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, 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[] => + 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: 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: 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], + [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), + 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', + 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 })), + ], + // 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, + }, +}; + +/** 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 })); +} + +/** 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..592c96e --- /dev/null +++ b/scripts/scroll-bench/measure.ts @@ -0,0 +1,148 @@ +// 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 { RunRecord } from './dump'; +import { type Direction, type InputProfile, playInput } from './inputs'; +import { + averageStepPx, + blinkFrames, + matchPairs, + type PaintStats, + paintStats, + type StreamStats, + scrollWindow, + streamStats, +} from './metrics'; +import { decodeProfiles, readPaintCounter, resetPaintCounter, viewportPoint } from './page'; +import { CENTRE, type Pointer, type Scenario } from './scenarios'; +import { type CastOptions, startRecording } from './screencast'; + +/** + * 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; +/** 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. */ +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); + // 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); + await cdp.key('Enter'); + await sleep(scenario.waitMs); + if (scenario.enter2) { + await cdp.key('Enter'); + await sleep(scenario.enter2WaitMs); + } +} + +/** 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; + 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, + pointer: Pointer = CENTRE, + sink?: RecordSink, +): Promise<ScrollMeasure> { + const at = await viewportPoint(cdp, pointer); + const startPng = await startShot(cdp, sink); + await resetPaintCounter(cdp); + 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); + const profiles = await decodeProfiles( + cdp, + cast.map((f) => f.jpeg), + { x: 8, width: 600, channels: 'rg' }, + ); + 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: blinks.length, + avgStepPx: averageStepPx(pairs, window), + ...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, + pointer: Pointer = CENTRE, + sink?: RecordSink, +): Promise<StreamStats> { + 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), + // 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/metrics.test.ts b/scripts/scroll-bench/metrics.test.ts new file mode 100644 index 0000000..b0c4a28 --- /dev/null +++ b/scripts/scroll-bench/metrics.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, test } from 'bun:test'; +import { + atBottom, + averageStepPx, + BAR_BRIGHTNESS, + bestShift, + blinkFrames, + matchPairs, + median, + nothingMoved, + type PaintFrame, + type Profile, + paintStats, + type StreamFrame, + scrollWindow, + streamStats, +} from './metrics'; + +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); + +/** 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); + }); + + 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 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]], + [ + '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', () => { + const stepOf = (profiles: Profile[]) => + averageStepPx(matchPairs(profiles, SCROLL_WINDOW), SCROLL_WINDOW); + + test('the mean distance of the frames that moved', () => { + expect(stepOf(smooth(6, 4))).toBe(4); + }); + test('0 when nothing moved', () => { + 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); + }); +}); + +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)); + } +}); + +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 new file mode 100644 index 0000000..e963b01 --- /dev/null +++ b/scripts/scroll-bench/metrics.ts @@ -0,0 +1,204 @@ +// 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 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. 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; + let rows = 0; + for (let y = margin; y < a.length - margin; y++) { + sum += Math.abs(b[y] - a[y - s]); + rows++; + } + const error = sum / rows; + 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: 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[], + 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 <= 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). 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); +} + +/** 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), + }; +} + +/** + * 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; + 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.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/page.ts b/scripts/scroll-bench/page.ts new file mode 100644 index 0000000..3b26d33 --- /dev/null +++ b/scripts/scroll-bench/page.ts @@ -0,0 +1,112 @@ +// 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'; +import type { Pointer } from './scenarios'; + +/** + * 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 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 new file mode 100644 index 0000000..9d76772 --- /dev/null +++ b/scripts/scroll-bench/scenarios.example.json @@ -0,0 +1,90 @@ +[ + { + "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; 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": "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, 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", + "pointer": { + "x": 0.3, + "y": 0.5 + } + }, + { + "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..deaa60f --- /dev/null +++ b/scripts/scroll-bench/scenarios.test.ts @@ -0,0 +1,90 @@ +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', + pointer: { x: 0.5, y: 0.5 }, + }); + }); + + 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', + pointer: { x: 0.3, y: 0.6 }, + }, + 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', + pointer: { x: 0.3, y: 0.6 }, + }); + }); + + 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'], + ['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)); + } +}); + +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..1dc31d1 --- /dev/null +++ b/scripts/scroll-bench/scenarios.ts @@ -0,0 +1,138 @@ +// 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'; + /** + * 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; + 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', + 'pointer', +]); + +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`); +} + +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); +} + +/** 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'), + pointer: pointer(`${at} pointer`, raw.pointer), + }; +} + +/** 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..db79adc --- /dev/null +++ b/scripts/scroll-bench/screencast.ts @@ -0,0 +1,45 @@ +// 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) }); + // 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); + 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..950c089 --- /dev/null +++ b/scripts/scroll-bench/session-api.test.ts @@ -0,0 +1,58 @@ +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(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(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 new file mode 100644 index 0000000..39c763a --- /dev/null +++ b/scripts/scroll-bench/session-api.ts @@ -0,0 +1,46 @@ +// 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 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 ${api}: ${res.status}`); + } +} diff --git a/scripts/scroll-bench/stream.sh b/scripts/scroll-bench/stream.sh new file mode 100755 index 0000000..bfe2383 --- /dev/null +++ b/scripts/scroll-bench/stream.sh @@ -0,0 +1,28 @@ +#!/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)) + +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. + # 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 +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.'; +} 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); + }); +}); diff --git a/scripts/update-ghostty-vt.ts b/scripts/update-ghostty-vt.ts new file mode 100644 index 0000000..395249d --- /dev/null +++ b/scripts/update-ghostty-vt.ts @@ -0,0 +1,86 @@ +// 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'; +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/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'; +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, + '..', + 'src', + 'client', + 'vendor', + 'ghostty-vt', +); + +/** 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>; +} + +/** 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 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(deps.dir, sidecar.file))), + ); + deps.log( + `OK ${sidecar.file} ${sidecar.sha256}, Ghostty ${sidecar.commit}, ${wasm.buildInfo.optimize}, ` + + `signed by ${sig.keyId} (${sig.trustedComment})`, + ); +} + +/** 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(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) { + 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); +} diff --git a/src/cli/api-client.test.ts b/src/cli/api-client.test.ts new file mode 100644 index 0000000..7022367 --- /dev/null +++ b/src/cli/api-client.test.ts @@ -0,0 +1,339 @@ +// 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, + 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("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; + 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.test.ts b/src/cli/browser-opener.test.ts new file mode 100644 index 0000000..5c4f94e --- /dev/null +++ b/src/cli/browser-opener.test.ts @@ -0,0 +1,74 @@ +// 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, + 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..32aaf43 --- /dev/null +++ b/src/cli/browser-opener.ts @@ -0,0 +1,55 @@ +// 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. + * - 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; +} + +/** 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, + 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/cli-args.test.ts b/src/cli/cli-args.test.ts new file mode 100644 index 0000000..f69237f --- /dev/null +++ b/src/cli/cli-args.test.ts @@ -0,0 +1,39 @@ +// 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'; + +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..18c0304 --- /dev/null +++ b/src/cli/cli-deps.ts @@ -0,0 +1,73 @@ +// 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 { 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'; + +/** 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>; + /** Asks the server to stop and waits until it is gone; false if it did not stop. */ + stop(): Promise<boolean>; +} + +/** 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; + 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, 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, + // 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/commands.test.ts b/src/cli/commands.test.ts deleted file mode 100644 index b953362..0000000 --- a/src/cli/commands.test.ts +++ /dev/null @@ -1,1020 +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 '../utils.test'; -import { bytesToChars, bytesToDisplay, toBrowserHost } from './commands'; -import * as httpModule from './http'; - -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('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'); - }); - - 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(httpModule, '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(httpModule, '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(httpModule, '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(httpModule, '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(httpModule, '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(httpModule, '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 d2c7adf..0000000 --- a/src/cli/commands.ts +++ /dev/null @@ -1,369 +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 { 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; -} - -/** - * 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(); - let buf = ''; - - 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; - 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..ca8dd63 --- /dev/null +++ b/src/cli/commands/edit-config.test.ts @@ -0,0 +1,92 @@ +// 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'; +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..ad55889 --- /dev/null +++ b/src/cli/commands/go.test.ts @@ -0,0 +1,63 @@ +// 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'; +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..1f7c089 --- /dev/null +++ b/src/cli/commands/key-format.test.ts @@ -0,0 +1,55 @@ +// 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'; + +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..5ad52dd --- /dev/null +++ b/src/cli/commands/key.test.ts @@ -0,0 +1,170 @@ +// 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'; + +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..9ec78ee --- /dev/null +++ b/src/cli/commands/list.test.ts @@ -0,0 +1,56 @@ +// 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'; +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..d2a8d0b --- /dev/null +++ b/src/cli/commands/remove.test.ts @@ -0,0 +1,64 @@ +// 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'; +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..f88374e --- /dev/null +++ b/src/cli/commands/rename.test.ts @@ -0,0 +1,64 @@ +// 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'; +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..c19f8dc --- /dev/null +++ b/src/cli/commands/run.test.ts @@ -0,0 +1,146 @@ +// 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'; +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..123ce85 --- /dev/null +++ b/src/cli/commands/start-stop.test.ts @@ -0,0 +1,47 @@ +// 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'; + +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/help-text.test.ts b/src/cli/help-text.test.ts new file mode 100644 index 0000000..7837330 --- /dev/null +++ b/src/cli/help-text.test.ts @@ -0,0 +1,30 @@ +// Pins the exact text `webtty help` prints (ADR 063). +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/http.test.ts b/src/cli/http.test.ts deleted file mode 100644 index f06cc9a..0000000 --- a/src/cli/http.test.ts +++ /dev/null @@ -1,354 +0,0 @@ -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'; - -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('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'); - expect(await isServerRunning()).toBe(true); - }); - - 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'); - 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); - - 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, - 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(); - }); - - 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(() => {}); - - globalThis.fetch = mock(async () => { - throw new Error('not yet'); - }) 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(), - ); - }); -}); diff --git a/src/cli/http.ts b/src/cli/http.ts deleted file mode 100644 index 861c3e3..0000000 --- a/src/cli/http.ts +++ /dev/null @@ -1,151 +0,0 @@ -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'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -/** 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 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 { - const res = await fetch(`${getBaseUrl()}/api/sessions`); - if (!res.ok) return false; - const body = await res.json(); - return Array.isArray(body); - } catch { - 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> { - 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 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/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/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..31b77fd --- /dev/null +++ b/src/cli/main.test.ts @@ -0,0 +1,196 @@ +// 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'; +import { createCliDeps } from './cli-deps'; +import { helpText } from './help-text'; +import { CLI_COMMANDS, type CliCommands, main } from './main'; +import { fakeCliDeps } from './test-support/fake-cli-deps'; + +/** A command table whose entries record `name(args)`, over recording fake deps. */ +function fakeCommands() { + const calls: Array<[string, ...unknown[]]> = []; + const record = + (name: string) => + (...args: unknown[]) => { + // 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[]) => { + record(name)(...args); + }; + const commands: CliCommands = { + go: asyncRecord('go'), + list: asyncRecord('list'), + remove: asyncRecord('remove'), + rename: asyncRecord('rename'), + stop: asyncRecord('stop'), + start: asyncRecord('start'), + editConfig: record('editConfig'), + run: asyncRecord('run'), + key: record('key'), + }; + return { commands, calls }; +} + +/** Runs main as `webtty ...args` and returns what it recorded. */ +async function run(...args: string[]) { + 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', { 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'], [['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) { + 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('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 { loadConfig } = await import('../config'); + const { getVersion } = await import('../version'); + expect(deps.client).toBeInstanceOf(WebttyClient); + expect(deps.config).toBe(loadConfig); + 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', () => { + 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(); + } + }); + + 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 new file mode 100644 index 0000000..bef4bc9 --- /dev/null +++ b/src/cli/main.ts @@ -0,0 +1,110 @@ +// 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, 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)); + + if (!command || GO_ALIASES.has(command)) { + await commands.go({ id: rest[0], baseDir }, deps); + return; + } + switch (command) { + case 'ls': + case 'list': + await commands.list({ filter: rest[0] }, deps); + return; + case 'rm': + case 'remove': + await commands.remove({ id: rest[0] }, deps); + return; + case 'mv': + case 'move': + case 'rename': + await commands.rename({ id: rest[0], newId: rest[1] }, deps); + return; + case 'stop': + await commands.stop(deps); + return; + case 'start': + await commands.start(deps); + return; + case 'config': + commands.editConfig(deps); + return; + case 'run': { + const [id, cmd, ...args] = rest; + if (!id) { + deps.err('usage: webtty run <id> [cmd [args...]]'); + deps.exit(1); + return; + } + await commands.run({ id, cmd, args }, deps); + return; + } + case 'key': + commands.key(deps); + 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); + } +} diff --git a/src/cli/server-process.test.ts b/src/cli/server-process.test.ts new file mode 100644 index 0000000..2a917ca --- /dev/null +++ b/src/cli/server-process.test.ts @@ -0,0 +1,263 @@ +// 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, + logPath, + resolveServerEntry, + type ServerProcessDeps, + startServer, + stopServer, +} from './server-process'; + +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("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); + 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', () => { + const client = (fetchFn: FetchLike) => new WebttyClient('http://127.0.0.1:1', fetchFn); + + test('returns true when server stops after POST succeeds', async () => { + 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 }); + if (++probes >= 2) throw new Error('connection refused'); + return new Response('[]', { status: 200 }); + }); + + 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 () => { + 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 () => { + const down = client(async () => { + throw new Error('connection refused'); + }); + expect(await stopServer(down)).toBe(false); + }); + + test('returns false when server does not come down within timeout', async () => { + 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 new file mode 100644 index 0000000..46d75a1 --- /dev/null +++ b/src/cli/server-process.ts @@ -0,0 +1,182 @@ +// Starting and stopping the webtty server as a detached background process. A service over +// 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 { configuredClient, type WebttyClient } from './api-client'; + +/** 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: () => configuredClient().isRunning(), + 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 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( + client: WebttyClient = configuredClient(), + timeoutMs = 5000, +): Promise<boolean> { + try { + if (!(await client.stop())) return false; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!(await client.isRunning())) return true; + await new Promise((r) => setTimeout(r, 100)); + } + return false; + } catch { + return false; + } +} 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/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 }; +} diff --git a/src/client/app/browser-env.test.ts b/src/client/app/browser-env.test.ts new file mode 100644 index 0000000..8a71e6a --- /dev/null +++ b/src/client/app/browser-env.test.ts @@ -0,0 +1,105 @@ +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, frameWindow } 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(); + }); + + 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 new file mode 100644 index 0000000..e1868af --- /dev/null +++ b/src/client/app/browser-env.ts @@ -0,0 +1,77 @@ +// 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), + }; +} + +/** 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/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..f71d74b --- /dev/null +++ b/src/client/app/client-config.ts @@ -0,0 +1,22 @@ +// 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 { ClientConfig } from '../../config'; + +/** + * 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, 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'>>; + +/** + * 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/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 new file mode 100644 index 0000000..379ce3f --- /dev/null +++ b/src/client/app/client-context.ts @@ -0,0 +1,35 @@ +// 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. client-context.test.ts holds every installer to it at compile time. + */ +export type FeatureInstaller<Deps, F extends Feature = Feature> = ( + ctx: ClientContext, + deps: Deps, +) => F; 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/platform.test.ts b/src/client/app/platform.test.ts new file mode 100644 index 0000000..019a1cb --- /dev/null +++ b/src/client/app/platform.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { createDomWindow, type DomWindow } from '../test-support/dom-window'; +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([ + [{ 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..fdd207a --- /dev/null +++ b/src/client/app/platform.ts @@ -0,0 +1,31 @@ +// 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. + +/** 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`. + */ +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/start-client.test.ts b/src/client/app/start-client.test.ts new file mode 100644 index 0000000..da79339 --- /dev/null +++ b/src/client/app/start-client.test.ts @@ -0,0 +1,320 @@ +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 { 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('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(); + 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("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(); + 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 }); + }); +}); diff --git a/src/client/app/start-client.ts b/src/client/app/start-client.ts new file mode 100644 index 0000000..63ff8fc --- /dev/null +++ b/src/client/app/start-client.ts @@ -0,0 +1,198 @@ +// 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 { 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'; +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 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; + 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 })); + // 9-13. Find, the key dispatcher, font zoom, input to the PTY and paste + installInput(ctx, env, store, { pty, graphics, smoothScroll, layout }); + return store; +} + +/** 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 }; +} + +/** 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 })); +} diff --git a/src/client/app/terminal-options.test.ts b/src/client/app/terminal-options.test.ts new file mode 100644 index 0000000..d7922de --- /dev/null +++ b/src/client/app/terminal-options.test.ts @@ -0,0 +1,50 @@ +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, + scrollback: 256 * 1024, + 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/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 87% rename from src/client/theme.ts rename to src/client/app/terminal-theme.ts index c5c9350..3bb3d12 100644 --- a/src/client/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/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 deleted file mode 100644 index 53b3295..0000000 --- a/src/client/compose.ts +++ /dev/null @@ -1,181 +0,0 @@ -/** - * 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. - */ - -import type { Terminal } from 'ghostty-web'; - -// ghostty-web's defaults for a theme without foreground/background. -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; - - constructor( - private readonly term: Terminal, - private readonly container: HTMLElement, - private readonly theme: { foreground?: string; background?: string }, - ) { - const o = this.overlay; - o.dataset.compose = 'true'; - o.style.position = 'absolute'; - o.style.display = 'none'; - o.style.pointerEvents = 'none'; - o.style.whiteSpace = 'pre'; - o.style.overflow = 'hidden'; - o.style.zIndex = '10'; - 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); - } - - private readonly tick = (): void => { - window.requestAnimationFrame(this.tick); - this.update(); - }; - - /** Re-anchor the textarea (and the overlay, while composing). */ - 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); - } - - 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(); - }; - - private readonly onCompositionUpdate = (e: CompositionEvent): void => { - this.composing = true; - const text = e.data ?? ''; - if (!text) { - this.overlay.style.display = 'none'; - return; - } - this.overlay.textContent = text; - this.update(); - this.overlay.style.display = 'block'; - }; - - private readonly onCompositionEnd = (): void => { - this.composing = false; - this.overlay.style.display = 'none'; - this.overlay.textContent = ''; - }; - - private placeOverlay(spot: CursorSpot): 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)); - o.style.fontFamily = this.term.options.fontFamily; - o.style.fontSize = `${this.term.options.fontSize}px`; - o.style.color = this.theme.foreground ?? DEFAULT_FOREGROUND; - 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 }; - } - - private terminalCanvas(): HTMLCanvasElement | null { - return this.container.querySelector('canvas:not([data-edge])'); - } -} - -function setPx( - el: HTMLElement, - prop: 'left' | 'top' | 'width' | 'height' | 'lineHeight' | 'maxWidth', - value: number, -): void { - const px = `${value}px`; - if (el.style[prop] !== px) el.style[prop] = px; -} diff --git a/src/client/connection/close-codes.test.ts b/src/client/connection/close-codes.test.ts new file mode 100644 index 0000000..370ebf5 --- /dev/null +++ b/src/client/connection/close-codes.test.ts @@ -0,0 +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 { WS_CLOSE } from '../../server/ws/close-codes'; +import { CLOSE_CODE } from './close-codes'; + +describe('CLOSE_CODE', () => { + 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]); + }, + ); + + test('the codes the tab closes on', () => { + expect(CLOSE_CODE).toEqual({ SERVER_STOPPED: 1001, SESSION_GONE: 4001, FELL_BEHIND: 4003 }); + }); +}); 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-ack.test.ts b/src/client/connection/output-ack.test.ts new file mode 100644 index 0000000..f4ef0f9 --- /dev/null +++ b/src/client/connection/output-ack.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from 'bun:test'; +import { ACK_EVERY, ACK_IDLE_MS, OutputAck } from './output-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([]); + }); + + 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 })]); + }); +}); diff --git a/src/client/connection/output-ack.ts b/src/client/connection/output-ack.ts new file mode 100644 index 0000000..0946352 --- /dev/null +++ b/src/client/connection/output-ack.ts @@ -0,0 +1,62 @@ +// 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. */ +export const ACK_IDLE_MS = 100; + +/** 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: AckTimers = { + set: (fn, ms) => setTimeout(fn, ms), + clear: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>), +}; + +/** + * 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":<total>}`, 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: AckTimers = 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/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/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/connection/replay-window.test.ts b/src/client/connection/replay-window.test.ts new file mode 100644 index 0000000..8e42854 --- /dev/null +++ b/src/client/connection/replay-window.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from 'bun:test'; +import { DEFAULT_FLOW_LIMITS } from '../../server/output-pacer'; +import { REPLAY_PIECE, REPLAY_QUIET_MS, ReplayWindow } from './replay-window'; + +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/connection/replay-window.ts b/src/client/connection/replay-window.ts new file mode 100644 index 0000000..4b738a8 --- /dev/null +++ b/src/client/connection/replay-window.ts @@ -0,0 +1,57 @@ +// 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 + * 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/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`; +} 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<typeof mock> }; - 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 a53dcb2..0000000 --- a/src/client/cursor.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { Terminal } from '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/edge.ts b/src/client/edge.ts deleted file mode 100644 index c8b20f7..0000000 --- a/src/client/edge.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * 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. - * - * 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, - }; -} - -interface Strip { - el: HTMLCanvasElement; - ctx: CanvasRenderingContext2D; - visible: boolean; -} - -function createStrip(): Strip | null { - const el = document.createElement('canvas'); - const ctx = el.getContext('2d'); - if (!ctx) return null; - el.style.position = 'absolute'; - el.style.pointerEvents = 'none'; - el.style.display = 'none'; - el.dataset.edge = 'true'; - return { el, ctx, visible: false }; -} - -function place(strip: Strip, rect: Rect | null, dpr: number): void { - if (!rect) { - strip.visible = false; - strip.el.style.display = 'none'; - return; - } - strip.visible = true; - strip.el.style.display = 'block'; - strip.el.style.left = `${rect.x}px`; - strip.el.style.top = `${rect.y}px`; - strip.el.style.width = `${rect.width}px`; - strip.el.style.height = `${rect.height}px`; - strip.el.width = Math.max(1, Math.round(rect.width * dpr)); - strip.el.height = Math.max(1, Math.round(rect.height * dpr)); - strip.ctx.imageSmoothingEnabled = false; -} - -export class EdgeExtender { - private readonly bottom = createStrip(); - private readonly right = createStrip(); - private frame: number | null = null; - - constructor( - private readonly container: HTMLElement, - private readonly padding: number, - ) { - // Appended after the terminal canvas, so `querySelector('canvas')` still finds that one. - for (const strip of [this.bottom, this.right]) { - if (strip) container.append(strip.el); - } - } - - /** Reposition the strips. Call after every fit, when the canvas or padding may have moved. */ - update(): void { - const canvas = this.terminalCanvas(); - if (!canvas) return; - const rects = computeEdgeRects( - { - left: canvas.offsetLeft, - top: canvas.offsetTop, - width: canvas.offsetWidth, - height: canvas.offsetHeight, - }, - { width: this.container.clientWidth, height: this.container.clientHeight }, - this.padding, - ); - const dpr = window.devicePixelRatio || 1; - if (this.bottom) place(this.bottom, rects.bottom, dpr); - if (this.right) place(this.right, rects.right, dpr); - if (this.frame === null && (this.bottom?.visible || this.right?.visible)) { - this.frame = window.requestAnimationFrame(this.paint); - } - } - - private terminalCanvas(): HTMLCanvasElement | null { - return this.container.querySelector('canvas:not([data-edge])'); - } - - private readonly paint = (): void => { - this.frame = null; - const src = this.terminalCanvas(); - if (!src || src.width === 0 || src.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); - } - 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); - } - if (this.bottom?.visible || this.right?.visible) { - this.frame = window.requestAnimationFrame(this.paint); - } - }; -} 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.test.ts b/src/client/features/clipboard/clipboard-writer.test.ts new file mode 100644 index 0000000..3b41cdc --- /dev/null +++ b/src/client/features/clipboard/clipboard-writer.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'bun:test'; +import { ClipboardWriter } from './clipboard-writer'; + +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/features/clipboard/clipboard-writer.ts b/src/client/features/clipboard/clipboard-writer.ts new file mode 100644 index 0000000..14a5721 --- /dev/null +++ b/src/client/features/clipboard/clipboard-writer.ts @@ -0,0 +1,67 @@ +// 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>; +} + +/** + * 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<string>(); + + 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<void> { + 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/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 new file mode 100644 index 0000000..1207241 --- /dev/null +++ b/src/client/features/compose/composer.ts @@ -0,0 +1,142 @@ +/** + * 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 + * 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 { terminalCanvas } from '../../layout/terminal-canvas'; +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: HTMLDivElement; + private readonly store = new DisposableStore(); + private composing = false; + private frame = 0; + + constructor( + 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 = deps.document.createElement('div'); + this.overlay = o; + o.dataset.compose = 'true'; + o.style.position = 'absolute'; + o.style.display = 'none'; + o.style.pointerEvents = 'none'; + o.style.whiteSpace = 'pre'; + o.style.overflow = 'hidden'; + o.style.zIndex = '10'; + o.style.textDecoration = 'underline'; + container.append(o); + + 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. + private readonly tick = (): void => { + if (!this.composing) { + this.frame = 0; + return; + } + this.frame = this.deps.window.requestAnimationFrame(this.tick); + this.update(); + }; + + /** Re-place the overlay over the cursor cell. */ + update(): void { + const spot = this.term.getCursorCellOffset(); + 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 = this.deps.window.requestAnimationFrame(this.tick); + }; + + private readonly onCompositionUpdate = (e: CompositionEvent): void => { + this.composing = true; + if (!this.frame) this.frame = this.deps.window.requestAnimationFrame(this.tick); + const text = e.data ?? ''; + if (!text) { + this.overlay.style.display = 'none'; + return; + } + this.overlay.textContent = text; + this.update(); + this.overlay.style.display = 'block'; + }; + + private readonly onCompositionEnd = (): void => { + this.composing = false; + this.overlay.style.display = 'none'; + this.overlay.textContent = ''; + }; + + private placeOverlay(spot: { left: number; top: number }): void { + const o = this.overlay; + const canvas = terminalCanvas(this.container); + 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; + o.style.background = this.theme.background ?? DEFAULT_BACKGROUND; + } + + private cell(): { width: number; height: number } { + const metrics = this.term.renderer?.getMetrics(); + return { width: metrics?.width ?? 0, height: metrics?.height ?? 0 }; + } +} + +function setPx(el: HTMLElement, prop: keyof OverlayBox, value: number): void { + const px = `${value}px`; + if (el.style[prop] !== px) el.style[prop] = px; +} diff --git a/src/client/features/find/find-bar.test.ts b/src/client/features/find/find-bar.test.ts new file mode 100644 index 0000000..6934113 --- /dev/null +++ b/src/client/features/find/find-bar.test.ts @@ -0,0 +1,238 @@ +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'; + +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'); + }); + + 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(''); + }); + + 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('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('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 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('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('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 new file mode 100644 index 0000000..cadd0e3 --- /dev/null +++ b/src/client/features/find/find-bar.ts @@ -0,0 +1,205 @@ +/** + * 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. + * + * 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 { 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; + previous(): void; + /** The bar closed: end the search and give the terminal its focus back. */ + close(): void; +} + +/** 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 colours the bar takes from the terminal theme. */ +export type FindBarTheme = Record<string, string | undefined>; + +/** 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 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: 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 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'; + + 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 + this.root.append( + label, + 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(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; + } + + /** + * 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(); + } + + /** 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; + const action = fieldKey(e, this.shortcutActionOf); + 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 = this.deps.setTimeout(() => this.flush(), FIND_DELAY_MS); + } + + private cancel(): void { + if (this.timer !== null) this.deps.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); + } +} + +/** 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-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/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/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/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/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; + }; +} 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/features/keyboard/shortcut-map.test.ts b/src/client/features/keyboard/shortcut-map.test.ts new file mode 100644 index 0000000..9e0b8a1 --- /dev/null +++ b/src/client/features/keyboard/shortcut-map.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from 'bun:test'; +import { type Shortcut, shortcutFor } from './shortcut-map'; + +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('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(); + }); +}); + +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(); + }); +}); + +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/features/keyboard/shortcut-map.ts b/src/client/features/keyboard/shortcut-map.ts new file mode 100644 index 0000000..2004f80 --- /dev/null +++ b/src/client/features/keyboard/shortcut-map.ts @@ -0,0 +1,74 @@ +// 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 + * 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' + | 'promptPrevious' + | 'promptNext'; + performable: boolean; +} + +type Keys = Pick<KeyboardEvent, 'code' | 'ctrlKey' | 'shiftKey' | 'altKey' | 'metaKey'>; + +/** + * 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 (trigger === 'Escape') return { action: 'findClose', performable: true }; + if (mac) { + 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 }; + 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; + } + switch (trigger) { + case 'ctrl+shift+KeyF': + return { action: 'find', performable: false }; + 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 }; + case 'ctrl+shift+ArrowUp': + return { action: 'promptPrevious', performable: false }; + case 'ctrl+shift+ArrowDown': + return { action: 'promptNext', performable: false }; + } + return null; +} 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/graphics.test.ts b/src/client/features/kitty/graphics/kitty-graphics.test.ts similarity index 58% rename from src/client/graphics.test.ts rename to src/client/features/kitty/graphics/kitty-graphics.test.ts index 8f4ddab..c2918df 100644 --- a/src/client/graphics.test.ts +++ b/src/client/features/kitty/graphics/kitty-graphics.test.ts @@ -1,281 +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 { KittyGraphics, type Limits } from './graphics'; -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. - -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'; -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; - }, - createElement: () => ({ dataset: {}, style: {}, width: 0, height: 0, getContext: () => null }), - }; - g.window = { devicePixelRatio: 1 }; - g.requestAnimationFrame = () => 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 }, -): 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: () => null }; - 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 { deviceCellSize } from '../protocol/cell-geometry'; +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(); @@ -283,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')); @@ -295,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')); @@ -380,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; @@ -428,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))); @@ -437,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`]); @@ -453,33 +361,33 @@ 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); - }); - - 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`]); + 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`); }); }); +describe('size queries', () => { + test('are left to Ghostty, which answers them (ADR 042, ADR 047)', () => { + 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`); + }); +}); + 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 @@ -495,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))); @@ -508,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([]); @@ -525,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([]); @@ -537,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}\\`]); @@ -564,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(); @@ -575,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(); @@ -596,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(); @@ -613,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(); @@ -657,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; @@ -673,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')); @@ -686,39 +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 backing store', () => { + test('a new overlay is empty and reads no layout while there is no image', () => { + 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 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).toMatchObject({ width: 800, height: 600 }); + h.g.feed(apc('a=d,d=I,i=1')); + 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('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('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); }); }); @@ -728,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 new file mode 100644 index 0000000..f939f2a --- /dev/null +++ b/src/client/features/kitty/graphics/kitty-graphics.ts @@ -0,0 +1,298 @@ +// 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 { ChunkAssembler, type Transmission } from '../protocol/chunk-assembler'; +import { ControlScanner } from '../protocol/control-scanner'; +import { kittyReply } from '../protocol/kitty-reply'; +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; +} + +/** 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; +} + +/** + * `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 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(); + // 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 readonly limits: KittyLimits; + private readonly overlay: OverlayCanvas; + + constructor( + private readonly term: KittyTerminal, + container: HTMLElement, + /** Sends bytes to the PTY as if typed: replies to queries go here. */ + 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 = new OverlayCanvas(term, container, this, deps); + } + + /** Repaint the overlay on the next frame, whatever has or has not changed. */ + invalidate(): void { + this.overlay.invalidate(); + } + + /** + * Forget everything from the previous PTY stream. Call when it restarts + * (WebSocket reconnect): a connection can drop mid-APC, and the scrollback + * replay that follows would otherwise be swallowed as payload; images and + * in-flight decodes belong to the old stream and would answer, or draw, + * into the new one. + */ + reset(): void { + this.generation++; + this.splitter = new ApcSplitter(this.limits.maxTransmissionBytes); + this.assembler = new ChunkAssembler(this.limits.maxTransmissionBytes); + this.scanner = new ControlScanner(); + this.images.dropAll(); + this.clear(); + } + + /** Writes PTY output to the terminal, intercepting graphics commands. */ + feed(data: string): void { + for (const seg of this.splitter.feed(data)) { + if (seg.type === 'text') this.writeText(seg.text); + else this.handleApc(seg.body, seg.overflow); + } + } + + /** + * 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 + // and ADR 047. + if (this.scanner.scan(text)) this.clear(); + } + + private handleApc(body: string, overflow = false): void { + const cmd = parseKittyApc(body); + if (!cmd) return; + // An oversized sequence arrives as its start only: enough to answer it. + const tx = overflow ? this.assembler.reject(cmd) : this.assembler.push(cmd); + if (!tx) return; + const action = tx.keys.a ?? 't'; + try { + if (tx.tooBig) throw new KittyError('EINVAL', 'transmission too large'); + if (action === 'q') this.query(tx); + else if (action === 't' || action === 'T') this.transmit(tx, action === 'T'); + else if (action === 'p') { + const id = this.requireImage(tx.keys); + this.place(tx.keys, id); + this.reply(tx.keys, id); + } else if (action === 'd') this.delete(tx.keys); + } catch (e) { + this.replyError(tx.keys, e); + } + } + + // 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 = imageIdOf(keys); + if (!this.images.get(id)) throw new KittyError('ENOENT', 'no such image'); + return id; + } + + private query(tx: Transmission): void { + assertSupported(tx.keys); + this.reply(tx.keys, imageIdOf(tx.keys)); + } + + private transmit(tx: Transmission, display: boolean): void { + 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) assertCursorMovable(tx.keys, size); + + // 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 = 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, seq } = this.images.open(id, size); + + // Placement is recorded now, before the decode finishes; the draw simply + // waits for the bitmap. + if (display) this.place(tx.keys, id); + + // 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.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), + ); + } + + 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 }; + assertCursorMovable(keys, image); + const buf = this.term.buffer.active; + 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. + private delete(keys: KittyKeys): void { + const target = keys.d ?? 'a'; + if (target === 'a' || target === 'A') { + this.removePlacements(() => true); + if (target === 'A') this.images.dropAll(); + } else if (target === 'i' || target === 'I') { + 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.images.drop(id); + } + } + + // Every path that removes a placement goes through here so the overlay is + // 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 { + if (this.placements.removeWhere(match)) this.overlay.markDirty(); + } + + private reply(keys: KittyKeys, id: number): void { + const r = kittyReply(keys, id); + if (r) this.send(r); + } + + 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, imageIdOf(keys), { code, message }); + if (r) this.send(r); + } +} 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..4836f78 --- /dev/null +++ b/src/client/features/kitty/graphics/kitty-terminal.ts @@ -0,0 +1,65 @@ +// 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 }; + /** 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. */ +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..001c2b0 --- /dev/null +++ b/src/client/features/kitty/graphics/overlay-canvas.test.ts @@ -0,0 +1,233 @@ +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 sync = { held: false, at: [] as number[], painted: new Set<() => void>() }; + 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; + }, + 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>(); + 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, + sync, + 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("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('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); + 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..d4b6057 --- /dev/null +++ b/src/client/features/kitty/graphics/overlay-canvas.ts @@ -0,0 +1,155 @@ +// 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 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'; +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))); + // 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. + 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 readonly tick = (now: number): void => { + this.frame = this.deps.window.requestAnimationFrame(this.tick); + // 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); + 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; + }, + }; +} 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/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..403ae07 --- /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 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. + */ +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/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 new file mode 100644 index 0000000..9198ae9 --- /dev/null +++ b/src/client/features/notifications/desktop-notifier.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, spyOn, test } from 'bun:test'; +import { + DesktopNotifier, + NOTIFY_INTERVAL_MS, + NOTIFY_REPEAT_MS, + type NotificationApi, +} from './desktop-notifier'; + +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(true); + expect(shown).toHaveLength(0); + expect(state.marks).toBe(0); + // Nor does a replayed one arm the permission prompt. + replay.active = false; + notifier.userInput(true); + 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(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(true); + notifier.userInput(true); + 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('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(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(true); + 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(true); + 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.'); + }); + + 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('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 new file mode 100644 index 0000000..b6bf896 --- /dev/null +++ b/src/client/features/notifications/desktop-notifier.ts @@ -0,0 +1,140 @@ +// 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; + 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; + +/** + * 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 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; + 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 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; + 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/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..e5a8ccd --- /dev/null +++ b/src/client/features/notifications/notifications-feature.ts @@ -0,0 +1,84 @@ +// 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; + // Clears the tab title's bell mark too: the tab title feature has no listener here. + title.attended(); + notifier.attended(); + }), + ); + store.add(listen(win, 'keydown', () => notifier.attended(), { capture: true })); + return { dispose: () => store.dispose() }; +} diff --git a/src/client/features/paste/non-text-paste.test.ts b/src/client/features/paste/non-text-paste.test.ts new file mode 100644 index 0000000..521b179 --- /dev/null +++ b/src/client/features/paste/non-text-paste.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from 'bun:test'; +import { nonTextPaste } from './non-text-paste'; + +describe('nonTextPaste', () => { + test('forwards Ctrl+V for an image-only clipboard (empty text/plain)', () => { + expect(nonTextPaste('')).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/features/paste/non-text-paste.ts b/src/client/features/paste/non-text-paste.ts new file mode 100644 index 0000000..34b1ee7 --- /dev/null +++ b/src/client/features/paste/non-text-paste.ts @@ -0,0 +1,11 @@ +// 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/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/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/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/smooth-scroll.test.ts b/src/client/features/scroll/scroll-split.test.ts similarity index 95% rename from src/client/smooth-scroll.test.ts rename to src/client/features/scroll/scroll-split.test.ts index 20ae8a4..69da26c 100644 --- a/src/client/smooth-scroll.test.ts +++ b/src/client/features/scroll/scroll-split.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { splitScroll, wheelLines } from './smooth-scroll'; +import { splitScroll, wheelLines } from './scroll-split'; describe('splitScroll', () => { test('a whole-line position has no offset', () => { 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-devices.test.ts b/src/client/features/scroll/smooth-scroll-devices.test.ts new file mode 100644 index 0000000..7d56bfa --- /dev/null +++ b/src/client/features/scroll/smooth-scroll-devices.test.ts @@ -0,0 +1,182 @@ +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. 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 { + 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, 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[], + 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); + }); + + /** 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([ + ['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, 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); + }); + + // 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.test.ts b/src/client/features/scroll/smooth-scroll.test.ts new file mode 100644 index 0000000..01743ac --- /dev/null +++ b/src/client/features/scroll/smooth-scroll.test.ts @@ -0,0 +1,348 @@ +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'; + +/** 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 unsubscribed: string[] = []; + const term = { + viewportY: 0, + scrollback: 100, + rows: 40, + wasmTerm: { isAlternateScreen: () => false }, + renderer: { + getMetrics: () => metrics, + setScrollOffset: (offset: number) => offsets.push(offset), + }, + onScroll: (cb: (y: number) => void) => { + scrolled = cb; + return { dispose: () => unsubscribed.push('onScroll') }; + }, + onResize: (cb: () => void) => { + resized = cb; + return { dispose: () => unsubscribed.push('onResize') }; + }, + getScrollbackLength: () => term.scrollback, + scrollToLine(line: number) { + term.viewportY = line; + }, + }; + 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, + unsubscribed, + 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('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. + 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 { 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('less than a line up, output keeps the text in view (ADR 066)', () => { + const { term, offsets, scroll } = fake(10); + // 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(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([term.viewportY, offsets.at(-1)]).toEqual([3, 6]); + }); + + 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", () => { + 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) { + // 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; + + 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); + // 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(8); + 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('with reduced motion a notch scrolls at once', () => { + withFrames(() => { + 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, scrolled } = fake(10); + // ghostty-web fires onScroll synchronously from scrollToLine. + const real = term.scrollToLine; + term.scrollToLine = (line: number) => { + real(line); + scrolled(line); + }; + // 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. + scroll.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); + 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 finishes the glide and applies at once', () => { + withFrames((tick) => { + const { term, offsets, scroll } = fake(10); + scroll.handleWheel({ ...notch(-40), timeStamp: 1000 } as WheelEvent); + tick(WHEEL_GLIDE_MS / 2); + 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([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); + }); + }); + + 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); + }); + }); + }); +}); diff --git a/src/client/features/scroll/smooth-scroll.ts b/src/client/features/scroll/smooth-scroll.ts new file mode 100644 index 0000000..db433ee --- /dev/null +++ b/src/client/features/scroll/smooth-scroll.ts @@ -0,0 +1,172 @@ +/** + * 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 + * 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 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. + * + * 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. + * + * 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. + */ +import { type Disposable, DisposableStore, 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 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; + /** 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 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 !== 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. */ + handleWheel(e: WheelEvent): boolean { + 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); + this.glide.retarget(to, this.stream.forScrollback(e), this.deps.performance.now()); + if (!this.glide.active) this.apply(); + 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 { + 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; + this.glide.step(now, 0.5 / (height * (this.deps.window.devicePixelRatio || 1))); + this.glide.value = this.clamp(this.glide.value); + this.apply(); + } + + /** New output was written: the glide follows where it kept the view. */ + onOutput(): void { + this.follow(); + } + + /** + * 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(); + } + + /** + * 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, 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. + */ + private follow(): void { + const y = this.term.viewportY; + if (y !== this.line) { + if (y === 0) { + this.jump(0); + } else { + 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(); + } + } 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(); + } + + private clamp(position: number): number { + return Math.max(0, Math.min(this.term.getScrollbackLength(), position)); + } + + private apply(): void { + const renderer = this.term.renderer; + const metrics = renderer?.getMetrics(); + if (!renderer || !metrics) return; + const { line, offset } = splitScroll( + this.glide.value, + metrics.height, + 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 + // 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); + this.line = this.term.viewportY; + } +} 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..33e6c63 --- /dev/null +++ b/src/client/features/scroll/wheel-classifier.test.ts @@ -0,0 +1,74 @@ +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('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); + }); + + 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..da320ba --- /dev/null +++ b/src/client/features/scroll/wheel-classifier.ts @@ -0,0 +1,62 @@ +// 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 && 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 new file mode 100644 index 0000000..3eaea3b --- /dev/null +++ b/src/client/features/scroll/wheel-stream.test.ts @@ -0,0 +1,146 @@ +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', () => { + 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('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', () => { + 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', () => { + 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", () => { + 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); + }); +}); + +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); + }); +}); + +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 new file mode 100644 index 0000000..47e95ed --- /dev/null +++ b/src/client/features/scroll/wheel-stream.ts @@ -0,0 +1,158 @@ +/** + * 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 + * 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'; +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`. */ +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; + +/** 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 }; +} + +/** 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 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. */ + 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 this.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 && 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 { + 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; + // 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; + // 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 + // gliding a trackpad's first deltas put the view behind the fingers at every start. + if (long) { + this.shortGaps = 0; + 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 + // 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 }; + } +} 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..d3af250 --- /dev/null +++ b/src/client/features/tab-title/tab-title-feature.ts @@ -0,0 +1,31 @@ +// 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. 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'; +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.test.ts b/src/client/features/tab-title/tab-title.test.ts new file mode 100644 index 0000000..a194b6b --- /dev/null +++ b/src/client/features/tab-title/tab-title.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from 'bun:test'; +import { TabTitle } from './tab-title'; + +function setup(bellTitle = true) { + const titles: string[] = []; + const replay = { active: false }; + const tab = new TabTitle('main', bellTitle, replay, (t) => titles.push(t)); + const last = () => titles[titles.length - 1]; + 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(); + 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'); + expect(tab.name).toBe('done'); + tab.attended(); + expect(last()).toBe('done | webtty'); + // Attending again changes nothing. + tab.attended(); + expect(titles).toHaveLength(count + 1); + }); + + test('ignores bells while the replay is active', () => { + const { tab, replay, last } = setup(); + replay.active = true; + tab.bell(); + expect(last()).toBe('main | webtty'); + replay.active = false; + tab.bell(); + expect(last()).toBe('🔔 main | webtty'); + }); + + test('applies titles from the replay', () => { + 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); + tab.bell(); + expect(titles).toEqual(['main | webtty']); + }); +}); diff --git a/src/client/features/tab-title/tab-title.ts b/src/client/features/tab-title/tab-title.ts new file mode 100644 index 0000000..516f67f --- /dev/null +++ b/src/client/features/tab-title/tab-title.ts @@ -0,0 +1,55 @@ +// 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 = '🔔 '; + +/** + * 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. + * + * 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; + + 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; + this.update(); + } + + /** 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; + 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(); + } + + private update(): void { + this.set(`${this.ringing ? BELL_MARK : ''}${this.name} | webtty`); + } +} diff --git a/src/client/fit.test.ts b/src/client/fit.test.ts deleted file mode 100644 index f90abe4..0000000 --- a/src/client/fit.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -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(); - }); -}); - -describe('computeInsets', () => { - test('with no padding, top and left are flush and the leftover goes right and bottom', () => { - expect(computeInsets(9, 13, 0)).toEqual({ left: 0, right: 9, top: 0, bottom: 13 }); - }); - - test('top and left are exactly the padding; right and bottom add the leftover', () => { - // hGap 29 = 2 x 10 padding + 9 leftover; vGap 33 = 2 x 10 padding + 13 leftover. - expect(computeInsets(29, 33, 10)).toEqual({ left: 10, right: 19, top: 10, bottom: 23 }); - }); - - test('keeps the padding on all sides even with no leftover', () => { - expect(computeInsets(20, 20, 10)).toEqual({ left: 10, right: 10, top: 10, bottom: 10 }); - }); - - test('never returns a negative inset', () => { - expect(computeInsets(0, 0, 0)).toEqual({ left: 0, right: 0, top: 0, bottom: 0 }); - expect(computeInsets(-5, -5, 0)).toEqual({ left: 0, right: 0, top: 0, bottom: 0 }); - }); -}); diff --git a/src/client/fit.ts b/src/client/fit.ts deleted file mode 100644 index 15132e6..0000000 --- a/src/client/fit.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Terminal grid sizing for 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. - */ - -/** 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; - top: number; - bottom: number; -} - -/** - * Padding to apply on each side of the canvas. `hGap` and `vGap` are the total space - * left over in the container (container size minus canvas size), including the - * configured `padding` on both sides. The top and left edges are exactly `padding`; - * the leftover, less than one cell, goes to the right and bottom, like most terminal - * emulators. So `padding` alone decides the gap at the top and left, and `0` puts the - * terminal flush against whatever is above and to the left of it. - */ -export function computeInsets(hGap: number, vGap: number, padding: number): Insets { - const p = Math.max(0, padding); - return { - left: p, - right: Math.max(p, hGap - p), - top: p, - bottom: Math.max(p, vGap - p), - }; -} diff --git a/src/client/graphics.ts b/src/client/graphics.ts deleted file mode 100644 index 541c00b..0000000 --- a/src/client/graphics.ts +++ /dev/null @@ -1,652 +0,0 @@ -import type { Terminal } from 'ghostty-web'; -import { - ApcSplitter, - axisScale, - ChunkAssembler, - ControlScanner, - cellSpan, - deviceCellSize, - type KittyKeys, - kittyReply, - MAX_TRANSMISSION_BYTES, - num, - parseKittyApc, - pngSize, - pngSizeFromBytes, - sizeReply, - type Transmission, -} from './kitty'; - -// 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 -// 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 -// 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; -} - -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)); -} - -/** - * 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. - */ -export class KittyGraphics { - 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 = ''; - - constructor( - private term: Terminal, - private container: HTMLElement, - /** Sends bytes to the PTY as if typed: replies to queries go here. */ - private send: (data: string) => void, - limits: Partial<Limits> = {}, - ) { - this.limits = { ...DEFAULT_LIMITS, ...limits }; - this.splitter = new ApcSplitter(this.limits.maxTransmissionBytes); - this.assembler = new ChunkAssembler(this.limits.maxTransmissionBytes); - this.overlay = document.createElement('canvas'); - this.overlay.dataset.kitty = ''; - 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(); - }); - } - - /** Repaint the overlay on the next frame, whatever has or has not changed. */ - invalidate(): void { - this.dirty = true; - this.lastSignature = ''; - } - - /** - * Forget everything from the previous PTY stream. Call when it restarts - * (WebSocket reconnect): a connection can drop mid-APC, and the scrollback - * replay that follows would otherwise be swallowed as payload; images and - * in-flight decodes belong to the old stream and would answer, or draw, - * into the new one. - */ - reset(): void { - this.generation++; - this.splitter = new ApcSplitter(this.limits.maxTransmissionBytes); - this.assembler = new ChunkAssembler(this.limits.maxTransmissionBytes); - this.scanner = new ControlScanner(); - this.dropImages(); - this.clear(); - } - - /** Writes PTY output to the terminal, intercepting graphics commands. */ - feed(data: string): void { - for (const seg of this.splitter.feed(data)) { - if (seg.type === 'text') this.writeText(seg.text); - else this.handleApc(seg.body, seg.overflow); - } - } - - private writeText(text: string): void { - this.term.write(text); - const { clears, queries } = this.scanner.scan(text); - if (clears) this.clear(); - for (const q of queries) this.send(sizeReply(q, this.terminalSize())); - } - - private handleApc(body: string, overflow = false): void { - const cmd = parseKittyApc(body); - if (!cmd) return; - // An oversized sequence arrives as its start only: enough to answer it. - const tx = overflow ? this.assembler.reject(cmd) : this.assembler.push(cmd); - if (!tx) return; - const action = tx.keys.a ?? 't'; - try { - if (tx.tooBig) throw new KittyError('EINVAL', 'transmission too large'); - if (action === 'q') this.query(tx); - else if (action === 't' || action === 'T') this.transmit(tx, action === 'T'); - else if (action === 'p') { - const id = this.requireImage(tx.keys); - this.place(tx.keys, id); - this.reply(tx.keys, id); - } else if (action === 'd') this.delete(tx.keys); - } catch (e) { - this.replyError(tx.keys, e); - } - } - - 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'); - 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'); - } - } - - private transmit(tx: Transmission, display: boolean): void { - this.assertSupported(tx.keys); - const size = this.dimensions(tx); - this.assertWithinLimits(tx.keys, size); - // 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); - - // 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)) { - throw new KittyError('EINVAL', 'too many images being decoded'); - } - - const explicit = this.imageIdOf(tx.keys); - const id = explicit || --this.anonymousId; - const gen = 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; - - // Placement is recorded now, before the decode finishes; the draw simply - // waits for the bitmap. - if (display) this.place(tx.keys, id); - - // 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); - }, - ); - } - - /** 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 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); - 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`); - } - - // The protocol defines no success reply for a delete, so none is sent. - private delete(keys: KittyKeys): void { - const target = keys.d ?? 'a'; - if (target === 'a' || target === 'A') { - this.removePlacements(() => true); - if (target === 'A') this.dropImages(); - } else if (target === 'i' || target === 'I') { - const id = this.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); - } - } - - // 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 - // 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. */ - private 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(); - } - - private reply(keys: KittyKeys, id: number): void { - const r = kittyReply(keys, id); - if (r) this.send(r); - } - - 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 }); - 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 }; - } - - 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; - if (!wasm || wasm.isAlternateScreen()) return 0; - return wasm.getScrollbackLength(); - } - - private terminalCanvas(): HTMLCanvasElement | null { - return this.container.querySelector('canvas:not([data-kitty])'); - } - - 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, - 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 (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; - 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; - 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.css b/src/client/index.css index 9548919..9298571 100644 --- a/src/client/index.css +++ b/src/client/index.css @@ -13,10 +13,93 @@ 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 + scrolled, so focusing the textarea or scrollIntoView cannot shift the view. */ + overflow: clip; } #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 511448a..828e80e 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,501 +1,7 @@ -import { init, Terminal } from 'ghostty-web'; -import { Composer } from './compose'; -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'; +// 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. -interface KeyboardBinding { - key: string; - mods?: string[]; - chars: string; -} +import { browserEnv } from './app/browser-env'; +import { startClient } from './app/start-client'; -interface Theme { - background?: string; - foreground?: string; - cursor?: string; - selection?: string; - selectionForeground?: 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; - scrollback: number; - theme: Theme; - copyOnSelect: boolean; - rightClickBehavior: 'default' | 'copyPaste'; - 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()); - -document.title = `${sessionId} | webtty`; - -await init(); - -// 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({ - cols: config.cols, - rows: config.rows, - cursorStyle: config.cursorStyle, - cursorBlink: config.cursorStyleBlink, - fontSize: config.fontSize, - fontFamily: config.fontFamily, - scrollback: Math.ceil(config.scrollback / 80), - theme: terminalTheme, -}); - -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; -} -await term.open(container); -const edges = new EdgeExtender(container, config.padding); -// 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); - -// 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. -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, - ); - if (!grid) return; - if (grid.cols !== term.cols || grid.rows !== term.rows) { - term.resize(grid.cols, grid.rows); - } - const canvas = container.querySelector('canvas') as HTMLElement | null; - 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 and ADR 015 for the same forced-repaint -// pattern used for the DECSCUSR ghost-cursor fix. -document.addEventListener('visibilitychange', () => { - if (document.visibilityState !== 'visible') return; - if (term.renderer && term.wasmTerm) { - term.renderer.render(term.wasmTerm, true, term.viewportY); - } -}); - -// 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(); - -// 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; - -// 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. -const graphics = new KittyGraphics(term, container, (data: string) => { - if (ws && ws.readyState === WebSocket.OPEN) ws.send(data); -}); - -function connect(): void { - const wsUrl = `${protocol}//${window.location.host}/ws/${sessionId}/pty?cols=${term.cols}&rows=${term.rows}`; - ws = new WebSocket(wsUrl); - - 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 })); - }; - - ws.onmessage = (event: MessageEvent<string>) => { - applyDecscusr(term, event.data); - graphics.feed(event.data); - smoothScroll.onOutput(); - }; - - ws.onclose = (event: CloseEvent) => { - 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(); - -// 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; -}); - -// 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); - if (!binding) return; - e.preventDefault(); - e.stopPropagation(); - if (binding.chars && ws.readyState === WebSocket.OPEN) { - ws.send(binding.chars); - } - }, - { 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; - fit(); - }, - { capture: true }, -); - -// 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); - } -}); - -// 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 })); - } -}); - -// 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) => { - 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 -// event at all. See ADR 014. -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. -function sendPaste(text: string): void { - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(encodePaste(text, term.getMode(2004))); - } -} - -// 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). -container.addEventListener( - 'keydown', - (e: KeyboardEvent) => { - if (!e.ctrlKey || e.metaKey || e.code !== 'KeyV') return; - if (findBinding(e)) return; - if (!navigator.clipboard?.readText) return; - e.preventDefault(); - e.stopImmediatePropagation(); - navigator.clipboard.readText().then(sendPaste, sendCtrlV); - }, - { capture: true }, -); - -container.addEventListener( - 'paste', - (e: ClipboardEvent) => { - const cd = e.clipboardData; - if (!cd) return; - e.preventDefault(); - e.stopImmediatePropagation(); - sendPaste(cd.getData('text/plain')); - }, - { capture: true }, -); +await startClient(browserEnv()); diff --git a/src/client/kitty.test.ts b/src/client/kitty.test.ts deleted file mode 100644 index ee27b1b..0000000 --- a/src/client/kitty.test.ts +++ /dev/null @@ -1,511 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { - ApcSplitter, - axisScale, - ChunkAssembler, - ControlScanner, - cellSpan, - clearsPlacements, - deviceCellSize, - kittyReply, - parseKittyApc, - pngSize, - pngSizeFromBytes, - sizeQueries, - sizeReply, -} 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('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 }; - - 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[')).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]); - }); - - 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.clears || b.clears).toBe(true); - expect([...a.queries, ...b.queries]).toEqual([16]); - } - }); - - 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); - }); - - 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: [] }); - }); - - 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([]); - }); -}); - -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/kitty.ts b/src/client/kitty.ts deleted file mode 100644 index aaaf94d..0000000 --- a/src/client/kitty.ts +++ /dev/null @@ -1,420 +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); -} - -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. - */ -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; -} - -/** What a stretch of terminal text asks of the client. */ -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 -// bytes but no final byte yet. -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 - * 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 { - 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), queries: sizeQueries(s) }; - } -} - -export interface TerminalSize { - cols: number; - rows: number; - /** Cell size in pixels. */ - cellWidth: number; - cellHeight: number; -} - -/** Reply to a `CSI <query> 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; -} - -/** - * 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/layout/edge-extender.test.ts b/src/client/layout/edge-extender.test.ts new file mode 100644 index 0000000..1ad582f --- /dev/null +++ b/src/client/layout/edge-extender.test.ts @@ -0,0 +1,170 @@ +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'; + +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 }); + } +} + +/** + * 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('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('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('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 new file mode 100644 index 0000000..a09a49d --- /dev/null +++ b/src/client/layout/edge-extender.ts @@ -0,0 +1,161 @@ +/** + * Extends the terminal's edge colours into the leftover strip at the right and bottom, + * 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. + */ +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 { + 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; + /** Changes whenever the canvas is drawn on; absent: assume it changed every frame. */ + 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(doc: Document): Strip | null { + const el = doc.createElement('canvas'); + const ctx = el.getContext('2d'); + if (!ctx) return null; + el.style.position = 'absolute'; + el.style.pointerEvents = 'none'; + el.style.display = 'none'; + el.dataset.edge = 'true'; + return { el, ctx, visible: false }; +} + +function place(strip: Strip, rect: Rect | null, dpr: number): void { + if (!rect) { + strip.visible = false; + strip.el.style.display = 'none'; + return; + } + strip.visible = true; + strip.el.style.display = 'block'; + strip.el.style.left = `${rect.x}px`; + strip.el.style.top = `${rect.y}px`; + strip.el.style.width = `${rect.width}px`; + strip.el.style.height = `${rect.height}px`; + strip.el.width = Math.max(1, Math.round(rect.width * dpr)); + strip.el.height = Math.max(1, Math.round(rect.height * dpr)); + 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: 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, + 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, + 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); + } + } + + /** Reposition the strips. Call after every fit, when the canvas or padding may have moved. */ + update(): void { + if (this.disposed) return; + const canvas = terminalCanvas(this.container); + if (!canvas) return; + const rects = computeEdgeRects( + { + left: canvas.offsetLeft, + top: canvas.offsetTop, + width: canvas.offsetWidth, + height: canvas.offsetHeight, + }, + { width: this.container.clientWidth, height: this.container.clientHeight }, + this.padding, + ); + 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 = 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 readonly paint = (): void => { + this.frame = null; + const terminal = terminalCanvas(this.container); + 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; + // 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 = this.deps.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); + } + if (this.right?.visible) { + const { ctx, el } = this.right; + 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 = this.deps.window.requestAnimationFrame(this.paint); + } + }; +} diff --git a/src/client/edge.test.ts b/src/client/layout/edge-rects.test.ts similarity index 83% rename from src/client/edge.test.ts rename to src/client/layout/edge-rects.test.ts index 8a99b99..a001764 100644 --- a/src/client/edge.test.ts +++ b/src/client/layout/edge-rects.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { computeEdgeRects } from './edge'; +import { computeEdgeRects } from './edge-rects'; const canvas = { left: 0, top: 0, width: 800, height: 400 }; @@ -34,4 +34,10 @@ describe('computeEdgeRects', () => { 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, + }; +} diff --git a/src/client/layout/fit-insets.test.ts b/src/client/layout/fit-insets.test.ts new file mode 100644 index 0000000..ecb6c9c --- /dev/null +++ b/src/client/layout/fit-insets.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from 'bun:test'; +import { computeInsets } from './fit-insets'; + +describe('computeInsets', () => { + test('with no padding, top and left are flush and the leftover goes right and bottom', () => { + expect(computeInsets(9, 13, 0)).toEqual({ left: 0, right: 9, top: 0, bottom: 13 }); + }); + + test('top and left are exactly the padding; right and bottom add the leftover', () => { + // hGap 29 = 2 x 10 padding + 9 leftover; vGap 33 = 2 x 10 padding + 13 leftover. + expect(computeInsets(29, 33, 10)).toEqual({ left: 10, right: 19, top: 10, bottom: 23 }); + }); + + test('keeps the padding on all sides even with no leftover', () => { + expect(computeInsets(20, 20, 10)).toEqual({ left: 10, right: 10, top: 10, bottom: 10 }); + }); + + test('never returns a negative inset', () => { + expect(computeInsets(0, 0, 0)).toEqual({ left: 0, right: 0, top: 0, bottom: 0 }); + expect(computeInsets(-5, -5, 0)).toEqual({ left: 0, right: 0, top: 0, bottom: 0 }); + }); +}); diff --git a/src/client/layout/fit-insets.ts b/src/client/layout/fit-insets.ts new file mode 100644 index 0000000..e550355 --- /dev/null +++ b/src/client/layout/fit-insets.ts @@ -0,0 +1,33 @@ +/** + * 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. + */ + +/** The padding on each side of the canvas, in CSS pixels. */ +export interface Insets { + left: number; + right: number; + top: number; + bottom: number; +} + +/** + * Padding to apply on each side of the canvas. `hGap` and `vGap` are the total space + * left over in the container (container size minus canvas size), including the + * configured `padding` on both sides. The top and left edges are exactly `padding`; + * the leftover, less than one cell, goes to the right and bottom, like most terminal + * emulators. So `padding` alone decides the gap at the top and left, and `0` puts the + * terminal flush against whatever is above and to the left of it. + */ +export function computeInsets(hGap: number, vGap: number, padding: number): Insets { + const p = Math.max(0, padding); + return { + left: p, + right: Math.max(p, hGap - p), + top: p, + bottom: Math.max(p, vGap - p), + }; +} 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); + } + }); +} 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); +} 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 - * <LeftDrag>, 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 <LeftDrag> - * 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/paste.test.ts b/src/client/paste.test.ts deleted file mode 100644 index a2df2d5..0000000 --- a/src/client/paste.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { encodePaste } 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'); - }); - - test('forwards Ctrl+V for an image-only clipboard (empty text/plain)', () => { - expect(encodePaste('', false)).toBe('\x16'); - expect(encodePaste('', true)).toBe('\x16'); - }); -}); diff --git a/src/client/paste.ts b/src/client/paste.ts deleted file mode 100644 index 4928c84..0000000 --- a/src/client/paste.ts +++ /dev/null @@ -1,10 +0,0 @@ -// 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; -} diff --git a/src/client/smooth-scroll.ts b/src/client/smooth-scroll.ts deleted file mode 100644 index 0d6afec..0000000 --- a/src/client/smooth-scroll.ts +++ /dev/null @@ -1,174 +0,0 @@ -/** - * Pixel-smooth scrollback scrolling. See ADR 040. - * - * 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. - * - * 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'; - -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; -} - -type Cell = NonNullable<ReturnType<Terminal['getScrollbackLine']>>[number]; - -interface RendererInternals { - ctx: CanvasRenderingContext2D; - devicePixelRatio: number; - renderLine(line: Cell[], row: number, cols: number): 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); - - // 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); - }); - term.onResize(() => this.jump(term.viewportY)); - } - - /** Handles a wheel event on the normal screen. Returns false to leave it to ghostty-web. */ - handleWheel(e: WheelEvent): boolean { - 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)); - this.apply(); - return true; - } - - /** 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); - } - - /** Re-applies the offset after a fit moved or resized the canvas. */ - update(): void { - this.apply(); - } - - private jump(line: number): void { - this.position = line; - this.apply(); - } - - private apply(): void { - const renderer = this.term.renderer as unknown as RendererInternals | undefined; - const metrics = this.term.renderer?.getMetrics(); - const canvas = this.container.querySelector<HTMLCanvasElement>('canvas:not([data-edge])'); - if (!renderer || !metrics || !canvas) 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. - 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 cells = this.term.getScrollbackLine(this.term.getScrollbackLength() - line - 1); - if (!ctx || !cells) { - this.strip.style.display = 'none'; - return; - } - const dpr = renderer.devicePixelRatio; - 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. - 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); - } finally { - renderer.ctx = own; - } - } -} diff --git a/src/client/test-support.test.ts b/src/client/test-support.test.ts new file mode 100644 index 0000000..c86de47 --- /dev/null +++ b/src/client/test-support.test.ts @@ -0,0 +1,425 @@ +// 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 { 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 { + type ListenerRecord, + 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', () => { + 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']); + }); + + 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', () => { + 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(); + 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); + }); + + 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', + ]); + }); + + 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', () => { + 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('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(); + 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..798fb17 --- /dev/null +++ b/src/client/test-support/dom-window.ts @@ -0,0 +1,27 @@ +// 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 { + /** 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>; +} + +/** 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 & typeof globalThis; + return { + window, + document: window.document, + close: () => happy.happyDOM.close(), + }; +} 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/fake-context.ts b/src/client/test-support/fake-context.ts new file mode 100644 index 0000000..77a92e3 --- /dev/null +++ b/src/client/test-support/fake-context.ts @@ -0,0 +1,89 @@ +// 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, + scrollback: 256 * 1024, + 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-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..ce1a9c0 --- /dev/null +++ b/src/client/test-support/fake-terminal.ts @@ -0,0 +1,230 @@ +// 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 Pace, + 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; + +/** 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; + /** What `getScrollbackLength` returns. */ + scrollbackLength = 0; + options: Record<string, unknown> = {}; + /** Undefined until {@link attachRenderer}, as before `open()`. */ + renderer: FakeRenderer | undefined; + 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. */ + 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'); + } + + 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; + } + + /** 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'); + } + + getScrollbackLength(): number { + return this.scrollbackLength; + } + + /** 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}`); + } + + /** 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; + } + + /** 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/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; + } +} 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..9878808 --- /dev/null +++ b/src/client/test-support/recording-target.ts @@ -0,0 +1,109 @@ +// 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[] = []; + /** When set, every add is appended here too, so several targets share one ordered log. */ + timeline?: 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); + 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); + } + + 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); + } + } +} + +/** + * 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; +} 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..0e2cb4b --- /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/<commit>/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/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 (`vendor/tests/engine/`, `official-replay.test.ts`, 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 0000000..3df159e Binary files /dev/null and b/src/client/vendor/ghostty-vt/ghostty-vt-small.wasm differ diff --git a/src/client/vendor/ghostty-vt/ghostty-vt-small.wasm.json b/src/client/vendor/ghostty-vt/ghostty-vt-small.wasm.json new file mode 100644 index 0000000..475904d --- /dev/null +++ b/src/client/vendor/ghostty-vt/ghostty-vt-small.wasm.json @@ -0,0 +1,39 @@ +{ + "repo": "https://github.com/ghostty-org/ghostty", + "commit": "05dcf887ef51ea27821f20979493306ec3a19149", + "commitDate": "2026-10-13T00:35:35Z", + "url": "https://tip.files.ghostty.org/05dcf887ef51ea27821f20979493306ec3a19149/ghostty-vt-small.wasm", + "file": "ghostty-vt-small.wasm", + "variant": "ReleaseSmall", + "size": 780049, + "sha256": "c8a8a0cdd3eeb17951b0c23277d99e1b88016e2afe48f932b37992dfb88607ee", + "minisig": "ghostty-vt-small.wasm.minisig", + "minisigSource": "https://github.com/ghostty-org/ghostty/releases/download/tip/ghostty-vt-small.wasm.minisig (tip == 05dcf887ef51ea27821f20979493306ec3a19149)", + "publicKey": "RWQlAjJC23149WL2sEpT/l0QKy7hMIFhYdQOFy0Z7z7PbneUgvlsnYcV", + "signature": { + "keyId": "F5787DDB42320225", + "algorithm": "ED", + "trustedComment": "timestamp:1791854124\tfile:ghostty-vt-small.wasm\thashed", + "signedAt": "2026-10-13T01:15:24.000Z" + }, + "verifiedAt": "2026-10-13T01:41:11.529Z", + "license": "MIT (Copyright (c) 2024 Mitchell Hashimoto, Ghostty contributors; LICENSE)", + "wasm": { + "imports": [], + "exportCount": 190, + "functionExports": 188, + "nonFunctionExports": [ + "memory:memory", + "__indirect_function_table:table" + ], + "initialMemoryBytes": 524288, + "buildInfo": { + "version": "0.1.0-dev", + "versionBuild": "", + "optimize": "ReleaseSmall", + "simd": false, + "kittyGraphics": false, + "tmuxControlMode": false + } + } +} diff --git a/src/client/vendor/ghostty-vt/ghostty-vt-small.wasm.minisig b/src/client/vendor/ghostty-vt/ghostty-vt-small.wasm.minisig new file mode 100644 index 0000000..127cf46 --- /dev/null +++ b/src/client/vendor/ghostty-vt/ghostty-vt-small.wasm.minisig @@ -0,0 +1,4 @@ +untrusted comment: signature from minisign secret key +RUQlAjJC23149dYo9V6EWRRExSHfYn2ng79Qhb9UmTvPYmiBR/aJTY9iI4RZm9Fd3N0469MGTCb2Et5nvdmFVgaEbtvtP2fIjQ4= +trusted comment: timestamp:1791854124 file:ghostty-vt-small.wasm hashed +CQjww/epXXRFYrpdGSjc1K9R4A66lIlGrmVAUczI9i4tw1vjuJfX+J0H4qv/e0fu1URHZv8rCp3VRstGRCYvBg== 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-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 new file mode 100644 index 0000000..75287c6 --- /dev/null +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -0,0 +1,186 @@ +# 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`:** 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 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. 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 + +- `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`, 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`. +- `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. +- `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; 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`. +- `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. +- `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. +- `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. 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. +- `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. +- `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). +- `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). +- `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. 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`, `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 + +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` (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). +- `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. + +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`), 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. +- `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. +- `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 + +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 | +|---|---|---| +| `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 | +| `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 | +| `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) | +| `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 | +| `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 | +| `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 | +| `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) | +| `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 | +| `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 | +| `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 | +| `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 | +| `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 | +| `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): remove the vendor types.ts shim | 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..8c8a0f7 --- /dev/null +++ b/src/client/vendor/ghostty-web/addons/fit.ts @@ -0,0 +1,137 @@ +// 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'; + +const MINIMUM_COLS = 2; +const MINIMUM_ROWS = 1; + +/** 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; + 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)), + }; +} + +/** 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) + */ + public activate(terminal: ITerminalCore): void { + this._terminal = terminal; + } + + /** + * Dispose the addon and clean up resources + */ + public dispose(): void { + this._terminal = undefined; + } + + /** + * 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) + * + * @returns Proposed dimensions or undefined if cannot calculate + */ + public proposeDimensions(): ITerminalDimensions | undefined { + // Need terminal and its DOM element + if (!this._terminal?.element) { + return undefined; + } + + const renderer = this._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 = this.computedStyle(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; + } + + // Subtract padding (clientWidth includes it) and enforce minimums + return fitDimensions( + containerWidth, + containerHeight, + { top: paddingTop, right: paddingRight, bottom: paddingBottom, left: paddingLeft }, + metrics.width, + metrics.height + ); + } +} diff --git a/src/client/vendor/ghostty-web/buffer.ts b/src/client/vendor/ghostty-web/buffer.ts new file mode 100644 index 0000000..dec2663 --- /dev/null +++ b/src/client/vendor/ghostty-web/buffer.ts @@ -0,0 +1,221 @@ +// 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 { GhosttyCell } from './engine/cell-types'; +import type { GhosttyTerminal } from './engine/engine-types'; +import type { IBuffer, IBufferCell, IBufferLine, IBufferNamespace } from './interfaces'; + +/** 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: IBufferSource; + + // Lazy-initialized buffer wrappers (stateless, so we can cache them) + private _normalBuffer?: Buffer; + private _alternateBuffer?: Buffer; + + constructor(terminal: IBufferSource) { + this.terminal = terminal; + } + + get active(): IBuffer { + // Query WASM to determine which buffer is active + const wasmTerm = this.terminal.wasmTerm; + 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; + } +} + +/** + * A terminal buffer (normal or alternate screen) + */ +export class Buffer implements IBuffer { + private terminal: IBufferSource; + private bufferType: 'normal' | 'alternate'; + + constructor(terminal: IBufferSource, type: 'normal' | 'alternate') { + this.terminal = terminal; + this.bufferType = type; + } + + 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 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; + + 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); + } else { + // Accessing visible screen + cells = wasmTerm.getLine(this.bufferType === 'normal' ? y - scrollbackLength : y); + } + + if (!cells) { + return undefined; + } + + return new BufferLine(cells, wasmTerm.cols); + } + + private getWasmTerm(): BufferEngine | undefined { + return this.terminal.wasmTerm; + } +} + +/** + * A single line in the buffer + */ +export class BufferLine implements IBufferLine { + private cells: GhosttyCell[]; + private _length: number; + + constructor(cells: GhosttyCell[], length: number) { + this.cells = cells; + this._length = length; + } + + get length(): number { + return this._length; + } + + 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); + } +} + +/** + * 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; + } + + /** + * 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; + } +} 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..ad8699b --- /dev/null +++ b/src/client/vendor/ghostty-web/core/dom-setup.ts @@ -0,0 +1,136 @@ +// 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, 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. The Disposable removes the + * cancelling listener (ADR 065). + */ +export function makeContainerEditable(container: HTMLElement): Disposable { + if (!container.hasAttribute('tabindex')) container.setAttribute('tabindex', '0'); + container.setAttribute('contenteditable', 'true'); + return listen(container, '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. The Disposable removes + * both listeners (ADR 065). + */ +export function focusTextareaOnPress( + canvas: HTMLCanvasElement, + textarea: HTMLTextAreaElement, +): 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. */ +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/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..a330a20 --- /dev/null +++ b/src/client/vendor/ghostty-web/core/focus-tracker.ts @@ -0,0 +1,169 @@ +// 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); 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, 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. */ +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 listeners?: DisposableStore; + + /** `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 + * 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.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) + 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)); + listeners.add(listen(env.window, 'blur', this.windowBlurred)); + listeners.add(listen(env.document, 'visibilitychange', this.visibilityChanged)); + } + + /** + * 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.listeners?.dispose(); + } + + /** + * 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. + */ + 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. */ + 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 new file mode 100644 index 0000000..ff74249 --- /dev/null +++ b/src/client/vendor/ghostty-web/core/host-types.ts @@ -0,0 +1,319 @@ +// 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 { + DesktopNotification, + 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 + * 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' +>; + +/** + * 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; + /** + * 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; +} + +/** + * 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; + /** 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; + /** + * 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; + /** + * 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. */ + live(): boolean; + /** 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, 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. */ + hasReplies(): boolean; + drainReplies(): void; + /** 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; +} + +/** + * 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; + cancelAnimationFrame(id: number): void; +} + +/** + * 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/mouse-controller.ts b/src/client/vendor/ghostty-web/core/mouse-controller.ts new file mode 100644 index 0000000..74cc4b5 --- /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 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) => window.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/opened-parts.ts b/src/client/vendor/ghostty-web/core/opened-parts.ts new file mode 100644 index 0000000..a4719a6 --- /dev/null +++ b/src/client/vendor/ghostty-web/core/opened-parts.ts @@ -0,0 +1,90 @@ +// 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, +// 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'; + +/** + * What the controllers reach of the CanvasRenderer: the mouse's reads, the find + * 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; +} + +/** 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. The facade + * passes the parts' classes; core/ sees only the interfaces, the defaults. + */ +export interface OpenedParts< + R extends RendererPart = RendererPart, + I extends InputPart = InputPart, + S extends SelectionPart = SelectionPart, + 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; + readonly textarea: HTMLTextAreaElement; + 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<P extends OpenedParts = OpenedParts> = { -readonly [K in keyof P]?: P[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 the DOM setup's listeners 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.domListeners?.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 new file mode 100644 index 0000000..9dbe03a --- /dev/null +++ b/src/client/vendor/ghostty-web/core/options-proxy.ts @@ -0,0 +1,165 @@ +// 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'; +import type { RendererOptions } from '../render/renderable'; + +/** 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, + }; +} + +/** 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/render-loop.ts b/src/client/vendor/ghostty-web/core/render-loop.ts new file mode 100644 index 0000000..4f7c0cc --- /dev/null +++ b/src/client/vendor/ghostty-web/core/render-loop.ts @@ -0,0 +1,66 @@ +// 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, +// ADR 067). + +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. 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) + host.anchorTextarea(); + this.frameId = this.frames.requestAnimationFrame(this.frame); + }; +} 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..0249df5 --- /dev/null +++ b/src/client/vendor/ghostty-web/core/scrollbar-controller.ts @@ -0,0 +1,205 @@ +// 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); +// dispose() releases its listeners, auto-hide and fade (ADR 065). + +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), + cancelAnimationFrame: (id) => cancelAnimationFrame(id), +}; + +/** + * 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; + // The next frame of each fade still running + private readonly fadeFrames = new Set<number>(); + private listeners?: DisposableStore; + + 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, and dispose() removes it. + */ + watchRelease(doc: ListenerTarget): void { + if (!this.listeners || this.listeners.isDisposed) this.listeners = new DisposableStore(); + this.listeners.add(listen(doc, 'mouseup', this.release)); + } + + /** + * Removes the listeners, the pending auto-hide and a fade's next frame (ADR 065). 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. */ + 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. 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) { + frame = this.clock.requestAnimationFrame(animate); + this.fadeFrames.add(frame); + } else if (direction === 'out') { + this.visible = false; + this.shownOpacity = 0; + this.host.render(0); + } + }; + animate(); + } +} 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..12dbeaf --- /dev/null +++ b/src/client/vendor/ghostty-web/core/sync-hold.ts @@ -0,0 +1,101 @@ +// 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. 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 { 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; + +/** 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 = ''; + /** 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( + 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 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; + } + + /** + * 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. + */ + expire(now: number): void { + if (!this.host.active()) { + this.since = null; + 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; + this.host.end(); + this.since = null; + } + + /** + * 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); + } + + /** 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, this.carry) : -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 new file mode 100644 index 0000000..a8911a7 --- /dev/null +++ b/src/client/vendor/ghostty-web/core/sync-scan.ts @@ -0,0 +1,88 @@ +// 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), 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. */ +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 : '' }; +} + +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 : '' }; +} + +/** + * 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 begun in the `carry` the previous chunk left is found where `data` finishes it. + */ +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 > carry.length ? end - carry.length : -1; +} 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-controllers.ts b/src/client/vendor/ghostty-web/core/terminal-controllers.ts new file mode 100644 index 0000000..2e0d666 --- /dev/null +++ b/src/client/vendor/ghostty-web/core/terminal-controllers.ts @@ -0,0 +1,322 @@ +// 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 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'; +import { FindController } from './find-controller'; +import { FocusTracker } from './focus-tracker'; +import type { + FindHost, + FocusHost, + FrameScheduler, + MouseHost, + RenderLoopHost, + ScrollbarHost, + SyncHoldHost, + 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 { 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. */ +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; + /** 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); + 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(), + }); + 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); + } + + /** 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 + * 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 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(); + } +} + +// 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 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; + +/** 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, + engine: () => inner.parts()?.engine, + disableStdin: () => term.options.disableStdin, + send: (data) => inner.emit.data.fire(data), + }; +} + +/** 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), + getScrollbackLength: () => term.getScrollbackLength(), + viewportY: () => term.viewportY, + rows: () => term.rows, + scrollToLine: (line) => term.scrollToLine(line), + changed: (state) => inner.emit.findChange.fire(state), + }; +} + +/** 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, + 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(), + 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); + }, + 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(); + }, + wroteOutput: (data) => core.sync.wrote(data), + // Not inside a synchronized update: the frame after it ends paints (ADR 067) + renderEcho: () => { + 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 { + active: () => inner.parts()?.engine.getMode(SYNCHRONIZED_OUTPUT_MODE) ?? false, + end: () => inner.parts()?.engine.setMode(SYNCHRONIZED_OUTPUT_MODE, false), + }; +} + +/** 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), + stepFind: (now) => core.finder.step(now), + renderHeld: (now) => { + core.sync.expire(now); + const held = core.sync.held(now); + if (held) core.sync.frameHeld(); + return held; + }, + render: () => paint(inner, core), + hasReplies: () => inner.parts()?.engine.hasResponse() ?? false, + drainReplies: () => core.writes.drainReplies(), + anchorTextarea: () => inner.anchorTextarea(), + }; +} + +/** 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, + getScrollbackLength: () => term.getScrollbackLength(), + rows: () => term.rows, + viewportY: () => term.viewportY, + scrollToLine: (line) => term.scrollToLine(line), + render: (opacity) => inner.render(false, opacity), + }; +} + +/** 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, + 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(), + }; +} + +/** 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, + 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..75f018f --- /dev/null +++ b/src/client/vendor/ghostty-web/core/terminal-emitters.ts @@ -0,0 +1,34 @@ +// 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>(); + readonly updatePainted = new EventEmitter<void>(); + + /** 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(); + this.updatePainted.dispose(); + } +} 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..ee9c108 --- /dev/null +++ b/src/client/vendor/ghostty-web/core/terminal-geometry.ts @@ -0,0 +1,183 @@ +// 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 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 + * `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..eb468b1 --- /dev/null +++ b/src/client/vendor/ghostty-web/core/viewport.ts @@ -0,0 +1,96 @@ +// 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), 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 { + /** + * 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; + /** 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) {} + + /** 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. 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 && !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, + * 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; + this.host.scrolled(y); + if (scrollbackLength > 0) this.host.showScrollbar(); + } +} 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/core/write-pipeline.ts b/src/client/vendor/ghostty-web/core/write-pipeline.ts new file mode 100644 index 0000000..5d7f92d --- /dev/null +++ b/src/client/vendor/ghostty-web/core/write-pipeline.ts @@ -0,0 +1,185 @@ +// 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, 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'; + +/** + * 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; + // 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: FrameScheduler = 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 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 { + // 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(); + } + + // 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(); + + // 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(); + + // 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); + + 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); + } + } + + /** 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(); + } + + /** + * 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)); + 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. */ + 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(() => { + 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()!; + // 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/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/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-config.ts b/src/client/vendor/ghostty-web/engine/engine-config.ts new file mode 100644 index 0000000..32dbd85 --- /dev/null +++ b/src/client/vendor/ghostty-web/engine/engine-config.ts @@ -0,0 +1,151 @@ +// 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; + 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 new file mode 100644 index 0000000..d5c88b7 --- /dev/null +++ b/src/client/vendor/ghostty-web/engine/engine-context.ts @@ -0,0 +1,166 @@ +// 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 and sets. 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 out-parameters and small structs every binding shares (one word, a + * GhosttyMode and a GhosttyString). + */ +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], + mode: [L.mode.size, 8], + str: [L.string.size, 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; + /** 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. + */ + 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}`); + } + + /** 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; + 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. + */ + 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/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/engine-types.ts b/src/client/vendor/ghostty-web/engine/engine-types.ts new file mode 100644 index 0000000..ff665ec --- /dev/null +++ b/src/client/vendor/ghostty-web/engine/engine-types.ts @@ -0,0 +1,304 @@ +// Derived from coder/ghostty-web lib/types.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// 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 { 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 + * 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 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; + /** 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) + */ + 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; + /** + * 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; + 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; +} + +/** + * 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/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/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/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/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-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/official-terminal.ts b/src/client/vendor/ghostty-web/engine/official-terminal.ts new file mode 100644 index 0000000..20085b8 --- /dev/null +++ b/src/client/vendor/ghostty-web/engine/official-terminal.ts @@ -0,0 +1,469 @@ +// 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, +// 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'; +import { EngineContext } from './engine-context'; +import { type EffectsSlot, EngineEffects, effectsSlots } from './engine-effects'; +import type { + EngineHost, + GhosttyTerminal, + GhosttyTerminalConfig, + MouseGeometry, + MouseInput, + ScreenCell, + ScreenRange, + SearchState, + TerminalEffects, +} from './engine-types'; +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 { 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 +// 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`. + +/** Bytes as one char each, for scanning ASCII sequences in byte output. */ +const LATIN1 = new TextDecoder('latin1'); + +/** 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; +} + +/** 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). + * + * 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. 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 ctx: EngineContext<TerminalSlot>; + private readonly render: RenderState; + private readonly pages: ScrollbackPages; + 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; + + constructor(vt: EngineHost, cols: number, rows: number, config?: GhosttyTerminalConfig) { + 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.config = new EngineConfig(ctx); + this.config.apply(config); + } + + get cols(): number { + return this.ctx.cols; + } + + get rows(): number { + return this.ctx.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 { + // 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 + 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, 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) this.config.afterWrite(text ?? LATIN1.decode(bytes.subarray(0, fed))); + this.ctx.generation++; + this.effects.afterWrite(); + return consumed; + } + + resize(cols: number, rows: number, cellWidthPx?: number, cellHeightPx?: number): void { + const ctx = this.ctx; + if (cols === ctx.cols && rows === ctx.rows) { + if (cellWidthPx !== undefined && cellHeightPx !== undefined) { + this.setCellPixelSize(cellWidthPx, cellHeightPx); + } + return; + } + ctx.cols = cols; + ctx.rows = rows; + if (cellWidthPx !== undefined && cellHeightPx !== undefined) { + 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 = 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}`); + 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 === 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}`); + ctx.generation++; + } + + setDefaultCursor(style: RenderStateCursor['style'], blink: boolean): void { + this.config.setDefaultCursor(style, blink); + } + + /** + * 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.ctx.info(); + const k = this.ctx.altScreen + ? 0 + : 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; + } + + free(): void { + if (!this.ctx.term) return; + this.search.free(); + this.ctx.ex.ghostty_terminal_free(this.ctx.term); + this.ctx.term = 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(); + } + + getCursor(): RenderStateCursor { + return this.render.getCursor(); + } + + isRowDirty(y: number): boolean { + return this.render.isRowDirty(y); + } + + markClean(): void { + this.render.markClean(); + } + + getViewport(): GhosttyCell[] { + // The active screen, scrolled or not. + return this.render.getViewport(); + } + + getLine(y: number): GhosttyCell[] | null { + 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 { + return this.update() === DirtyState.FULL; + } + + clearDirty(): void { + this.markClean(); + } + + getDimensions(): { cols: number; rows: number } { + return { cols: this.ctx.cols, rows: this.ctx.rows }; + } + + // Modes + + isAlternateScreen(): boolean { + this.ctx.info(); + return this.ctx.altScreen; + } + + hasMouseTracking(): boolean { + return this.getFlag(this.ctx.L.tdata.MOUSE_TRACKING); + } + + cursorAtPrompt(): boolean { + return this.getFlag(this.ctx.L.tdata.CURSOR_AT_PROMPT); + } + + promptJumpRow(top: number, delta: number): number { + 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); + 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); + } + + 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; + 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(); + return this.ctx.sbRows; + } + + getScrollbackLine(offset: number): GhosttyCell[] | 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.pages.scrollbackRow(offset).cells.slice(); + } + + isRowWrapped(row: number): boolean { + return this.refs.isRowWrapped(row); + } + + getHyperlinkUri(row: number, col: number): string | null { + if (row < 0 || row >= this.ctx.rows) return null; + return this.refs.hyperlinkAt(this.ctx.L.point.ACTIVE, col, row); + } + + getScrollbackHyperlinkUri(offset: number, col: number): string | null { + return this.refs.hyperlinkAt(this.ctx.L.point.HISTORY, col, offset); + } + + getGraphemeString(row: number, col: number): string { + 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.render.refresh(); + if (offset < 0 || offset >= this.ctx.sbRows || col < 0 || col >= this.ctx.cols) return ' '; + return graphemeOf(this.pages.rowAt(offset), col); + } + + // Selection (ADR 050) + + selectWord(cell: ScreenCell): ScreenRange | null { + return this.selection.selectWord(cell); + } + + selectLine(cell: ScreenCell): ScreenRange | null { + return this.selection.selectLine(cell); + } + + selectOutput(cell: ScreenCell): ScreenRange | null { + return this.selection.selectOutput(cell); + } + + selectionText(start: ScreenCell, end: ScreenCell): string { + return this.selection.selectionText(start, end); + } + + // Search (ADR 055) + + setSearch(needle: string): void { + this.search.setSearch(needle); + } + + searchStep(budgetMs: number): boolean { + return this.search.searchStep(budgetMs); + } + + searchSelect(direction: 'next' | 'previous'): boolean { + return this.search.searchSelect(direction); + } + + searchState(): SearchState { + return this.search.searchState(); + } + + searchSelectedMatch(): ScreenRange | null { + return this.search.searchSelectedMatch(); + } + + searchMatches(): Int32Array { + return this.search.searchMatches(); + } + + // Mouse, focus and paste (ADR 049, ADR 053) + + setMouseGeometry(geometry: MouseGeometry): void { + this.mouse.setMouseGeometry(geometry); + } + + encodeMouse(input: MouseInput): Uint8Array { + return this.mouse.encodeMouse(input); + } + + encodeFocus(focused: boolean): Uint8Array { + return this.pasteFocus.encodeFocus(focused); + } + + paste(text: string): void { + this.pasteFocus.paste(text); + } + + // Replies and effects + + hasResponse(): boolean { + return this.effects.hasResponse(); + } + + readResponse(): string | null { + return this.effects.readResponse(); + } + + readEffects(): TerminalEffects { + 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/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/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/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/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); + } +} 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/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/event-emitter.ts b/src/client/vendor/ghostty-web/event-emitter.ts new file mode 100644 index 0000000..6bc40d4 --- /dev/null +++ b/src/client/vendor/ghostty-web/event-emitter.ts @@ -0,0 +1,39 @@ +// 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/core/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); + } + } + + /** Subscribes a listener; the returned disposable removes that one subscription. */ + event: IEvent<T> = (listener): IDisposable => { + this.listeners.push(listener); + return { + dispose: () => { + const index = this.listeners.indexOf(listener); + if (index >= 0) { + this.listeners.splice(index, 1); + } + }, + }; + }; + + /** Drops every listener, for the owner's own `dispose`. */ + dispose(): void { + this.listeners = []; + } +} 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 <sig>)) (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 new file mode 100644 index 0000000..eae79e6 --- /dev/null +++ b/src/client/vendor/ghostty-web/ghostty.ts @@ -0,0 +1,118 @@ +// 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 { type OfficialLayout, resolveOfficialLayout } from './engine/official-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 } from './fn-table'; +import { Key, KeyAction, KeyEncoderOption } from './input/key-types'; + +// Re-export types for convenience +export { + CellFlags, + DirtyState, + type GhosttyCell, + type GhosttyTerminalConfig, + KeyEncoderOption, + OfficialTerminal, + type RenderStateCursor, +}; +// 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'; + +/** + * 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 engine/ 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<number, number>; + /** @internal ghostty-web `KeyEncoderOption` → official `GhosttyKeyEncoderOption`. */ + readonly encoderOptions: Map<number, number>; + /** @internal ghostty-web `KeyAction` → official `GhosttyKeyAction`. */ + readonly keyActions: Map<number, number>; + /** @internal */ + readonly unidentifiedKey: number; + + constructor(exports: OfficialWasmExports) { + this.ex = exports; + 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); + this.encoderOptions = translateEnum( + KeyEncoderOption, + layout, + 'GhosttyKeyEncoderOption', + OFFICIAL_ENCODER_OPTION_NAMES + ); + this.keyActions = translateEnum(KeyAction, layout, 'GhosttyKeyAction', {}); + 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> { + return new Ghostty(await loadWasmExports(wasmPath)); + } + + 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); + } + } +} diff --git a/src/client/vendor/ghostty-web/glide.ts b/src/client/vendor/ghostty-web/glide.ts new file mode 100644 index 0000000..3e98f62 --- /dev/null +++ b/src/client/vendor/ghostty-web/glide.ts @@ -0,0 +1,212 @@ +// 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). + +/** 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; + +/** 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; + +/** + * 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]; +} + +/** + * 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; +} + +/** + * 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; + /** 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; + + /** 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 { + 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; + } 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; + } + } + + /** + * 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; + this.target = value; + 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 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, 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 += to - this.target; + else this.from = to + ((this.from - this.target) * (this.value + delta - to)) / left; + } + this.target = to; + this.value += delta; + } + + /** Stops where the value is. */ + stop(): void { + this.moving = false; + this.omega = 0; + this.takeoverOmega = 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 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; + 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/index.ts b/src/client/vendor/ghostty-web/index.ts new file mode 100644 index 0000000..63a276f --- /dev/null +++ b/src/client/vendor/ghostty-web/index.ts @@ -0,0 +1,62 @@ +// 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, and its runtime names are +// pinned by vendor/tests/barrel.test.ts. + +// Main Terminal class +export { Terminal } from './terminal'; + +// xterm.js-compatible interfaces +export type { + ITerminalOptions, + ITheme, + ITerminalAddon, + ITerminalCore, + IDisposable, + IEvent, +} from './interfaces'; + +// Ghostty WASM components (for advanced usage) +export { + Ghostty, + OfficialTerminal, + CellFlags, + DirtyState, + KeyEncoderOption, +} from './ghostty'; +export type { + DesktopNotification, + GhosttyTerminal, + KeyEncoder, + TerminalEffects, +} from './ghostty'; +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'; +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'; +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 './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'; +export type { Ease, Pace } from './glide'; +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/input-handler.ts b/src/client/vendor/ghostty-web/input-handler.ts new file mode 100644 index 0000000..64291cf --- /dev/null +++ b/src/client/vendor/ghostty-web/input-handler.ts @@ -0,0 +1,199 @@ +// 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); +// dispose() removes every listener (ADR 065). +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; + +/** 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. + */ +export class InputHandler { + 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: EchoGuard; + private readonly listeners = new DisposableStore(); + private isComposing = false; + + 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. */ + private attach(inputElement: HTMLElement | undefined): void { + const container = this.container; + // Make container focusable so it can receive keyboard events (browser only) + 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 (inputElement) { + store.add(listen(inputElement, 'beforeinput', (e) => this.handleBeforeInput(e))); + } + store.add(listen(container, 'compositionstart', () => this.handleCompositionStart())); + // 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))); + store.add(listen(mouseTarget, 'mousemove', (e) => this.mouse.move(e as MouseEvent))); + // 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) + } + + /** 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); + } + + private handleKeyDown(event: KeyboardEvent): void { + // 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; + + // Ctrl+V and Cmd+V are not prevented, so the browser's paste event fires + if ((event.ctrlKey || event.metaKey) && event.code === 'KeyV') return; + + // 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') { + if (this.onCopy?.()) event.preventDefault(); + return; + } + + // Printable characters without modifiers are sent as they are: a-z, A-Z (with + // shift), 0-9, punctuation + if (isPrintableKey(event)) { + event.preventDefault(); + this.sendKey(event.key); + return; + } + + 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 + // and the modifiers (webtty) + const plain = plainKeySequence(key, mods); + if (plain !== null) { + event.preventDefault(); + this.sendKey(plain); + return; + } + + try { + const data = encodeKeyPress(this.encoder, key, mods, event.key, this.getMode?.(1)); + event.preventDefault(); + event.stopPropagation(); + 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); + } + } + + /** Mobile keyboards and IMEs: text, line breaks and deletes not seen as a keydown. */ + private handleBeforeInput(event: InputEvent): void { + if (this.isComposing || event.isComposing) return; + const data = event.data ?? ''; + const output = beforeInputText(event.inputType, data); + if (!output) return; + + event.preventDefault(); + event.stopPropagation(); + // 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); + } + + private handleCompositionStart(): void { + this.isComposing = true; + } + + private handleCompositionEnd(event: CompositionEvent): void { + this.isComposing = false; + const data = event.data; + if (data && data.length > 0 && !this.echoes.isBeforeInputEcho(data)) { + this.onData(data); + this.echoes.recordComposition(data); + } + removeCompositionText(this.container); + } + + /** Removes every listener; a second call does nothing. */ + dispose(): void { + 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/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/input/mouse-report.ts b/src/client/vendor/ghostty-web/input/mouse-report.ts new file mode 100644 index 0000000..2f469b1 --- /dev/null +++ b/src/client/vendor/ghostty-web/input/mouse-report.ts @@ -0,0 +1,169 @@ +// 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); + } +} + +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/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/ghostty-web/interfaces.ts b/src/client/vendor/ghostty-web/interfaces.ts new file mode 100644 index 0000000..71b0b03 --- /dev/null +++ b/src/client/vendor/ghostty-web/interfaces.ts @@ -0,0 +1,154 @@ +// 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 + cursorBlink?: boolean; // Default: false + cursorStyle?: 'block' | 'underline' | 'bar'; + theme?: ITheme; + scrollbackLines?: number; // History in lines, 0 = no line limit; default 10000 (webtty, ADR 046) + 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 + 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) + 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) + + // 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; +} + +/** The terminal's colours as CSS colour strings; unset ones keep the default theme's. */ +export interface ITheme { + foreground?: string; + background?: string; + cursor?: string; + 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; + 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; +} + +/** 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 } }; +} + +/** + * 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; +} + +/** + * 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; + /** 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; +} + +/** + * A single line in the buffer + */ +export interface IBufferLine { + /** Length of the line (in columns) */ + readonly length: number; + + /** + * 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; +} + +/** + * A single cell in the buffer + */ +export interface IBufferCell { + /** Get hyperlink ID for this cell (0 = no link) */ + getHyperlinkId(): number; + /** Get the Unicode codepoint for this cell */ + getCodepoint(): number; +} 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..d039031 --- /dev/null +++ b/src/client/vendor/ghostty-web/lifecycle/disposable-store.ts @@ -0,0 +1,133 @@ +// 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; + } + + /** + * 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; + 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/ghostty-web/link-detector.ts b/src/client/vendor/ghostty-web/link-detector.ts new file mode 100644 index 0000000..22af36b --- /dev/null +++ b/src/client/vendor/ghostty-web/link-detector.ts @@ -0,0 +1,170 @@ +// 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'; + +/** + * 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<string, ILink>(); + + // Track which rows have been scanned to avoid redundant provider calls + private scannedRows = new Set<number>(); + + // 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<ILink | undefined> { + 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<void> { + this.scannedRows.add(row); + + const allLinks: ILink[] = []; + + // Query all providers + for (const provider of this.providers) { + const links = await new Promise<ILink[] | undefined>((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 (the one inclusive test, ADR 061) + */ + private isPositionInLink(col: number, row: number, link: ILink): boolean { + const { start, end } = link.range; + return cellInRange( + { startCol: start.x, startRow: start.y, endCol: end.x, endRow: end.y }, + col, + row + ); + } + + /** + * Invalidate cache when terminal content changes + * Should be called on terminal write, resize, or clear + */ + invalidateCache(): void { + this.linkCache.clear(); + this.scannedRows.clear(); + } + + /** + * 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/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/mouse-route.ts b/src/client/vendor/ghostty-web/mouse-route.ts new file mode 100644 index 0000000..90c1351 --- /dev/null +++ b/src/client/vendor/ghostty-web/mouse-route.ts @@ -0,0 +1,78 @@ +// 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'; + +/** + * 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; +} + +/** 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; + 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<MouseEvent, 'button' | 'ctrlKey'>): 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<MouseEvent, 'button'>): boolean { + const fromButton = this.armed || e.button === 2; + this.armed = false; + return !fromButton; + } +} 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 new file mode 100644 index 0000000..c91ac06 --- /dev/null +++ b/src/client/vendor/ghostty-web/providers/osc8-link-provider.ts @@ -0,0 +1,111 @@ +// 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 '../link-types'; +import { type LinkOpener, openLinkOnModifier, windowOpen } from './open-link'; + +/** + * 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 { + /** `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. */ + provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void { + const line = this.terminal.buffer.active.getLine(y); + if (!line) { + callback(undefined); + return; + } + + const links: ILink[] = []; + for (let x = 0; x < line.length; x++) { + 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; + } + + callback(links.length > 0 ? links : undefined); + } + + /** 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; + } + + 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 { + // No resources to clean up + } +} + +/** One buffer row as the provider reads it. */ +type OSC8Line = NonNullable<ReturnType<ITerminalForOSC8Provider['buffer']['active']['getLine']>>; + +/** + * 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..7835634 --- /dev/null +++ b/src/client/vendor/ghostty-web/providers/url-regex-provider.ts @@ -0,0 +1,146 @@ +// 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 '../link-types'; +import { type LinkOpener, openLinkOnModifier, windowOpen } from './open-link'; + +/** + * 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 = /[.,;!?)\]]+$/; + + /** `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 + */ + 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 }, + }, + // Opens with Ctrl/Cmd held (ADR 061: one rule for both providers) + activate: (event) => openLinkOnModifier(url, event, this.open), + }); + } + + // 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/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/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/render/cursor-painter.ts b/src/client/vendor/ghostty-web/render/cursor-painter.ts new file mode 100644 index 0000000..81bab4c --- /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; cleared on the window that set it. */ +export const windowIntervals: IntervalTimers = { + setInterval: (run, ms) => window.setInterval(run, ms), + clearInterval: (id) => window.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/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..0db935b --- /dev/null +++ b/src/client/vendor/ghostty-web/render/frame-lines.ts @@ -0,0 +1,69 @@ +// webtty (ADR 042, ADR 061), not from upstream. +// 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 { GraphemeLookup, IRenderable, IScrollbackProvider, RowContent } 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); + }; +} + +/** 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 new file mode 100644 index 0000000..84a121a --- /dev/null +++ b/src/client/vendor/ghostty-web/render/renderable.ts @@ -0,0 +1,154 @@ +// Derived from coder/ghostty-web lib/renderer.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// 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 { + 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; +} + +/** + * 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-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/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..30fd47a --- /dev/null +++ b/src/client/vendor/ghostty-web/render/scroll-tracker.ts @@ -0,0 +1,50 @@ +// 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 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[] = []; + 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 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/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/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 new file mode 100644 index 0000000..7d1b946 --- /dev/null +++ b/src/client/vendor/ghostty-web/renderer.ts @@ -0,0 +1,497 @@ +// 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, type RowSource, rowContent } from './render/frame-lines'; +import type { + CursorStyle, + DrawCounter, + IRenderable, + IScrollbackProvider, + PaintSettings, + RendererDeps, + RendererOptions, +} from './render/renderable'; +import { + changeRow, + DEFAULT_OVERSCAN, + invalidateOutOfView, + nextBand, + ROW_INVALID, + shiftedRowStates, +} from './render/row-band'; +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, + RendererDeps, + RendererOptions, +} from './render/renderable'; +export { DEFAULT_OVERSCAN } from './render/row-band'; +export { REPAINT_CHUNK_ROWS } from './render/row-repainter'; + +/** + * 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 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 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 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 = 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 = 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 = 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 = false; + + constructor(canvas: HTMLCanvasElement, options: RendererOptions = {}, deps: RendererDeps = {}) { + this.element = 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'); + } + this.ctx = ctx; + // Frames only draw what changed, so a canvas the browser blanked would stay blank + // (webtty, ADR 043) + this.disposables.add( + listen(this.canvas, 'contextrestored', () => { + this.contextRestored = true; + }) + ); + const scratch = this.document.createElement('canvas'); + + this.overscan = Math.max(1, Math.floor(options.overscan ?? DEFAULT_OVERSCAN)); + 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) { + const stop = watchDevicePixelRatio(win(), (ratio) => { + this.settings.devicePixelRatio = wholeDevicePixelRatio(ratio); + this.canvasStale = true; + }); + this.disposables.add(toDisposable(stop)); + } + this.settings.metrics = this.measureFont(); + + 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.settings.devicePixelRatio; + } + + private measureFont(): FontMetrics { + // Use an offscreen canvas for measurement + const ctx = this.document.createElement('canvas').getContext('2d')!; + return measureFont(ctx, this.settings.fontSize, this.settings.fontFamily); + } + + /** Remeasures the font (after it loads or changes); the next frame repaints every row. */ + public remeasureFont(): void { + this.settings.metrics = this.measureFont(); + this.canvasStale = true; + } + + /** Sizes the element and the drawing canvas (with its overscan rows) for `cols` × `rows`. */ + public resize(cols: number, rows: number): void { + 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`; + this.element.style.height = `${cssHeight}px`; + this.element.width = 0; + this.element.height = 0; + + // 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) * metrics.height; + this.canvas.style.width = `${cssWidth}px`; + this.canvas.style.height = `${canvasHeight}px`; + 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(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.settings.theme.background; + this.ctx.fillRect(0, 0, cssWidth, canvasHeight); + this.repainter.freeScratch(); + this.canvasStale = true; + } + + /** + * Render the terminal buffer to canvas + * + * 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, + forceAll: boolean = false, + viewportY: number = 0, + scrollbackProvider?: IScrollbackProvider, + scrollbarOpacity: number = 1 + ): void { + 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)); + 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) + const offset = view < source.scrollbackLength ? this.clampedScrollOffset() : 0; + const above = offset > 0 ? 1 : 0; + + forceAll = this.moveBand(view, above, source.scrollbackLength, total, forceAll); + // The canvas row of viewport row 0 + const top = this.overscan + this.bandY - view; + const state = this.rowState; + if (forceAll) state.fill(ROW_INVALID); + 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 + // cursor is in now unless the same cursor is drawn there again. + const drawsCursor = view === 0 && cursor.visible && this.blink.visible; + const cursorRow = top + cursor.y; + 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; + this.scrollbar.moveTo(state, paintsScrollbar ? top : null, rows); + + const band = this.bandY; + const rowAt = (k: number) => rowContent(source, k - this.overscan, band); + const last = top + rows - 1; + 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.cursor.paint(this.ctx, cursor.x, cursorRow, cursor.y, source); + } + this.cursor.drawnAt(drawsCursor ? cursorRow : null, cursor.x); + // Render scrollbar if scrolled or scrollback exists (with opacity for fade effect) + if (paintsScrollbar) { + 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: if they persisted + // after a full redraw, the next frame might not detect new changes properly. + buffer.clearDirty(); + } + + /** + * 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 readFrame( + buffer: IRenderable, + 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 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); + } + + /** + * 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); + } + + /** + * 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 prepareCanvas( + buffer: IRenderable, + cols: number, + 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; + } + 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; + } + return forceAll; + } + + /** + * 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 moveBand( + view: number, + above: number, + scrollbackLength: number, + total: number, + forceAll: boolean + ): boolean { + if (forceAll) { + this.bandY = view; + return true; + } + 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); + } + return false; + } + + /** Sets the font size; the next frame repaints every row. */ + public setFontSize(size: number): void { + this.settings.fontSize = size; + this.remeasureFont(); + } + + /** Sets the font family; the next frame repaints every row. */ + public setFontFamily(family: string): void { + this.settings.fontFamily = family; + this.remeasureFont(); + } + + /** Sets the cursor shape; a change repaints the cursor cell on the next frame. */ + public setCursorStyle(style: CursorStyle): void { + this.cursor.setStyle(style); + } + + /** 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.blink.enabled) this.cursor.markShapeChanged(); + this.blink.setEnabled(enabled); + } + + /** The cell size and baseline, a copy. */ + public getMetrics(): FontMetrics { + return { ...this.settings.metrics }; + } + + /** The terminal's canvas element (needed by SelectionManager). */ + public getCanvas(): HTMLCanvasElement { + 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 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) + */ + public get scrollOffset(): number { + return this.clip.offset; + } + + /** + * 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. `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(): VisibleArea { + return this.clip.area(this.devicePixelRatio, this.settings.metrics.height, this.counter.draws); + } + + /** Sets the selection to draw; rows whose selection changed are repainted next frame. */ + public setSelectionManager(manager: SelectionSource): void { + this.decorations.setSelectionSource(manager); + } + + /** + * 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.decorations.setSearchHighlights(highlights); + } + + /** + * Set the currently hovered link range for rendering underlines (OSC 8 or regex links) + * Pass null to clear the hover state + */ + public setHoveredLinkRange( + range: { startX: number; startY: number; endX: number; endY: number } | null + ): void { + this.decorations.setHoveredLink( + range && { + startCol: range.startX, + startRow: range.startY, + endCol: range.endX, + endRow: range.endY, + } + ); + } + + /** Character cell width (for coordinate conversion). */ + public get charWidth(): number { + return this.settings.metrics.width; + } + + /** Character cell height (for coordinate conversion). */ + public get charHeight(): number { + return this.settings.metrics.height; + } + + /** Clears the entire canvas; the next frame repaints every row. */ + public clear(): void { + 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.settings.theme.background; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + this.canvasStale = true; + } + + /** Stops the blink and the ratio watch, removes the drawing canvas and frees the scratch. */ + public dispose(): void { + this.disposables.dispose(); + this.canvas.remove?.(); + this.repainter.freeScratch(); + } +} diff --git a/src/client/vendor/ghostty-web/search.ts b/src/client/vendor/ghostty-web/search.ts new file mode 100644 index 0000000..22aec8c --- /dev/null +++ b/src/client/vendor/ghostty-web/search.ts @@ -0,0 +1,138 @@ +// 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. */ +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/ghostty-web/selection-manager.ts b/src/client/vendor/ghostty-web/selection-manager.ts new file mode 100644 index 0000000..03e47ae --- /dev/null +++ b/src/client/vendor/ghostty-web/selection-manager.ts @@ -0,0 +1,243 @@ +// 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); dispose() releases all of it (ADR 065). + +import type { + SelectionCanvasSource, + SelectionEngineSource, + SelectionHost, +} from './core/host-types'; +import type { ScreenRange } from './engine/engine-types'; +import { EventEmitter } from './event-emitter'; +import type { IEvent } from './interfaces'; +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, type MenuTimers } 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; + +/** The browser globals the manager uses, injected by tests; production reads the page's. */ +export interface SelectionDeps { + document?: GestureDocument & ClipboardEnv['document']; + timers?: AutoScrollTimers; + /** 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; +} + +/** Owns the selection: its ends in absolute rows, its text, copy and the change event. */ +export class SelectionManager { + // 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; + + private readonly selectionChangedEmitter = new EventEmitter<void>(); + private readonly gestures: SelectionGestures; + private readonly contextMenu: ContextMenuTextarea; + private readonly clipboard: () => ClipboardEnv; + + constructor( + private readonly terminal: SelectionHost, + private readonly renderer: SelectionCanvasSource, + private readonly wasmTerm: SelectionEngineSource, + textarea: HTMLTextAreaElement, + deps: SelectionDeps = {}, + ) { + const doc = deps.document ?? document; + this.clipboard = deps.clipboard ?? (() => browserClipboardEnv(textarea, doc)); + this.contextMenu = new ContextMenuTextarea(textarea, doc, deps.menuTimers); + this.gestures = new SelectionGestures( + renderer.getCanvas(), + doc, + this.gestureHost(), + deps.timers, + ); + } + + // ========================================================================== + // Public API + // ========================================================================== + + /** + * Get the selected text as a string + */ + getSelection(): string { + if (!this.selectionStart || !this.selectionEnd) return ''; + // 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 }, + ); + } + + /** + * 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) + return !this.gestures.pressPending; + } + + /** + * 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) return false; + copyText(text, this.clipboard()); + return true; + } + + /** + * 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.gestures.endDrag(); + } + + /** + * 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; + return orderedViewportRange(this.selectionStart, this.selectionEnd, (row) => + absoluteRowToViewport(row, this.wasmTerm.getScrollbackLength(), this.viewportY()), + ); + } + + /** + * Get selection change event accessor + */ + get onSelectionChange(): IEvent<void> { + return this.selectionChangedEmitter.event; + } + + /** + * 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(); + } + + /** + * 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 { + this.contextMenu.show(e, this.hasSelection() ? this.getSelection() : null); + } + + // ========================================================================== + // Private Methods + // ========================================================================== + + /** Lines scrolled into history, as a whole number. */ + private viewportY(): number { + return wholeViewportY(this.terminal.getViewportY()); + } + + /** A viewport row as an absolute buffer row. */ + private toAbsolute(viewportRow: number): number { + return viewportRowToAbsolute( + viewportRow, + this.wasmTerm.getScrollbackLength(), + this.viewportY(), + ); + } + + /** The gestures' view of the grid and the selection. */ + private gestureHost(): GestureHost { + const { terminal, renderer, wasmTerm } = this; + return { + 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; + * report the change. (webtty) + */ + private selectionMade(): void { + if (this.terminal.options.copyOnSelect) { + const text = this.getSelection(); + if (text) copyText(text, this.clipboard()); + this.clearSelection(); + } + this.selectionChangedEmitter.fire(); + } +} 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..23ba0cd --- /dev/null +++ b/src/client/vendor/ghostty-web/selection/context-menu-textarea.ts @@ -0,0 +1,104 @@ +// 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). 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; + +/** 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 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 timers: MenuTimers = browserMenuTimers, + ) {} + + /** + * 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 + 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'; + textarea.style.width = '0'; + textarea.style.height = '0'; + textarea.style.left = '0'; + textarea.style.top = '0'; + textarea.value = ''; + 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-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..767f99f --- /dev/null +++ b/src/client/vendor/ghostty-web/selection/selection-gestures.ts @@ -0,0 +1,238 @@ +// 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); dispose() removes every +// listener it added (ADR 065). + +import type { ScreenRange } from '../engine/engine-types'; +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, + type ClientRect, + clampedOffset, + outsideRect, + pastDragThreshold, + verticalOverflow, +} from './selection-coords'; + +/** The canvas the gestures listen on. */ +export interface GestureCanvas + extends Pick<HTMLElement, 'addEventListener' | 'removeEventListener' | '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; + private readonly listeners = 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 every listener (ADR 065). Calling it again does nothing. */ + dispose(): void { + this.autoScroll.stop(); + this.listeners.dispose(); + } + + /** Adds the listeners in upstream's order, which the listener snapshot pins (ADR 064). */ + private attach(): void { + 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); + }); + on(canvas, 'mouseenter', () => { + if (this.selecting) this.autoScroll.stop(); + }); + 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 + 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. */ + 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/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts new file mode 100644 index 0000000..3d16d8b --- /dev/null +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -0,0 +1,595 @@ +// 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. +// 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 { + 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 { TerminalControllers } from './core/terminal-controllers'; +import { TerminalEmitters } from './core/terminal-emitters'; +import { cellDevicePixels, cursorCellOffset } from './core/terminal-geometry'; +import type { + DesktopNotification, + Ghostty, + GhosttyCell, + GhosttyTerminal, + SearchState, +} from './ghostty'; +import type { Pace } from './glide'; +import { InputHandler } from './input-handler'; +import type { + IBufferNamespace, + IEvent, + ITerminalAddon, + 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'; +import { CanvasRenderer } from './renderer'; +import { SelectionManager } from './selection-manager'; + +// 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 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) + public cols: number; + public rows: number; + public readonly buffer: IBufferNamespace; + /** The options; a change after open() is applied (xterm.js compatibility). */ + public readonly options: TerminalOptionValues; + + private ghostty?: Ghostty; + private parts?: TerminalParts; + private isOpen = false; + private isDisposed = false; + private addons: ITerminalAddon[] = []; + // The title last reported through onTitleChange + private currentTitle = ''; + private beforeRender?: (now: number) => void; + + private readonly emit = new TerminalEmitters(); + // Public event accessors (xterm.js compatibility) + 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.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.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.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, { + emit: this.emit, + parts: () => this.parts, + live: () => !this.isDisposed && this.isOpen, + beforeRender: (now) => this.beforeRender?.(now), + setTitle: (title) => this.setTitle(title), + render: (force, opacity) => this.renderFrame(force, opacity), + anchorTextarea: () => this.anchorTextarea(), + cellPixelSize: () => this.cellPixelSize(), + }); + + // What an option set after open() reaches + private readonly optionChangeHost: OptionChangeHost = { + options: () => this.options, + engine: () => this.wasmTerm, + 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) applyOptionChange(key, value, oldValue, this.optionChangeHost); + }); + this.cols = this.options.cols; + this.rows = this.options.rows; + 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; + } + + /** 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. + */ + get viewportY(): number { + return this.core.viewport.y; + } + + set viewportY(y: number) { + this.core.viewport.y = y; + } + + /** + * 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 { + 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); + } + + /** + * 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'); + this.isOpen = true; + const made: PartsMade<TerminalParts> = { element: parent }; + try { + 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) { + this.isOpen = false; + 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<TerminalParts>): TerminalParts { + 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); + 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); + 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 + 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, domListeners, 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 + * 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 { + const parts = this.openParts(); + if (this.options.disableStdin) return; + // Ghostty always scrolls to the bottom on a paste (webtty, ADR 055) + this.scrollToBottom(); + parts.engine.paste(text); + this.core.writes.drainReplies(); + } + + /** + * 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.core.finder.find(needle); + } + + /** 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.core.finder.finding; + } + + /** + * 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.core.finder.select('next'); + } + + /** Selects the previous match, toward newer output. False with no search open. */ + findPrevious(): boolean { + return this.core.finder.select('previous'); + } + + /** + * 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 { + const engine = this.wasmTerm; + if (engine) this.core.viewport.jumpToPrompt(delta, engine); + } + + /** + * 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, and so waits for output cut off inside a sequence to finish + * it. (webtty, ADR 054) + */ + clearScreen(): boolean { + if (this.openParts().engine.isAlternateScreen()) return false; + this.clearSelection(); + this.core.writes.clearAtGround(); + return true; + } + + /** Writes output to the terminal; `callback` runs on the next frame, after its render. */ + write(data: string | Uint8Array, callback?: () => void): void { + this.openParts(); + if (this.options.convertEol && typeof data === 'string') data = data.replace(/\n/g, '\r\n'); + this.core.writes.write(data, callback); + } + + /** Resizes the grid, the engine and the canvas, fires onResize and renders every row. */ + resize(cols: number, rows: number): void { + 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 { + this.cols = cols; + this.rows = rows; + // 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(); + 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.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.core.renderLoop.start(); + } + + /** + * 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), + * Ghostty makes one size update. (webtty, ADR 042) + */ + syncCellPixelSize(): void { + const px = this.cellPixelSize(); + if (!this.wasmTerm || !px) return; + this.wasmTerm.setCellPixelSize(px.width, px.height); + this.core.writes.drainReplies(); + } + + /** The renderer's cell in whole device pixels, or null before open(). (webtty) */ + private cellPixelSize(): { width: number; height: number } | null { + return this.renderer ? cellDevicePixels(this.renderer.getMetrics()) : null; + } + + /** Starts over on a new engine: the title and the controllers' engine state reset. */ + reset(): void { + const parts = this.openParts(); + parts.engine.free(); + parts.engine = this.ghostty!.createTerminal(this.cols, this.rows, toEngineConfig(this.options)); + this.syncCellPixelSize(); + parts.renderer.clear(); + this.currentTitle = ''; + this.core.engineReplaced(); + } + + /** + * 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.core.focus.focusContainer(); + } + + /** Activates an addon; dispose() disposes it. */ + loadAddon(addon: ITerminalAddon): void { + addon.activate(this); + this.addons.push(addon); + } + + /** The selected text, or '' with none. */ + public getSelection(): string { + return this.parts?.selection.getSelection() || ''; + } + + /** Clears the selection. */ + public clearSelection(): void { + this.parts?.selection.clearSelection(); + } + + /** Copies the selection to the clipboard; true when there was text to copy. */ + public copySelection(): boolean { + return this.parts?.selection.copySelection() || false; + } + + /** viewportY, as a method (xterm.js compatibility). */ + public getViewportY(): number { + return this.viewportY; + } + + /** Sets the embedder's wheel handler; when it returns true, the terminal does nothing. */ + public attachCustomWheelEventHandler( + customWheelEventHandler?: (event: WheelEvent) => boolean, + ): void { + this.core.wheel.setCustomHandler(customWheelEventHandler); + } + + /** + * Sets how to pace the scroll ticks of a wheel event sent to the app (mouse + * tracking, or arrow keys on the alternate screen); without it they go at once. + * Called once for each such event. (webtty, ADR 045) + */ + public attachWheelPace(pace?: (event: WheelEvent) => Pace): void { + this.core.wheel.setPace(pace); + } + + /** + * 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; + } + + /** 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.core.viewport.scrollLines(amount); + } + + /** Scrolls the view to the bottom, the current output. */ + public scrollToBottom(): void { + this.core.viewport.scrollToBottom(); + } + + /** Scrolls the view to `line` lines back from the bottom (the scrollback length is the top). */ + public scrollToLine(line: number): void { + this.core.viewport.scrollToLine(line); + } + + /** Disposes the addons, the parts and the emitters. Calling it again does nothing. */ + dispose(): void { + if (this.isDisposed) return; + this.isDisposed = true; + this.isOpen = false; + this.core.renderLoop.cancel(); + for (const addon of this.addons) addon.dispose(); + this.addons = []; + this.cleanupComponents(this.parts); + this.emit.dispose(); + } + + /** + * 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 { + this.core.focus.resync(); + } + + /** + * 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 { + const parts = this.parts; + if (!parts) return undefined; + return cursorCellOffset( + parts.engine.getCursor(), + this.viewportY, + { cols: this.cols, rows: this.rows }, + parts.renderer.getMetrics(), + { left: parts.canvas.offsetLeft, top: parts.canvas.offsetTop }, + ); + } + + /** Keeps the input textarea on the cursor cell, for the IME and dictation (webtty). */ + private anchorTextarea(): void { + const parts = this.parts; + const spot = this.getCursorCellOffset(); + if (!parts || !spot) return; + anchorTextarea(parts.textarea, spot, parts.renderer.getMetrics().height); + } + + /** A scrollback line's cells, `offset` lines from its top (IScrollbackProvider). */ + public getScrollbackLine(offset: number): GhosttyCell[] | null { + return this.wasmTerm?.getScrollbackLine(offset) ?? null; + } + + /** The number of scrollback lines, 0 before open() (IScrollbackProvider). */ + public getScrollbackLength(): number { + return this.wasmTerm?.getScrollbackLength() ?? 0; + } + + /** A scrollback cell's whole grapheme (IScrollbackProvider). */ + public getScrollbackGraphemeString(offset: number, col: number): string { + return this.wasmTerm?.getScrollbackGraphemeString(offset, col) ?? ' '; + } + + /** + * Takes down what open() made, on dispose or after a failed open(): `parts` may be + * 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) unlabelContainer(parts.element); + if (parts) disposeParts(parts); + this.parts = undefined; + this.ghostty = undefined; + } + + /** The parts of an open terminal; throws before open() and after dispose(). */ + 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.'); + } + return this.parts; + } + + /** + * Renders the current view now, if open, with the scrollbar at `opacity` (its current + * one by default); `force` repaints every row. + */ + 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.emit.titleChange.fire(title); + } + + /** Whether a mode is on: `isAnsi` for an ANSI mode, else DEC private (2004: bracketed paste). */ + public getMode(mode: number, isAnsi: boolean = false): boolean { + return this.openParts().engine.getMode(mode, isAnsi); + } + + /** Whether the app tracks the mouse. */ + public hasMouseTracking(): boolean { + return this.openParts().engine.hasMouseTracking(); + } +} diff --git a/src/client/vendor/ghostty-web/wheel.ts b/src/client/vendor/ghostty-web/wheel.ts new file mode 100644 index 0000000..28d805a --- /dev/null +++ b/src/client/vendor/ghostty-web/wheel.ts @@ -0,0 +1,143 @@ +// 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. */ +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 (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 + * 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<WheelEvent, 'deltaY' | 'deltaMode'>, + 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<WheelEvent, 'deltaY' | 'deltaMode'>, + 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); +} + +/** + * 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/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/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/core/buffer.test.ts b/src/client/vendor/tests/core/buffer.test.ts new file mode 100644 index 0000000..fa5d7a0 --- /dev/null +++ b/src/client/vendor/tests/core/buffer.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from 'bun:test'; +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. +describe('BufferNamespace', () => { + test('reads the cursor, scrollback and screen lines, codepoints and links', async () => { + 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 }); + 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(); + }); + + 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/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 new file mode 100644 index 0000000..77eb247 --- /dev/null +++ b/src/client/vendor/tests/core/dom-setup.test.ts @@ -0,0 +1,176 @@ +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]); + }); + + 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', () => { + 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('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); + 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/event-emitter.test.ts b/src/client/vendor/tests/core/event-emitter.test.ts new file mode 100644 index 0000000..46ecfd9 --- /dev/null +++ b/src/client/vendor/tests/core/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]); + }); +}); 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..3adc275 --- /dev/null +++ b/src/client/vendor/tests/core/focus-tracker.test.ts @@ -0,0 +1,306 @@ +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, + 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. */ +/** 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; + 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 every listener with its capture flag, once (ADR 065)', async () => { + const { tracker, containerLog, win, page, engine } = await tracked(); + tracker.dispose(); + tracker.dispose(); + 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('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); + }); +}); + +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); + // With no container, as after dispose(), a window focus moves nothing + 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/glide.test.ts b/src/client/vendor/tests/core/glide.test.ts new file mode 100644 index 0000000..d55866d --- /dev/null +++ b/src/client/vendor/tests/core/glide.test.ts @@ -0,0 +1,187 @@ +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 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, 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); + 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, 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); + 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 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); + 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 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); + 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/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/listener-snapshot.test.ts b/src/client/vendor/tests/core/listener-snapshot.test.ts new file mode 100644 index 0000000..0373073 --- /dev/null +++ b/src/client/vendor/tests/core/listener-snapshot.test.ts @@ -0,0 +1,157 @@ +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). +// 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 { + 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(' '); +} + +/** The listeners, in the order open() adds them; dispose() removes every one (ADR 065). */ +const SNAPSHOT = [ + 'container beforeinput -> removed', + 'container focusin -> removed', + 'window focus -> removed', + 'container focusin -> removed', + 'container focusout -> removed', + 'window blur -> removed', + 'document visibilitychange -> removed', + // The terminal's canvas: it focuses the textarea on a press or tap + 'canvas mousedown -> removed', + 'canvas touchend -> removed', + // 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 compositionend -> removed', + 'container mousedown -> removed', + 'document mouseup -> removed', + 'document mousemove -> removed', + // SelectionManager + 'canvas mousedown -> removed', + 'canvas mousemove -> removed', + 'canvas mouseleave -> removed', + 'canvas mouseenter -> removed', + 'document mousemove -> removed', + 'document mousedown -> removed', + 'document mouseup -> removed', + 'canvas click -> removed', + '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 -> removed', + '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; + // The animation frames requested and not cancelled; none ever runs + let frames: Set<number>; + + 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; + frames = new Set(); + let nextFrame = 1; + g.requestAnimationFrame = () => { + frames.add(nextFrame); + return nextFrame++; + }; + g.cancelAnimationFrame = (id: number) => frames.delete(id); + }); + + 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, 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 () => { + 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('no listener is left after dispose(), and each went with its capture flag', async () => { + const term = await openTerminal(); + term.dispose(); + 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 new file mode 100644 index 0000000..298ff4d --- /dev/null +++ b/src/client/vendor/tests/core/mouse-controller.test.ts @@ -0,0 +1,400 @@ +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 window.clearTimeout when called", () => { + const g = globalThis as Record<string, unknown>; + const saved = g.window; + const scheduled: number[] = []; + 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(1); + expect(cleared).toEqual([1]); + } finally { + g.window = saved; + } + }); +}); 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 new file mode 100644 index 0000000..4c71d43 --- /dev/null +++ b/src/client/vendor/tests/core/options-proxy.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, test } from 'bun:test'; +import { + createOptionsProxy, + parseThemeColor, + rendererOptions, + 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, + }); + }); +}); + +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/render-loop.test.ts b/src/client/vendor/tests/core/render-loop.test.ts new file mode 100644 index 0000000..b7128d5 --- /dev/null +++ b/src/client/vendor/tests/core/render-loop.test.ts @@ -0,0 +1,106 @@ +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, 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'), + 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("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(); + 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/scrollbar-controller.test.ts b/src/client/vendor/tests/core/scrollbar-controller.test.ts new file mode 100644 index 0000000..caaa774 --- /dev/null +++ b/src/client/vendor/tests/core/scrollbar-controller.test.ts @@ -0,0 +1,273 @@ +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('dispose() removes the document mouseup too, with its flags (ADR 065)', () => { + dom = createDomWindow(); + const doc = recordListeners(dom.document, 'document'); + 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.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', () => { + const before = Date.now(); + expect(browserScrollbarClock.now()).toBeGreaterThanOrEqual(before); +}); 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..719d3ae --- /dev/null +++ b/src/client/vendor/tests/core/sync-hold.test.ts @@ -0,0 +1,260 @@ +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 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(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); + }); + + 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; + hold.expire(800); + expect(hold.held(800 + SYNC_HOLD_MAX_MS - 1)).toBe(true); + }); +}); + +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(); + 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(); + // 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); + expect(render).toHaveBeenCalledTimes(1); + clock.frame(); + expect(render).toHaveBeenCalledTimes(2); + } 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('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('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 { + 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..e48e9b1 --- /dev/null +++ b/src/client/vendor/tests/core/sync-scan.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from 'bun:test'; +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, the byte scan agreeing with the string scan, and where the +// last update in a chunk ends. + +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(''); + }); +}); + +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); + } + } + }); +}); + +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 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); + expect(lastSyncEnd(new TextEncoder().encode(data))).toBe(11); + }); +}); 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..060e695 --- /dev/null +++ b/src/client/vendor/tests/core/terminal-controllers.test.ts @@ -0,0 +1,406 @@ +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, + syncHoldHost, + TerminalControllers, + wheelHost, + writeHost, +} 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/core/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 and modes + const parts = { + selection: { clearSelection: () => void log.push('clear selection') }, + engine: { hasResponse: () => false, getMode: () => 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 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, doc.active]).toEqual([[], []]); + expect(container.removed.length + doc.removed.length).toBe(removed); + }); +}); + +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); + }); +}); + +/** + * 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[] = []; + /** 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, + 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'), + 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')); + emit.updatePainted.event(() => void log.push('update painted')); + 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.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'); + return { log, parts, term, inner, core, sync }; +} + +// 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, sync } = world(); + const host = writeHost(term, inner, core); + expect<unknown[]>([host.engine(), host.disableStdin(), host.findAnchored()]).toEqual([ + parts.engine, + true, + 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(); + expect(host.pinView()).toBe(true); + host.unpinView(); + host.wroteOutput('o'); + host.renderEcho(); + // 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', + 'bell', + 'title t', + 'clipboard c', + 'notify n', + 'links stale', + 'scroll to bottom', + 'pin true', + 'unpin true', + 'wrote o', + 'render false undefined', + 'render false undefined', + 'update painted', + ]); + }); + + 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, sync } = world(); + const host = renderLoopHost(inner, core); + expect<unknown[]>([host.live(), host.hasReplies(), host.renderHeld(0)]).toEqual([ + true, + true, + false, + ]); + 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); + 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-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 new file mode 100644 index 0000000..962f97b --- /dev/null +++ b/src/client/vendor/tests/core/terminal-geometry.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, test } from 'bun:test'; +import { + cellDevicePixels, + cursorCellOffset, + deviceCellPixels, + linkViewportRange, + mouseCursorFor, + mouseSurface, + pageDevicePixelRatio, + 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('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 }; + 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/terminal.test.ts b/src/client/vendor/tests/core/terminal.test.ts new file mode 100644 index 0000000..d4c5a3f --- /dev/null +++ b/src/client/vendor/tests/core/terminal.test.ts @@ -0,0 +1,720 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import type { ITerminalAddon } from '../../ghostty-web/interfaces'; +import { Terminal } from '../../ghostty-web/terminal'; +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', () => { + 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 }); + 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', 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; + term.options.cursorBlink = true; + expect(defaults).toEqual([ + ['underline', false], + ['underline', true], + ]); + }); + + 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`); + }); +}); + +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('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, clock } = await open(); + const focused = () => doc.activeElement; + doc.activeElement = null; + term.focus(); + expect(focused()).toBe(container); + doc.activeElement = null; + 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); + 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). + test('sends the text as Ghostty encodes it for the mode', async () => { + const { term, sent } = await open(); + sent.length = 0; + term.paste('one\ntwo\x1b'); + term.write('\x1b[?2004h'); + term.paste('three'); + expect(sent).toEqual(['one\rtwo ', '\x1b[200~three\x1b[201~']); + }); + + test('sends nothing with stdin disabled', async () => { + const { term, sent } = await open({ disableStdin: true }); + term.paste('hello'); + expect(sent).toEqual([]); + 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). + 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, sent } = await open(); + fill(term); + term.write('$ make'); + expect(term.clearScreen()).toBe(true); + 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(screenText(term)).toEqual(['$ make', 'ok', '', '', '']); + expect(sent).toEqual([]); + }); + + test('with the cursor on the top row, drops only the scrollback', async () => { + const { term } = await open(); + fill(term); + term.write('\x1b[H'); + expect(term.clearScreen()).toBe(true); + 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, sent } = await open(); + fill(term); + term.write('\x1b]133;A\x07~ > \x1b]133;B\x07ls'); + expect(engineOf(term).cursorAtPrompt()).toBe(true); + expect(term.clearScreen()).toBe(true); + expect(screenText(term)).toEqual(['', '', '', '', '']); + expect(scrollbackLines(term)).toEqual([]); + expect(sent).toEqual(['\x0c']); + }); + + test('sends no FF with stdin disabled', async () => { + 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([]); + }); + + test('does nothing on the alternate screen, where the key goes to the app', async () => { + const { term, sent } = await open(); + fill(term); + term.write('\x1b[?1049h\x1b[Hvim'); + expect(term.clearScreen()).toBe(false); + expect(screenText(term)[0]).toBe('vim'); + term.write('\x1b[?1049l'); + expect(term.getScrollbackLength()).toBe(4); + expect(sent).toEqual([]); + }); + + test('clears the selection', async () => { + const { term } = await open(); + fill(term); + let cleared = 0; + term.clearSelection = () => void cleared++; + term.clearScreen(); + expect(cleared).toBe(1); + }); + + test('waits for a sequence cut off by the end of a write, and clears where it ends', async () => { + const { term } = await open(); + fill(term); + term.write('build \x1b[3'); + expect(term.clearScreen()).toBe(true); + 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(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); + }); + + test('a cut-off OSC keeps its effect', async () => { + 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(scrollbackLines(term)).toEqual([]); + }); + + test('a write that ends inside another sequence still gets the clear', async () => { + const { term } = await open(); + fill(term); + term.write('a\x1b[3'); + term.clearScreen(); + term.write('1mb\x1b[0'); + expect(scrollbackLines(term)).toEqual([]); + term.write('mc'); + 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, 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(screenText(term)).toEqual(['', '', '', '', '']); + expect(sent).toEqual(['\x0c']); + }); + + test('a waiting clear is dropped if the screen has turned alternate', async () => { + const { term } = await open(); + fill(term); + term.write('\x1b[?1049'); + term.clearScreen(); + term.write('h\x1b[Hvim'); + term.write('\x1b[?1049l'); + 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 harness = await open({ rows: 4, scrollbackLines: 1000 }); + const { term, clock } = harness; + const highlights: unknown[] = []; + 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)); + /** 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] }; + } + + 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); + 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'); + 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); + expect(term.findPrevious()).toBe(true); + }); + + 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(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 () => { + // Ghostty's `scroll-to-bottom = keystroke` (ADR 055) + const { term, frames, container } = await findTerm(); + needleAbove(term); + term.find('needle'); + frames(2); + term.findNext(); + expect(term.viewportY).toBeGreaterThan(0); + container.dispatch(keydown('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); + }); + + test('output that switches to the alternate screen scrolls to the bottom', async () => { + const { term, frames } = await findTerm(); + needleAbove(term); + 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); + }); + + test('ending the search clears the highlights and the count', async () => { + const { term, 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(); + expect(term.findNext()).toBe(false); + }); + + test('a reset (reconnect) searches the new engine', async () => { + const { term, 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 }); + }); +}); + +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)); + term.write('\x1b]2;vim\x07\x07'); + term.write('\x1b]2;vim\x07'); + expect([bells, titles]).toEqual([1, ['vim']]); + }); + + test('OSC 52 output fires onClipboardWrite with the decoded text, once', async () => { + const { term } = await open(); + const copied: string[] = []; + term.onClipboardWrite((text) => copied.push(text)); + term.write(`\x1b]52;c;${Buffer.from('héllo 中文').toString('base64')}\x07`); + expect(copied).toEqual(['héllo 中文']); + term.write('plain output'); + term.write('\x1b]52;c;\x07'); + expect(copied).toEqual(['héllo 中文', '']); + }); + + test('OSC 9 and OSC 777 output fire onDesktopNotification, each once, in order', async () => { + const { term } = await open(); + const shown: { title: string; body: string }[] = []; + term.onDesktopNotification((n) => shown.push(n)); + 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' }, + ]); + 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', () => { + // 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); + }); + + 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', () => { + 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('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(markedSession()); + 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]); + }); + + 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); + }); + + 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'); + }); +}); 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..e22224a --- /dev/null +++ b/src/client/vendor/tests/core/viewport.test.ts @@ -0,0 +1,190 @@ +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, +// 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'), + }; + 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('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; + expect(view.y).toBe(7); + 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); + }); +}); + +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/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/core/write-pipeline.test.ts b/src/client/vendor/tests/core/write-pipeline.test.ts new file mode 100644 index 0000000..805ed47 --- /dev/null +++ b/src/client/vendor/tests/core/write-pipeline.test.ts @@ -0,0 +1,323 @@ +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 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, + 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; + /** The engine write throws, as on a wasm trap. */ + 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. */ +function recorded(options: Options = {}) { + const calls: string[] = []; + const replies = [...(options.replies ?? [])]; + 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, + 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'), + findAnchored: () => options.anchored ?? false, + scrollToBottom: () => calls.push('scrollToBottom'), + pinView: () => { + calls.push('pinView'); + return (options.viewportY ?? 0) > 0; + }, + unpinView: () => calls.push('unpinView'), + syncPaintPoint: () => options.paintAt ?? -1, + paintUpdate: () => { + calls.push('paintUpdate'); + if (options.paintThrows) throw new Error('paint'); + }, + wroteOutput: (data) => calls.push(`wroteOutput ${JSON.stringify(data)}`), + renderEcho: () => calls.push('renderEcho'), + }; +} + +describe('WritePipeline order', () => { + test('view pinned, write, sync timing, replies, focus edge, effects, link cache, 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([ + 'pinView', + 'engine.write "x"', + 'unpinView', + 'wroteOutput "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', + '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([ + 'pinView', + 'engine.write "x"', + 'wroteOutput "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, 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); + 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('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, 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', () => { + 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 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() { + 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/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/engine-config.test.ts b/src/client/vendor/tests/engine/engine-config.test.ts new file mode 100644 index 0000000..9392347 --- /dev/null +++ b/src/client/vendor/tests/engine/engine-config.test.ts @@ -0,0 +1,207 @@ +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, the scrollback limit in lines, and the cursor blink kept in mode 12, +// through OfficialTerminal 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('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); + } + }); +}); + +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-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/engine-effects.test.ts b/src/client/vendor/tests/engine/engine-effects.test.ts new file mode 100644 index 0000000..248df4e --- /dev/null +++ b/src/client/vendor/tests/engine/engine-effects.test.ts @@ -0,0 +1,294 @@ +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, 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, through OfficialTerminal 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('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([]); + }); +}); + +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 new file mode 100644 index 0000000..a2ae74c --- /dev/null +++ b/src/client/vendor/tests/engine/grid-refs.test.ts @@ -0,0 +1,80 @@ +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, wrap flags, and the cell lookups the selection and search use. + +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(); + }); +}); + +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/key-encoder-binding.test.ts b/src/client/vendor/tests/engine/key-encoder-binding.test.ts new file mode 100644 index 0000000..c8df009 --- /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/input/key-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/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/mouse-encoder-binding.test.ts b/src/client/vendor/tests/engine/mouse-encoder-binding.test.ts new file mode 100644 index 0000000..e071e5f --- /dev/null +++ b/src/client/vendor/tests/engine/mouse-encoder-binding.test.ts @@ -0,0 +1,241 @@ +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, + MouseInput, + OfficialTerminal, +} from '../../ghostty-web/ghostty'; +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'; + +// The wasm mouse encoder (ADR 049, ADR 061): every tracking mode and report format, the +// modifiers, the padding and the last-cell de-dup, through OfficialTerminal 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('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('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-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/official-replay.test.ts b/src/client/vendor/tests/engine/official-replay.test.ts new file mode 100644 index 0000000..024a044 --- /dev/null +++ b/src/client/vendor/tests/engine/official-replay.test.ts @@ -0,0 +1,527 @@ +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'; + +// 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 COLS = 120; +const ROWS = 40; +const THEME = { fgColor: 0xd4d4d4, bgColor: 0x1e1e1e }; + +let vt: Ghostty; + +beforeAll(async () => { + vt = await loadGhostty(); +}); + +interface Cell { + text: string; + fg: number; + bg: number; + flags: number; + width: number; +} + +type Row = Cell[]; + +function toRow(line: ReturnType<GhosttyTerminal['getLine']>, 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<T>(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_DIR, `${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/tests/engine/official-terminal.test.ts b/src/client/vendor/tests/engine/official-terminal.test.ts new file mode 100644 index 0000000..6a5b16e --- /dev/null +++ b/src/client/vendor/tests/engine/official-terminal.test.ts @@ -0,0 +1,374 @@ +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 { 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/, through this 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('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(); + }); +}); + +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 new file mode 100644 index 0000000..cf22f13 --- /dev/null +++ b/src/client/vendor/tests/engine/paste-focus-binding.test.ts @@ -0,0 +1,118 @@ +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, through OfficialTerminal 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('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(); + }); +}); + +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/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/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/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/engine/search-binding.test.ts b/src/client/vendor/tests/engine/search-binding.test.ts new file mode 100644 index 0000000..50dab2d --- /dev/null +++ b/src/client/vendor/tests/engine/search-binding.test.ts @@ -0,0 +1,166 @@ +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 { 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, through OfficialTerminal 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('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(); + }); +}); + +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 new file mode 100644 index 0000000..0285c5f --- /dev/null +++ b/src/client/vendor/tests/engine/selection-binding.test.ts @@ -0,0 +1,161 @@ +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, through OfficialTerminal 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('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(); + }); +}); + +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/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); + }); +}); 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); + } + }); +}); diff --git a/src/client/vendor/tests/fixtures/less.bin b/src/client/vendor/tests/fixtures/less.bin new file mode 100644 index 0000000..c1a82f9 --- /dev/null +++ b/src/client/vendor/tests/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/tests/fixtures/ls.bin b/src/client/vendor/tests/fixtures/ls.bin new file mode 100644 index 0000000..ecccc51 --- /dev/null +++ b/src/client/vendor/tests/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/tests/fixtures/nvim.bin b/src/client/vendor/tests/fixtures/nvim.bin new file mode 100644 index 0000000..e033732 --- /dev/null +++ b/src/client/vendor/tests/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/tests/fixtures/seq.bin b/src/client/vendor/tests/fixtures/seq.bin new file mode 100644 index 0000000..7d17149 --- /dev/null +++ b/src/client/vendor/tests/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/tests/fixtures/sgr.bin b/src/client/vendor/tests/fixtures/sgr.bin new file mode 100644 index 0000000..34aa321 --- /dev/null +++ b/src/client/vendor/tests/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/tests/fixtures/top.bin b/src/client/vendor/tests/fixtures/top.bin new file mode 100644 index 0000000..0caedbe --- /dev/null +++ b/src/client/vendor/tests/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/tests/fixtures/uni.bin b/src/client/vendor/tests/fixtures/uni.bin new file mode 100644 index 0000000..e15d1a6 --- /dev/null +++ b/src/client/vendor/tests/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/tests/fixtures/vim.bin b/src/client/vendor/tests/fixtures/vim.bin new file mode 100644 index 0000000..9271456 --- /dev/null +++ b/src/client/vendor/tests/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/tests/ghostty-vt.test.ts b/src/client/vendor/tests/ghostty-vt.test.ts new file mode 100644 index 0000000..fc6e0b3 --- /dev/null +++ b/src/client/vendor/tests/ghostty-vt.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from 'bun:test'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { + GHOSTTY_MINISIGN_PUBKEY, + type Sidecar, + verifyMinisign, + verifyPinned, +} from '../../../../scripts/update-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. +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(GHOSTTY_VT_DIR, 'ghostty-vt-small.wasm.json'), 'utf8'), + ) as Sidecar; + 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'); + 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(GHOSTTY_VT_DIR, GHOSTTY_PUBLIC_KEY)).not.toThrow(); + }); + + test('ships with the Ghostty MIT licence', () => { + const license = fs.readFileSync(path.join(GHOSTTY_VT_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(GHOSTTY_VT_DIR, 'ghostty-vt-small.wasm.json'), 'utf8'), + ) as Sidecar; + 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(); + 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/tests/input/input-handler.test.ts b/src/client/vendor/tests/input/input-handler.test.ts new file mode 100644 index 0000000..911c941 --- /dev/null +++ b/src/client/vendor/tests/input/input-handler.test.ts @@ -0,0 +1,491 @@ +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'; +import { loadGhostty } from '../support/wasm'; + +// 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()); + +/** An InputHandler on the test's container and textarea, recording what it sends. */ +function handler( + options: Partial<InputHandlerOptions> & { appCursor?: boolean } = {}, +): InputHandler { + const { appCursor, ...rest } = options; + return new InputHandler({ + ghostty, + container, + onData: (data, mouseReport) => { + sent.push(data); + reports.push(mouseReport ?? false); + }, + getMode: (mode) => mode === 1 && !!appCursor, + inputElement: textarea, + ...rest, + }); +} + +/** 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 }; +} + +/** 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; +} + +/** 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', '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([4, 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, onData: (data) => sent.push(data) }).dispose(); + expect(spy.mock.calls.map(([type]) => type)).not.toContain('beforeinput'); + }); +}); + +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']); + }); + + 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('Shift+Enter is CR, prevented but not stopped', () => { + handler(); + expect(keydown('Enter', 'Enter', { shiftKey: true })).toEqual({ + prevented: true, + propagated: true, + }); + expect(sent).toEqual(['\r']); + }); + + 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([]); + }); + + 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([]); + }); + + test('Cmd+C with no copy callback passes through', () => { + handler(); + expect(keydown('KeyC', 'c', { metaKey: true }).prevented).toBe(false); + expect(sent).toEqual([]); + }); + + 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([]); + }); + + test('a key the table does not know is left alone', () => { + handler(); + expect(keydown('MediaPlayPause', 'MediaPlayPause').prevented).toBe(false); + expect(sent).toEqual([]); + }); + + 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 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) { + handler({ appCursor }); + return (code: string, key: string, mods: string[] = []) => { + sent = []; + keydown(code, key, { + shiftKey: mods.includes('shift'), + ctrlKey: mods.includes('ctrl'), + altKey: mods.includes('alt'), + metaKey: mods.includes('meta'), + }); + return sent.join(''); + }; + } + + 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'); + 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', () => { + 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~'); + 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', () => { + const send = 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', () => { + const send = 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', () => { + 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', () => { + 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('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('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'); + 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({ 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 }); + 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({ mouseConfig: 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..a145620 --- /dev/null +++ b/src/client/vendor/tests/input/mouse-report.test.ts @@ -0,0 +1,236 @@ +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, + mouseReportText, +} 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([]); + }); +}); + +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/input/mouse-route.test.ts b/src/client/vendor/tests/input/mouse-route.test.ts new file mode 100644 index 0000000..af04764 --- /dev/null +++ b/src/client/vendor/tests/input/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); + }); +}); 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); + }); +}); diff --git a/src/client/vendor/tests/input/wheel.test.ts b/src/client/vendor/tests/input/wheel.test.ts new file mode 100644 index 0000000..945369c --- /dev/null +++ b/src/client/vendor/tests/input/wheel.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, test } from 'bun:test'; +import { + TickPacer, + type WheelAccumulator, + wheelArrowKey, + wheelArrows, + 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('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/vendor/tests/lifecycle/disposable-store.test.ts b/src/client/vendor/tests/lifecycle/disposable-store.test.ts new file mode 100644 index 0000000..8a16216 --- /dev/null +++ b/src/client/vendor/tests/lifecycle/disposable-store.test.ts @@ -0,0 +1,130 @@ +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('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(); + 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']); + }); + }); +}); diff --git a/src/client/vendor/tests/links/link-detector.test.ts b/src/client/vendor/tests/links/link-detector.test.ts new file mode 100644 index 0000000..368467c --- /dev/null +++ b/src/client/vendor/tests/links/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/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(); + }); +}); diff --git a/src/client/vendor/tests/provenance.test.ts b/src/client/vendor/tests/provenance.test.ts new file mode 100644 index 0000000..fc822fd --- /dev/null +++ b/src/client/vendor/tests/provenance.test.ts @@ -0,0 +1,87 @@ +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([]); + }); + + // 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([]); + }); +}); 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..5809f16 --- /dev/null +++ b/src/client/vendor/tests/render/cell-painter.test.ts @@ -0,0 +1,335 @@ +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 { recordingContext } from '../support/recording-context'; +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); + }); +}); + +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/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); + }); +}); 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..242b8cb --- /dev/null +++ b/src/client/vendor/tests/render/cursor-painter.test.ts @@ -0,0 +1,274 @@ +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 { recordingContext } from '../support/recording-context'; +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); + }); +}); + +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/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..5294c25 --- /dev/null +++ b/src/client/vendor/tests/render/frame-lines.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test } from 'bun:test'; +import type { GhosttyCell } from '../../ghostty-web/engine/cell-types'; +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. + +/** 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(); + }); +}); + +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/renderer.test.ts b/src/client/vendor/tests/render/renderer.test.ts new file mode 100644 index 0000000..5e660f5 --- /dev/null +++ b/src/client/vendor/tests/render/renderer.test.ts @@ -0,0 +1,247 @@ +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 { + 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 change = (next: number) => { + win.devicePixelRatio = next; + for (const listener of [...listeners]) { + listeners.delete(listener); + listener(); + } + }; + return { win: win as unknown as Window, change, listeners }; +} + +/** 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 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; + // 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(`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.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.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; + live.r.render(term, false, 0, provider, 0); + expect(live.drawn.length).toBe(before); + 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('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 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(); + }); + + // 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); + live.r.render(term, true, 0, provider, 0); + const before = live.drawn.length; + const full = new Proxy(term, { + get: (t, k) => { + if (k === 'needsFullRedraw') return () => true; + const v = t[k as keyof typeof t]; + return typeof v === 'function' ? v.bind(t) : v; + }, + }); + 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 writtenTerminal(); + for (const y of [6, 0]) { + 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 = rasterRenderer(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(); + }); + + 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/render/row-band.test.ts b/src/client/vendor/tests/render/row-band.test.ts new file mode 100644 index 0000000..40e2230 --- /dev/null +++ b/src/client/vendor/tests/render/row-band.test.ts @@ -0,0 +1,256 @@ +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; + // 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; + 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 + view + 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/row-repainter.test.ts b/src/client/vendor/tests/render/row-repainter.test.ts new file mode 100644 index 0000000..77e08db --- /dev/null +++ b/src/client/vendor/tests/render/row-repainter.test.ts @@ -0,0 +1,381 @@ +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 { recordingContext } from '../support/recording-context'; +import { + COLS, + type LineSelection, + previousFrame, + ROWS, + rasterRenderer, + scrollbackOf, + selectionOf, + splitPosition, + writtenTerminal, +} from '../support/renderer-harness'; +import { loadGhostty } from '../support/wasm'; + +/** 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..5abbe7e --- /dev/null +++ b/src/client/vendor/tests/render/scroll-tracker.test.ts @@ -0,0 +1,45 @@ +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, 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], + ['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-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); + }); +}); 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..e829e23 --- /dev/null +++ b/src/client/vendor/tests/render/scrollbar-painter.test.ts @@ -0,0 +1,98 @@ +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'; +import { recordingContext } from '../support/recording-context'; + +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/search.test.ts b/src/client/vendor/tests/render/search.test.ts new file mode 100644 index 0000000..6900c82 --- /dev/null +++ b/src/client/vendor/tests/render/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); + }); +}); 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/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..40526b7 --- /dev/null +++ b/src/client/vendor/tests/selection/context-menu-textarea.test.ts @@ -0,0 +1,127 @@ +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'; +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); + 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('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'); + 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..714dc53 --- /dev/null +++ b/src/client/vendor/tests/selection/selection-gestures.test.ts @@ -0,0 +1,315 @@ +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 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); + 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.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 new file mode 100644 index 0000000..0a43d7d --- /dev/null +++ b/src/client/vendor/tests/selection/selection-manager.test.ts @@ -0,0 +1,408 @@ +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 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); + 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); + expect([f.doc.active, f.canvas.active]).toEqual([[], []]); + expect(f.canvas.removed.map((r) => r.type).sort()).toEqual([ + 'click', + 'mousedown', + 'mouseenter', + 'mouseleave', + 'mousemove', + ]); + const removed = f.doc.removed.length + f.canvas.removed.length; + f.m.dispose(); + expect(f.doc.removed.length + f.canvas.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/engine-harness.ts b/src/client/vendor/tests/support/engine-harness.ts new file mode 100644 index 0000000..e630bee --- /dev/null +++ b/src/client/vendor/tests/support/engine-harness.ts @@ -0,0 +1,75 @@ +// Engine parts on a real wasm instance without the OfficialTerminal facade (test support, +// 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, 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. */ +export function renderContext( + vt: Ghostty, + cols: number, + rows: number, +): EngineContext<RenderStateSlot> { + 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); + 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/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..eabd14c --- /dev/null +++ b/src/client/vendor/tests/support/fake-event-target.ts @@ -0,0 +1,172 @@ +// 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; + offsetLeft = 0; + offsetTop = 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; + } + + 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 }; + } +} + +/** + * 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 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'); + 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 { + this.intervals.push(ms); + return this.addTimer(); + } + + 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(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/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/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 }; +} 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(), + }; + }, + }; +} 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..0aa4ff1 --- /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, + menuTimers: clock, + 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 }; +} 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..6ba2581 --- /dev/null +++ b/src/client/vendor/tests/support/terminal-harness.ts @@ -0,0 +1,138 @@ +// 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(). + +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', + 'performance', +] 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 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>; + 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; + // Writes are timed on performance.now(), frames on their timestamps: one clock for both + g.performance = { now: () => clock.now() }; + 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/support/terminal-text.ts b/src/client/vendor/tests/support/terminal-text.ts new file mode 100644 index 0000000..10f3b31 --- /dev/null +++ b/src/client/vendor/tests/support/terminal-text.ts @@ -0,0 +1,65 @@ +// 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'); + +/** 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$ `; diff --git a/src/client/vendor/tests/support/wasm.ts b/src/client/vendor/tests/support/wasm.ts new file mode 100644 index 0000000..2052ed9 --- /dev/null +++ b/src/client/vendor/tests/support/wasm.ts @@ -0,0 +1,78 @@ +// 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]); +} + +/** 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. + */ +export async function countedGhostty( + names: readonly string[], +): Promise<{ ghostty: Ghostty; calls: ExportCalls }> { + const calls: ExportCalls = {}; + 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 }; +} diff --git a/src/config.test.ts b/src/config.test.ts deleted file mode 100644 index 89b16a8..0000000 --- a/src/config.test.ts +++ /dev/null @@ -1,334 +0,0 @@ -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 './config'; - -let tmpDir: string; -let configPath: string; -let origHome: string | undefined; - -beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webtty-config-test-')); - configPath = path.join(tmpDir, '.config', 'webtty', 'config.json'); - origHome = process.env.HOME; - process.env.HOME = tmpDir; -}); - -afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - if (origHome === undefined) { - delete process.env.HOME; - } else { - process.env.HOME = origHome; - } -}); - -describe('initConfig', () => { - test('creates the config directory if it does not exist', () => { - initConfig(); - expect(fs.existsSync(path.dirname(configPath))).toBe(true); - }); - - test('writes valid JSON', () => { - initConfig(); - const raw = fs.readFileSync(configPath, 'utf8'); - expect(() => JSON.parse(raw)).not.toThrow(); - }); - - test('written file contains port and host', () => { - initConfig(); - const raw = fs.readFileSync(configPath, 'utf8'); - expect(raw).toContain('"port"'); - expect(raw).toContain('"host"'); - }); -}); - -describe('loadConfig — first run', () => { - test('creates the config file when it does not exist', () => { - expect(fs.existsSync(configPath)).toBe(false); - loadConfig(); - expect(fs.existsSync(configPath)).toBe(true); - }); - - test('DEFAULT_CONFIG.term is xterm-256color', () => { - expect(DEFAULT_CONFIG.term).toBe('xterm-256color'); - }); - - test('returns config that equals DEFAULT_CONFIG after first run', () => { - const config = loadConfig(); - expect(config.port).toBe(DEFAULT_CONFIG.port); - expect(config.host).toBe(DEFAULT_CONFIG.host); - expect(config.cols).toBe(DEFAULT_CONFIG.cols); - expect(config.rows).toBe(DEFAULT_CONFIG.rows); - expect(config.fontSize).toBe(DEFAULT_CONFIG.fontSize); - expect(config.cursorStyleBlink).toBe(DEFAULT_CONFIG.cursorStyleBlink); - expect(config.cursorStyle).toBe(DEFAULT_CONFIG.cursorStyle); - expect(config.scrollback).toBe(DEFAULT_CONFIG.scrollback); - expect(config.theme).toEqual(DEFAULT_CONFIG.theme); - }); - - test('warns and returns defaults when write fails', () => { - const configDir = path.dirname(configPath); - fs.mkdirSync(path.dirname(configDir), { recursive: true }); - fs.writeFileSync(configDir, 'not-a-dir'); - - const warned: string[] = []; - const origWarn = console.warn; - console.warn = (...args: unknown[]) => warned.push(args.join(' ')); - - let config: ReturnType<typeof loadConfig> | undefined; - expect(() => { - config = loadConfig(); - }).not.toThrow(); - - console.warn = origWarn; - - expect(warned.some((w) => w.includes('webtty:'))).toBe(true); - expect(config?.port).toBe(DEFAULT_CONFIG.port); - }); -}); - -describe('loadConfig — reads and merges', () => { - function writeConfig(content: string) { - fs.mkdirSync(path.dirname(configPath), { recursive: true }); - fs.writeFileSync(configPath, content, 'utf8'); - } - - test('returns full defaults when file has no overrides', () => { - writeConfig('{}'); - const config = loadConfig(); - expect(config.port).toBe(DEFAULT_CONFIG.port); - expect(config.cols).toBe(DEFAULT_CONFIG.cols); - expect(config.theme).toEqual(DEFAULT_THEME); - }); - - test('overrides port when set in file', () => { - writeConfig(JSON.stringify({ port: 9999 })); - 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(); - expect(config.theme.background).toBe('#000000'); - expect(config.theme.foreground).toBe(DEFAULT_THEME.foreground); - 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('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('overrides rightClickBehavior when set to copyPaste', () => { - writeConfig(JSON.stringify({ rightClickBehavior: 'copyPaste' })); - 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('throws on invalid JSON', () => { - writeConfig('{ not valid json }'); - expect(() => loadConfig()).toThrow(/invalid JSON/); - }); - - test('throws with webtty: prefix in message on read error', () => { - fs.mkdirSync(configPath, { recursive: true }); - expect(() => loadConfig()).toThrow(/webtty:/); - }); -}); - -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]); - }); - - 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'); - }); -}); diff --git a/src/config.ts b/src/config.ts deleted file mode 100644 index 211f311..0000000 --- a/src/config.ts +++ /dev/null @@ -1,329 +0,0 @@ -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; - /** 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; -} - -/** 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'; - -/** 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. */ - scrollback: 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; - /** Auto-copy selection to clipboard on mouseup (kitty / Windows Terminal style). */ - copyOnSelect: boolean; - /** `"copyPaste"` copies selection + clears it on right-click if selection exists; `"default"` always shows native context menu. */ - 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; - /** 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, - cols: 80, - rows: 24, - fontSize: 13, - fontFamily: "Menlo, Consolas, 'DejaVu Sans Mono', monospace", - cursorStyle: 'bar', - cursorStyleBlink: true, - copyOnSelect: true, - rightClickBehavior: 'default' as RightClickBehavior, - 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; -} - -/** - * Load config from `~/.config/webtty/config.json`, merged over `DEFAULT_CONFIG`. - * - * On first run (file absent), writes the default port/host stub and returns defaults. - * Unknown keys and keys with wrong types are silently ignored. - * Throws if the file exists but cannot be read or contains invalid JSON. - * - * @returns The resolved {@link Config}, with all missing keys filled from {@link DEFAULT_CONFIG}. - */ -export function loadConfig(): Config { - if (!fs.existsSync(getConfigPath())) { - try { - initConfig(); - } catch (err) { - console.warn( - `webtty: failed to write default config to ${getConfigPath()}: ${(err as Error).message}`, - ); - return { ...DEFAULT_CONFIG }; - } - } - - let raw: string; - try { - raw = fs.readFileSync(getConfigPath(), 'utf8'); - } catch (err) { - throw new Error( - `webtty: failed to read config at ${getConfigPath()}: ${(err as Error).message}`, - ); - } - - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - 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.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: (p.rightClickBehavior === 'copyPaste' - ? 'copyPaste' - : 'default') as RightClickBehavior, - }), - ...(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][], - ), - }), - }; -} - -/** - * Write the initial config stub (`port` + `host` only) to `~/.config/webtty/config.json`. - * - * Only writes the two keys that are safe to persist as defaults; all other - * keys are intentionally omitted so future webtty versions can add new fields - * without the stub going stale. - */ -export function initConfig(): void { - fs.mkdirSync(path.dirname(getConfigPath()), { recursive: true }); - const content = JSON.stringify( - { - port: DEFAULT_CONFIG.port, - host: DEFAULT_CONFIG.host, - }, - null, - 2, - ); - fs.writeFileSync(getConfigPath(), content, 'utf8'); -} diff --git a/src/config/client-view.test.ts b/src/config/client-view.test.ts new file mode 100644 index 0000000..6497aad --- /dev/null +++ b/src/config/client-view.test.ts @@ -0,0 +1,69 @@ +// 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'; +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/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.test.ts b/src/config/config-paths.test.ts new file mode 100644 index 0000000..998a75c --- /dev/null +++ b/src/config/config-paths.test.ts @@ -0,0 +1,46 @@ +// 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'; +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-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.test.ts b/src/config/config-schema.test.ts new file mode 100644 index 0000000..3177ad4 --- /dev/null +++ b/src/config/config-schema.test.ts @@ -0,0 +1,25 @@ +// 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'; + +// 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/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.test.ts b/src/config/defaults.test.ts new file mode 100644 index 0000000..d1e0ed0 --- /dev/null +++ b/src/config/defaults.test.ts @@ -0,0 +1,64 @@ +// 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'; + +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 new file mode 100644 index 0000000..f866ee3 --- /dev/null +++ b/src/config/defaults.ts @@ -0,0 +1,68 @@ +// 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 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', + 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: defaultShell(), + 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.test.ts b/src/config/index.test.ts new file mode 100644 index 0000000..c15b992 --- /dev/null +++ b/src/config/index.test.ts @@ -0,0 +1,157 @@ +// 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'; +import path from 'node:path'; +import * as configModule from '.'; +import { DEFAULT_CONFIG, DEFAULT_THEME, initConfig, loadConfig } from '.'; + +let tmpDir: string; +let configPath: string; +let origHome: string | undefined; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webtty-config-test-')); + configPath = path.join(tmpDir, '.config', 'webtty', 'config.json'); + origHome = process.env.HOME; + process.env.HOME = tmpDir; +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + if (origHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = origHome; + } +}); + +describe('initConfig', () => { + test('creates the config directory if it does not exist', () => { + initConfig(); + expect(fs.existsSync(path.dirname(configPath))).toBe(true); + }); + + test('writes valid JSON', () => { + initConfig(); + const raw = fs.readFileSync(configPath, 'utf8'); + expect(() => JSON.parse(raw)).not.toThrow(); + }); + + test('written file contains port and host', () => { + initConfig(); + const raw = fs.readFileSync(configPath, 'utf8'); + expect(raw).toContain('"port"'); + expect(raw).toContain('"host"'); + }); +}); + +describe('loadConfig — first run', () => { + test('creates the config file when it does not exist', () => { + expect(fs.existsSync(configPath)).toBe(false); + loadConfig(); + expect(fs.existsSync(configPath)).toBe(true); + }); + + 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); + expect(config.host).toBe(DEFAULT_CONFIG.host); + expect(config.cols).toBe(DEFAULT_CONFIG.cols); + expect(config.rows).toBe(DEFAULT_CONFIG.rows); + expect(config.fontSize).toBe(DEFAULT_CONFIG.fontSize); + expect(config.cursorStyleBlink).toBe(DEFAULT_CONFIG.cursorStyleBlink); + expect(config.cursorStyle).toBe(DEFAULT_CONFIG.cursorStyle); + expect(config.scrollback).toBe(DEFAULT_CONFIG.scrollback); + expect(config.theme).toEqual(DEFAULT_CONFIG.theme); + }); + + test('warns and returns defaults when write fails', () => { + const configDir = path.dirname(configPath); + fs.mkdirSync(path.dirname(configDir), { recursive: true }); + fs.writeFileSync(configDir, 'not-a-dir'); + + const warned: string[] = []; + const origWarn = console.warn; + console.warn = (...args: unknown[]) => warned.push(args.join(' ')); + + let config: ReturnType<typeof loadConfig> | undefined; + expect(() => { + config = loadConfig(); + }).not.toThrow(); + + console.warn = origWarn; + + expect(warned.some((w) => w.includes('webtty:'))).toBe(true); + expect(config?.port).toBe(DEFAULT_CONFIG.port); + }); +}); + +describe('loadConfig — reads and merges', () => { + function writeConfig(content: string) { + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, content, 'utf8'); + } + + test('returns full defaults when file has no overrides', () => { + writeConfig('{}'); + const config = loadConfig(); + expect(config.port).toBe(DEFAULT_CONFIG.port); + expect(config.cols).toBe(DEFAULT_CONFIG.cols); + expect(config.theme).toEqual(DEFAULT_THEME); + }); + + test('overrides port when set in file', () => { + writeConfig(JSON.stringify({ port: 9999 })); + expect(loadConfig().port).toBe(9999); + }); + + test('merges partial theme over DEFAULT_THEME', () => { + writeConfig(JSON.stringify({ theme: { background: '#000000' } })); + const config = loadConfig(); + expect(config.theme.background).toBe('#000000'); + expect(config.theme.foreground).toBe(DEFAULT_THEME.foreground); + expect(config.theme.red).toBe(DEFAULT_THEME.red); + }); + + test('throws on invalid JSON', () => { + writeConfig('{ not valid json }'); + expect(() => loadConfig()).toThrow(/invalid JSON/); + }); + + test('throws with webtty: prefix in message on read error', () => { + fs.mkdirSync(configPath, { recursive: true }); + expect(() => loadConfig()).toThrow(/webtty:/); + }); +}); + +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('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/index.ts b/src/config/index.ts new file mode 100644 index 0000000..96bb3dc --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,84 @@ +// 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 path from 'node:path'; +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`. + * + * On first run (file absent), writes the default port/host stub and returns defaults. + * Unknown keys and keys with wrong types are silently ignored. + * Throws if the file exists but cannot be read or contains invalid JSON. + * + * @returns The resolved {@link Config}, with all missing keys filled from {@link DEFAULT_CONFIG}. + */ +export function loadConfig(): Config { + if (!fs.existsSync(getConfigPath())) { + try { + initConfig(); + } catch (err) { + console.warn( + `webtty: failed to write default config to ${getConfigPath()}: ${(err as Error).message}`, + ); + return { ...DEFAULT_CONFIG }; + } + } + + let raw: string; + try { + raw = fs.readFileSync(getConfigPath(), 'utf8'); + } catch (err) { + throw new Error( + `webtty: failed to read config at ${getConfigPath()}: ${(err as Error).message}`, + ); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error(`webtty: invalid JSON in config file ${getConfigPath()}`); + } + + return parseConfig(parsed); +} + +/** + * Write the initial config stub (`port` + `host` only) to `~/.config/webtty/config.json`. + * + * Only writes the two keys that are safe to persist as defaults; all other + * keys are intentionally omitted so future webtty versions can add new fields + * without the stub going stale. + */ +export function initConfig(): void { + fs.mkdirSync(path.dirname(getConfigPath()), { recursive: true }); + const content = JSON.stringify( + { + port: DEFAULT_CONFIG.port, + host: DEFAULT_CONFIG.host, + }, + null, + 2, + ); + fs.writeFileSync(getConfigPath(), content, 'utf8'); +} diff --git a/src/config/keyboard-bindings.test.ts b/src/config/keyboard-bindings.test.ts new file mode 100644 index 0000000..df8229b --- /dev/null +++ b/src/config/keyboard-bindings.test.ts @@ -0,0 +1,83 @@ +// 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'; + +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/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.test.ts b/src/config/parse-config.test.ts new file mode 100644 index 0000000..1150b5e --- /dev/null +++ b/src/config/parse-config.test.ts @@ -0,0 +1,186 @@ +// 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'; +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>, + ); + }); +}); 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) }), + }; +} diff --git a/src/ndjson.test.ts b/src/ndjson.test.ts new file mode 100644 index 0000000..07875ce --- /dev/null +++ b/src/ndjson.test.ts @@ -0,0 +1,40 @@ +// 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'; + +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/pty/bun-backend.ts b/src/pty/bun-backend.ts new file mode 100644 index 0000000..5286cd2 --- /dev/null +++ b/src/pty/bun-backend.ts @@ -0,0 +1,57 @@ +// 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. + * + * @returns A {@link PtyProcess} handle for reading/writing and managing the PTY. + */ +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 + // 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) { + const text = decoder.decode(data, { stream: true }); + if (text) onDataCb?.(text); + }, + }, + cwd, + env: { ...process.env, ...env, TERM: term, COLORTERM: colorTerm }, + }); + + proc.exited.then((exitCode) => { + onExitCb?.({ exitCode: exitCode ?? 0 }); + }); + + return { + pid: proc.pid, + onData(cb) { + onDataCb = cb; + }, + onExit(cb) { + onExitCb = cb; + }, + write(data) { + proc.terminal?.write(data); + }, + resize(cols, rows) { + proc.terminal?.resize(cols, rows); + }, + 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/bun.ts b/src/pty/bun.ts deleted file mode 100644 index d9f644a..0000000 --- a/src/pty/bun.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { PtyProcess } from './types'; - -/** - * 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 { - let onDataCb: ((data: string) => void) | undefined; - let onExitCb: ((e: { exitCode: number }) => void) | undefined; - - const proc = Bun.spawn([shell], { - terminal: { - cols, - rows, - data(_term: unknown, data: Uint8Array) { - onDataCb?.(Buffer.from(data).toString('utf8')); - }, - }, - cwd, - env: { ...process.env, ...env, TERM: term, COLORTERM: colorTerm }, - }); - - proc.exited.then((exitCode) => { - onExitCb?.({ exitCode: exitCode ?? 0 }); - }); - - return { - pid: proc.pid, - onData(cb) { - onDataCb = cb; - }, - onExit(cb) { - onExitCb = cb; - }, - write(data) { - proc.terminal?.write(data); - }, - resize(cols, rows) { - proc.terminal?.resize(cols, rows); - }, - kill() { - proc.kill(); - }, - }; -} diff --git a/src/pty/index.test.ts b/src/pty/index.test.ts index 2f1c899..1f0f066 100644 --- a/src/pty/index.test.ts +++ b/src/pty/index.test.ts @@ -1,5 +1,11 @@ +// 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 { spawnForSession } from './index'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +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) => { @@ -22,21 +28,49 @@ 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', () => { +/** 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'); @@ -49,15 +83,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)); @@ -74,4 +100,45 @@ describe('spawnForSession', () => { await new Promise<void>((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 = spawnPty(options()); + 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, + ); + + 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, + ); }); diff --git a/src/pty/index.ts b/src/pty/index.ts index 746583a..a183482 100644 --- a/src/pty/index.ts +++ b/src/pty/index.ts @@ -1,34 +1,32 @@ -export type { PtyProcess } from './types'; +// 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'; -// 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'}`); +export type { PtyProcess, PtySpawnOptions, SpawnPty } from './pty-process'; -const { spawn: _spawn } = await (isBun ? import('./bun') : import('./node')); - -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..47725db --- /dev/null +++ b/src/pty/node-backend.test.ts @@ -0,0 +1,122 @@ +// 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 { + 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 new file mode 100644 index 0000000..34ea055 --- /dev/null +++ b/src/pty/node-backend.ts @@ -0,0 +1,72 @@ +// 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, 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. + * + * @returns A {@link PtyProcess} handle for reading/writing and managing the PTY. + */ +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 = deps.platform === 'win32' && /cmd\.exe$/i.test(shell) ? ['/d'] : []; + + const ptyProc = deps.nodePty.spawn(shell, shellArgs, { + name: term, + cols, + rows, + cwd, + env: { ...deps.baseEnv, ...env, TERM: term, COLORTERM: colorTerm }, + }); + + return { + pid: ptyProc.pid, + onData(cb) { + ptyProc.onData(cb); + }, + onExit(cb) { + ptyProc.onExit(cb); + }, + write(data) { + ptyProc.write(data); + }, + resize(cols, rows) { + ptyProc.resize(cols, rows); + }, + pause() { + ptyProc.pause(); + }, + resume() { + ptyProc.resume(); + }, + kill() { + ptyProc.kill(); + }, + }; +} 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 deleted file mode 100644 index 005af65..0000000 --- a/src/pty/node.ts +++ /dev/null @@ -1,55 +0,0 @@ -import nodePty from '@lydell/node-pty'; -import type { PtyProcess } from './types'; - -/** - * 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>, -): PtyProcess { - // 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 ptyProc = nodePty.spawn(shell, shellArgs, { - name: term, - cols, - rows, - cwd, - env: { ...process.env, ...env, TERM: term, COLORTERM: colorTerm }, - }); - - return { - pid: ptyProc.pid, - onData(cb) { - ptyProc.onData(cb); - }, - onExit(cb) { - ptyProc.onExit(cb); - }, - write(data) { - ptyProc.write(data); - }, - resize(cols, rows) { - ptyProc.resize(cols, rows); - }, - kill() { - ptyProc.kill(); - }, - }; -} diff --git a/src/pty/pty-process.ts b/src/pty/pty-process.ts new file mode 100644 index 0000000..9c78d17 --- /dev/null +++ b/src/pty/pty-process.ts @@ -0,0 +1,46 @@ +// 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. */ + pid: number; + /** Register a callback that receives raw UTF-8 output from the PTY. */ + onData(cb: (data: string) => void): void; + /** Register a callback invoked when the child process exits. */ + onExit(cb: (e: { exitCode: number }) => void): void; + /** Write raw input to the PTY (keyboard data, escape sequences, etc.). */ + 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; +} + +/** 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/pty/types.ts b/src/pty/types.ts deleted file mode 100644 index 739353a..0000000 --- a/src/pty/types.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** 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. */ - pid: number; - /** Register a callback that receives raw UTF-8 output from the PTY. */ - onData(cb: (data: string) => void): void; - /** Register a callback invoked when the child process exits. */ - onExit(cb: (e: { exitCode: number }) => void): void; - /** Write raw input to the PTY (keyboard data, escape sequences, etc.). */ - write(data: string): void; - /** Notify the PTY of a terminal resize. */ - resize(cols: number, rows: number): void; - /** Terminate the child process. */ - kill(): void; -} diff --git a/src/runtime.test.ts b/src/runtime.test.ts new file mode 100644 index 0000000..d726688 --- /dev/null +++ b/src/runtime.test.ts @@ -0,0 +1,82 @@ +// 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, + 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/app.test.ts b/src/server/app.test.ts new file mode 100644 index 0000000..8166c90 --- /dev/null +++ b/src/server/app.test.ts @@ -0,0 +1,146 @@ +// 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'; +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..cd43e87 --- /dev/null +++ b/src/server/app.ts @@ -0,0 +1,88 @@ +// 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 { type SpawnPty, spawnPty } from '../pty'; +import { packageRunner } from '../runtime'; +import { createRequestHandler } from './routes/route-table'; +import type { ServerPaths } from './server-paths'; +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; `spawnPty` 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 ?? spawnPty, + 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/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/http/request-body.test.ts b/src/server/http/request-body.test.ts new file mode 100644 index 0000000..a82f738 --- /dev/null +++ b/src/server/http/request-body.test.ts @@ -0,0 +1,105 @@ +// 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'; +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..b6f175c --- /dev/null +++ b/src/server/http/respond.test.ts @@ -0,0 +1,68 @@ +// 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'; +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/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/static.test.ts b/src/server/http/static-files.test.ts similarity index 51% rename from src/server/static.test.ts rename to src/server/http/static-files.test.ts index f72cfff..2ef2365 100644 --- a/src/server/static.test.ts +++ b/src/server/http/static-files.test.ts @@ -1,7 +1,8 @@ +// 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 path from 'node:path'; -import { findGhosttyWeb, ghosttyWebRootFromMain, mimeType, serveFile } from './static'; +import { mimeType, serveFile } from './static-files'; describe('mimeType', () => { test('returns correct type for known extensions', () => { @@ -22,67 +23,11 @@ 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('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('falls back to node_modules when bundled assets are missing', () => { - 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); - }); - - const { distPath, wasmPath } = findGhosttyWeb(); - expect(distPath).toContain('ghostty-web'); - expect(wasmPath).toContain('ghostty-vt.wasm'); - existsSpy.mockRestore(); - }); - - test('exits when ghostty-web files are missing', () => { - const existsSpy = spyOn(fs, 'existsSync').mockReturnValue(false); - const exitSpy = spyOn(process, 'exit').mockImplementation((() => {}) as () => never); - const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); - - findGhosttyWeb(); - - expect(exitSpy).toHaveBeenCalledWith(1); - 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 new file mode 100644 index 0000000..29063f8 --- /dev/null +++ b/src/server/http/static-files.ts @@ -0,0 +1,52 @@ +// 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'; + +/** File extension → MIME type map used by {@link serveFile}. */ +export const MIME_TYPES: Record<string, string> = { + '.html': 'text/html', + '.js': 'application/javascript', + '.mjs': 'application/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.wasm': 'application/wasm', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', +}; + +/** + * Returns the MIME type for `filePath` based on its extension, defaulting to `application/octet-stream`. + * + * @param filePath - The file path to determine the MIME type for. + * @returns The MIME type string. + */ +export function mimeType(filePath: string): string { + const ext = path.extname(filePath); + return MIME_TYPES[ext] ?? 'application/octet-stream'; +} + +/** The wasm the client loads: Ghostty's official libghostty-vt (ADR 042, ADR 047). */ +export const GHOSTTY_WASM = 'ghostty-vt-small.wasm'; + +/** + * Reads `filePath` from disk and writes it to `res` with the correct Content-Type header. + * Responds with 404 if the file cannot be read. + * + * @param filePath - The file path to serve. + * @param res - The HTTP response object. + */ +export function serveFile(filePath: string, res: http.ServerResponse): void { + const contentType = mimeType(filePath); + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404); + res.end('Not Found'); + return; + } + res.writeHead(200, { 'Content-Type': contentType }); + res.end(data); + }); +} diff --git a/src/server/index.ts b/src/server/index.ts index ac3e85b..cc22c0b 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,69 +1,50 @@ -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 { handleRequest } from './routes'; -import { findGhosttyWeb } from './static'; -import { closeAllSessions, createWebSocketServer, setLastSessionClosedHandler } from './websocket'; - -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 { distPath, wasmPath } = findGhosttyWeb(); -const projectRoot = path.resolve(__dirname, '..', '..'); -const builtDistPath = path.join(projectRoot, 'dist'); -const srcClientPath = path.join(projectRoot, 'src', 'client'); -const clientDistPath = fs.existsSync(path.join(builtDistPath, 'client.html')) - ? builtDistPath - : srcClientPath; - -function shutdown() { - closeAllSessions(); - wss.close(); - const exit = () => process.exit(0); - const shutdownTimeout = setTimeout(exit, 1000); - httpServer.close(() => { - clearTimeout(shutdownTimeout); - exit(); +import { createServer, type WebttyServer } from './app'; +import { resolvePathsOrExit } from './server-paths'; +import { SessionStore } from './sessions/session-store'; + +/** 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 = resolvePathsOrExit(); + + // 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); }); -} - -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, distPath, wasmPath, clientDistPath, shutdown).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); -setLastSessionClosedHandler(shutdown); + 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/output-pacer.test.ts b/src/server/output-pacer.test.ts new file mode 100644 index 0000000..fe755cd --- /dev/null +++ b/src/server/output-pacer.test.ts @@ -0,0 +1,396 @@ +// 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, + type FlowLimits, + type FlowSocket, + OutputPacer, + splitChunks, +} from './output-pacer'; + +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('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 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(); + 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('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(); + 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/output-pacer.ts b/src/server/output-pacer.ts new file mode 100644 index 0000000..b777e4d --- /dev/null +++ b/src/server/output-pacer.ts @@ -0,0 +1,278 @@ +// 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; + +/** 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; + +/** 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, + 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/routes-golden.test.ts b/src/server/routes-golden.test.ts new file mode 100644 index 0000000..d1e0491 --- /dev/null +++ b/src/server/routes-golden.test.ts @@ -0,0 +1,276 @@ +// 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 './http/request-body'; +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 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); + }); + + 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/server/routes.test.ts b/src/server/routes.test.ts index e4454fc..2f6c510 100644 --- a/src/server/routes.test.ts +++ b/src/server/routes.test.ts @@ -1,43 +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 { - cleanupTmpHome, - getFreePort, - makeTmpHome, - waitForServer, - waitForServerDown, -} from '../utils.test'; +import { DEFAULT_THEME } from '../config'; +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 () => { @@ -45,6 +35,33 @@ describe('server — routes', () => { expect(res.status).toBe(404); }); + 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('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', + '/ghostty-vt-small.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); @@ -88,7 +105,11 @@ 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(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'); expect(body.port).toBeUndefined(); expect(body.host).toBeUndefined(); @@ -96,6 +117,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); @@ -397,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/routes.ts b/src/server/routes.ts deleted file mode 100644 index 2fde9d9..0000000 --- a/src/server/routes.ts +++ /dev/null @@ -1,495 +0,0 @@ -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 } from '../config'; -import { getVersion } from '../version'; -import { - createSession, - generateId, - isValidId, - lastUsedId, - sessionRegistry, - sessionToJson, - setLastUsedId, -} from './session'; -import { serveFile } from './static'; -import { broadcastToSubscribers, closeSession } from './websocket'; - -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); - }); -} - -/** - * 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 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. - */ -export async function handleRequest( - req: http.IncomingMessage, - res: http.ServerResponse, - distPath: string, - 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') { - res.writeHead(204); - res.end(); - return; - } - - if (req.method === 'POST' && pathname === '/api/server/stop') { - res.writeHead(200, { 'Content-Type': 'text/plain' }); - res.end('stopping'); - onStop(); - return; - } - - if (req.method === 'GET' && pathname === '/api/server/status') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ version: getVersion() })); - return; - } - - 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, - theme: config.theme, - copyOnSelect: config.copyOnSelect, - rightClickBehavior: config.rightClickBehavior, - mouseScrollSpeed: config.mouseScrollSpeed, - padding: config.padding, - keyboardBindings: config.keyboardBindings, - }; - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(clientConfig)); - return; - } - - if (pathname === '/api/sessions') { - if (req.method === 'GET') { - const list = [...sessionRegistry.values()].map(sessionToJson); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(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 id = body.id ?? generateId(); - if (!isValidId(id)) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: `invalid id: ${id}` })); - return; - } - if (sessionRegistry.has(id)) { - res.writeHead(409, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ 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' })); - return; - } - if (!fs.existsSync(baseDir)) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ 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}` })); - return; - } - const session = createSession(id, baseDir); - res.writeHead(201, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(sessionToJson(session))); - return; - } - } - - const sessionMatch = pathname.match(/^\/api\/sessions\/([^/]+)$/); - if (sessionMatch) { - const id = decodeId(sessionMatch[1]); - if (!id) { - res.writeHead(400); - res.end('Bad Request'); - return; - } - - if (req.method === 'GET') { - const session = sessionRegistry.get(id); - if (!session) { - res.writeHead(404); - res.end('Not Found'); - return; - } - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(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 session = sessionRegistry.get(id); - if (!session) { - res.writeHead(404); - res.end('Not Found'); - return; - } - - const newId = body.id; - if (!newId) { - res.writeHead(400); - res.end('missing id'); - return; - } - if (!isValidId(newId)) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: `invalid id: ${newId}` })); - return; - } - if (sessionRegistry.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); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(sessionToJson(session))); - return; - } - - if (req.method === 'DELETE') { - const session = sessionRegistry.get(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.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'); - targetId = 'main'; - } - res.writeHead(302, { Location: `/s/${targetId}` }); - res.end(); - return; - } - - const clientMatch = pathname.match(/^\/s\/([^/]+)$/); - if (req.method === 'GET' && clientMatch) { - const id = decodeId(clientMatch[1]); - if (!id || !isValidId(id) || !sessionRegistry.has(id)) { - res.writeHead(404); - res.end('Not Found'); - 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) { - res.writeHead(400); - res.end('Bad Request'); - return; - } - if (!(req.headers['content-type'] ?? '').startsWith('application/json')) { - res.writeHead(400); - res.end('Bad Request'); - return; - } - const session = sessionRegistry.get(id); - if (!session) { - res.writeHead(404); - res.end('Not Found'); - return; - } - if (session.pty === null) { - res.writeHead(409); - res.end('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); - broadcastToSubscribers(session, trimmed); - } catch { - // skip invalid JSON lines - } - } - }); - req.on('end', () => { - const trimmed = buf.replace(/\r$/, ''); - if (trimmed) { - try { - JSON.parse(trimmed); - broadcastToSubscribers(session, trimmed); - } catch { - // skip invalid JSON - } - } - res.writeHead(204); - res.end(); - }); - req.on('error', () => { - res.writeHead(500); - res.end(); - }); - return; - } - - const executeMatch = pathname.match(/^\/s\/([^/]+)\/execute$/); - if (req.method === 'POST' && executeMatch) { - const id = decodeId(executeMatch[1]); - if (!id) { - res.writeHead(400); - res.end('Bad Request'); - return; - } - if (!(req.headers['content-type'] ?? '').startsWith('application/json')) { - res.writeHead(400); - res.end('Bad Request'); - return; - } - const session = sessionRegistry.get(id); - if (!session) { - res.writeHead(404); - res.end('Not Found'); - 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'); - return; - } - const { cmd, args, stdin } = execBody as { cmd?: unknown; args?: unknown; stdin?: unknown }; - if (typeof cmd !== 'string' || cmd.length === 0) { - res.writeHead(400); - res.end('invalid body'); - return; - } - if (!Array.isArray(args) || !args.every((a) => typeof a === 'string')) { - res.writeHead(400); - res.end('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) { - res.writeHead(500); - res.end(`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) { - res.writeHead(404); - res.end('Not Found'); - return; - } - const session = [...sessionRegistry.values()].find((s) => s.pty?.pid === pid); - if (!session) { - res.writeHead(404); - res.end('Not Found'); - return; - } - res.writeHead(302, { Location: `/s/${session.id}` }); - res.end(); - return; - } - - if (pathname.startsWith('/dist/')) { - const relativePath = pathname.slice(6); - const ownFile = path.resolve(clientDistPath, relativePath); - 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'); - return; - } - serveFile(filePath, res); - return; - } - - if (pathname === '/ghostty-vt.wasm') { - serveFile(wasmPath, res); - return; - } - - res.writeHead(404); - res.end('Not Found'); -} 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.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/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.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(); + }); +}); 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/server/server-paths.test.ts b/src/server/server-paths.test.ts new file mode 100644 index 0000000..fa0e151 --- /dev/null +++ b/src/server/server-paths.test.ts @@ -0,0 +1,177 @@ +// 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'; +import { + findClientRoot, + findWasm, + MissingWasmError, + projectRootOf, + resolvePathsOrExit, + resolveServerPaths, + type ServerPaths, +} 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); + }); +}); + +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 new file mode 100644 index 0000000..4a28f8c --- /dev/null +++ b/src/server/server-paths.ts @@ -0,0 +1,107 @@ +// 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 }; +} + +/** 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); + } +} diff --git a/src/server/session.test.ts b/src/server/session.test.ts deleted file mode 100644 index 9fa9077..0000000 --- a/src/server/session.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { beforeEach, describe, expect, test } from 'bun:test'; -import { - createSession, - generateId, - isValidId, - lastUsedId, - sessionRegistry, - sessionToJson, - setLastUsedId, -} from './session'; - -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'); - expect(sessionRegistry.has('test')).toBe(true); - }); - - test('returns session with correct shape', () => { - const session = createSession('test'); - expect(session.id).toBe('test'); - expect(session.pty).toBeNull(); - expect(session.clients.size).toBe(0); - expect(session.scrollback).toBe(''); - expect(typeof session.createdAt).toBe('number'); - }); -}); - -describe('sessionToJson', () => { - test('connected is false when no clients', () => { - const session = createSession('test'); - expect(sessionToJson(session).connected).toBe(false); - }); - - test('connected is true when clients set is non-empty', () => { - const session = createSession('test'); - session.clients.add({} as never); - expect(sessionToJson(session).connected).toBe(true); - }); - - test('includes id and createdAt', () => { - const session = createSession('test'); - const json = sessionToJson(session); - expect(json.id).toBe('test'); - expect(typeof json.createdAt).toBe('number'); - }); - - test('pid is null when pty is not yet spawned', () => { - const session = createSession('test-pid-null'); - expect(sessionToJson(session).pid).toBeNull(); - }); - - test('pid reflects pty pid when pty is set', () => { - const session = createSession('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 deleted file mode 100644 index a1524f7..0000000 --- a/src/server/session.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { homedir } from 'node:os'; -import type { WebSocket as WS } from 'ws'; -import type { PtyProcess } from '../pty'; - -/** A running (or recently created) terminal session. */ -export interface Session { - /** Unique session identifier. */ - id: string; - /** 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; -} - -/** 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; -} - -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. - * - * @param id - The session ID. - * @param baseDir - Working directory for the PTY shell (default: `homedir()`). - * @returns The newly created {@link Session}. - */ -export function createSession(id: string, baseDir = homedir()): Session { - const session: Session = { - id, - createdAt: Date.now(), - baseDir, - pty: null, - clients: new Set(), - subscribers: new Set(), - scrollback: '', - }; - sessionRegistry.set(id, session); - return session; -} - -/** - * Returns a plain JSON-safe representation of a session for API responses. - * - * @param s - The session to serialize. - * @returns A JSON-safe object with session ID, creation timestamp, connection status, and PTY PID (or `null` if no PTY has been spawned yet). - */ -export function sessionToJson(s: Session) { - return { - id: s.id, - createdAt: s.createdAt, - baseDir: s.baseDir, - connected: s.clients.size > 0, - pid: s.pty?.pid ?? null, - }; -} diff --git a/src/server/sessions/session-banner.test.ts b/src/server/sessions/session-banner.test.ts new file mode 100644 index 0000000..a34737d --- /dev/null +++ b/src/server/sessions/session-banner.test.ts @@ -0,0 +1,37 @@ +// 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'; + +// 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/sessions/session-id.test.ts b/src/server/sessions/session-id.test.ts new file mode 100644 index 0000000..a40123f --- /dev/null +++ b/src/server/sessions/session-id.test.ts @@ -0,0 +1,46 @@ +// 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'; + +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'); +} diff --git a/src/server/sessions/session-store.test.ts b/src/server/sessions/session-store.test.ts new file mode 100644 index 0000000..5b8ccdc --- /dev/null +++ b/src/server/sessions/session-store.test.ts @@ -0,0 +1,172 @@ +// 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'; + +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(); + }); +}); + +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 new file mode 100644 index 0000000..791c6f9 --- /dev/null +++ b/src/server/sessions/session-store.ts @@ -0,0 +1,118 @@ +// 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 { 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 = new Session(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); + } + + /** 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 new file mode 100644 index 0000000..8e7396c --- /dev/null +++ b/src/server/sessions/session.test.ts @@ -0,0 +1,262 @@ +// 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'; +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'; +import { type PtySpawnDeps, Session, type SessionExitHost, sessionToJson } from './session'; +import { sessionBanner } from './session-banner'; + +/** 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((options: PtySpawnOptions) => { + const { cols, rows, ...rest } = options; + 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(); + 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(new Session('test').baseDir).toBe(homedir()); + }); +}); + +describe('session output', () => { + test('pauses the PTY the session holds when its only client falls behind', () => { + 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() {} }; + session.output.add(ws, true); + session.output.broadcast('x'.repeat(2 * 1024 * 1024)); + expect(pause).toHaveBeenCalledTimes(1); + }); +}); + +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({ 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"', + ]); + 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', () => { + expect(sessionToJson(new Session('test')).connected).toBe(false); + }); + + test('connected is true when clients set is non-empty', () => { + const session = new Session('test'); + session.clients.add(new FakeSocket()); + expect(sessionToJson(session).connected).toBe(true); + }); + + test('includes id, createdAt and baseDir', () => { + 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', () => { + expect(sessionToJson(new Session('test-pid-null')).pid).toBeNull(); + }); + + test('pid reflects pty pid when pty is set', () => { + 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 new file mode 100644 index 0000000..4abbc44 --- /dev/null +++ b/src/server/sessions/session.ts @@ -0,0 +1,166 @@ +// 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 { Config } from '../../config'; +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'; + +/** 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` 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; +} + +/** 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. */ + 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). */ + readonly output = new OutputPacer(() => this.pty); + + /** + * @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({ + shell: config.shell, + cols, + rows, + 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) => { + 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(); + }); + } +} + +/** + * Returns a plain JSON-safe representation of a session for API responses. + * + * @param s - The session to serialize. + * @returns A JSON-safe object with session ID, creation timestamp, connection status, and PTY PID (or `null` if no PTY has been spawned yet). + */ +export function sessionToJson(s: Session) { + return { + id: s.id, + createdAt: s.createdAt, + baseDir: s.baseDir, + connected: s.clients.size > 0, + pid: s.pty?.pid ?? null, + }; +} diff --git a/src/server/static.ts b/src/server/static.ts deleted file mode 100644 index 561b3c9..0000000 --- a/src/server/static.ts +++ /dev/null @@ -1,95 +0,0 @@ -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<string, string> = { - '.html': 'text/html', - '.js': 'application/javascript', - '.mjs': 'application/javascript', - '.css': 'text/css', - '.json': 'application/json', - '.wasm': 'application/wasm', - '.png': 'image/png', - '.svg': 'image/svg+xml', - '.ico': 'image/x-icon', -}; - -/** - * Returns the MIME type for `filePath` based on its extension, defaulting to `application/octet-stream`. - * - * @param filePath - The file path to determine the MIME type for. - * @returns The MIME type string. - */ -export function mimeType(filePath: string): string { - const ext = path.extname(filePath); - return MIME_TYPES[ext] ?? 'application/octet-stream'; -} - -/** - * Strips the `dist/…` suffix from a ghostty-web main entry path to get the package root. - * - * @param mainPath - The main entry path from ghostty-web package. - * @returns The package root directory path. - */ -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 - } - console.error('Error: Could not find ghostty-web package.'); - 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. - * - * @param filePath - The file path to serve. - * @param res - The HTTP response object. - */ -export function serveFile(filePath: string, res: http.ServerResponse): void { - const contentType = mimeType(filePath); - fs.readFile(filePath, (err, data) => { - if (err) { - res.writeHead(404); - res.end('Not Found'); - return; - } - res.writeHead(200, { 'Content-Type': contentType }); - res.end(data); - }); -} diff --git a/src/server/websocket.test.ts b/src/server/websocket.test.ts index 7f05f9a..0d2b55f 100644 --- a/src/server/websocket.test.ts +++ b/src/server/websocket.test.ts @@ -1,26 +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 { cleanupTmpHome, getFreePort, makeTmpHome, waitForServer } from '../utils.test'; +import { isWindows, runningUnderBun } from '../runtime'; +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 isBun = typeof (globalThis as Record<string, unknown>).Bun !== 'undefined'; -const useNode = process.platform === 'win32' && isBun; -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) => { @@ -80,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 () => { @@ -187,6 +169,81 @@ 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); + + // 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/) + .filter((l) => /^\d+$/.test(l)) + .map(Number); + expect(numbers.length).toBe(N); + expect(numbers.every((n, i) => n === i + 1)).toBe(true); + }, + 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<number>((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', @@ -249,6 +306,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))); @@ -393,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); }); }); diff --git a/src/server/websocket.ts b/src/server/websocket.ts deleted file mode 100644 index 4ec37ae..0000000 --- a/src/server/websocket.ts +++ /dev/null @@ -1,266 +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 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 -} as const; - -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 active session with the SERVER_STOPPED close code. */ -export function closeAllSessions(): void { - for (const session of sessionRegistry.values()) { - 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); - } -} - -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 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 isBun = process.execPath.includes('bun'); -const PKG_RUNNER = isBun ? 'bunx' : 'npx'; - -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) { - 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. - * @returns The configured {@link WebSocketServer}. - */ -export function createWebSocketServer(httpServer: http.Server): 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(ws, id); - 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), - ); - - if (!sessionRegistry.has(id)) { - ws.close(WS_CLOSE.SESSION_GONE, 'session deleted'); - return; - } - - const session = sessionRegistry.get(id); - if (!session) { - ws.close(WS_CLOSE.SESSION_GONE, 'session deleted'); - return; - } - session.clients.add(ws); - setLastUsedId(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); - for (const client of session.clients) { - if (client.readyState === client.OPEN) { - client.send(data, { binary: false }); - } - } - }); - - 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'); - } - } - session.subscribers.clear(); - session.pty = null; - if (sessionRegistry.size === 0) onLastSessionClosed?.(); - }); - - const banner = sessionBanner(); - ws.send(banner); - session.scrollback = banner; - session.pty.write('\n'); - } else { - if (session.scrollback) { - ws.send(session.scrollback, { binary: false }); - } - session.pty.resize(cols, rows); - } - - 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 }; - 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 - } - } - session.pty?.write(message); - }); - - ws.on('close', () => { - session.clients.delete(ws); - }); - - ws.on('error', () => {}); - }); - - return wss; -} diff --git a/src/server/ws/client-message.test.ts b/src/server/ws/client-message.test.ts new file mode 100644 index 0000000..9a92cc9 --- /dev/null +++ b/src/server/ws/client-message.test.ts @@ -0,0 +1,62 @@ +// 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'; + +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..8df4807 --- /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; pty-socket.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 }; +} diff --git a/src/server/ws/close-codes.test.ts b/src/server/ws/close-codes.test.ts new file mode 100644 index 0000000..2d8b65b --- /dev/null +++ b/src/server/ws/close-codes.test.ts @@ -0,0 +1,15 @@ +// 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'; + +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; diff --git a/src/server/ws/events-socket.test.ts b/src/server/ws/events-socket.test.ts new file mode 100644 index 0000000..efb3408 --- /dev/null +++ b/src/server/ws/events-socket.test.ts @@ -0,0 +1,40 @@ +// 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'; +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..d6698b1 --- /dev/null +++ b/src/server/ws/pty-socket.test.ts @@ -0,0 +1,139 @@ +// 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'; +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..4689c13 --- /dev/null +++ b/src/server/ws/ws-upgrade.test.ts @@ -0,0 +1,110 @@ +// 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'; +import { WebSocket, type WebSocketServer } from 'ws'; +import { DEFAULT_CONFIG, loadConfig } from '../../config'; +import { spawnPty } 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(spawnPty); + 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..e3e41a7 --- /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 { spawnPty } 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, 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; +} diff --git a/src/test-support.test.ts b/src/test-support.test.ts new file mode 100644 index 0000000..c4b675e --- /dev/null +++ b/src/test-support.test.ts @@ -0,0 +1,24 @@ +// 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 './test-support/server-harness'; + +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', + ); + }); + + test('waitForServerDown throws when server keeps responding', async () => { + const realFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response('[]', { status: 200 })) as unknown as typeof fetch; + try { + await expect(waitForServerDown('http://127.0.0.1:1', 50)).rejects.toThrow( + 'Server did not shut down in time', + ); + } finally { + globalThis.fetch = realFetch; + } + }); +}); 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..0a8ca36 --- /dev/null +++ b/src/test-support/fake-socket.ts @@ -0,0 +1,66 @@ +// 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; + +/** 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. */ +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')); + } +} diff --git a/src/test-support/live-server.ts b/src/test-support/live-server.ts new file mode 100644 index 0000000..ce1cbb0 --- /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 type { SpawnPty } from '../pty'; +import { createServer, type WebttyServer } from '../server/app'; +import type { ServerPaths } from '../server/server-paths'; +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/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 }; +} diff --git a/src/test-support/route-harness.ts b/src/test-support/route-harness.ts new file mode 100644 index 0000000..bd68ee1 --- /dev/null +++ b/src/test-support/route-harness.ts @@ -0,0 +1,199 @@ +// 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 path from 'node:path'; +import type { Session } from '../server/sessions/session'; +import type { SessionStore } from '../server/sessions/session-store'; +import { FakePty } from './fake-pty'; +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 { + 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 = ''; + 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.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'); + } + + /** 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> { + 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. */ + 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.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; + } + 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.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.live.base}/`, { 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); + } +} diff --git a/src/utils.test.ts b/src/test-support/server-harness.ts similarity index 55% rename from src/utils.test.ts rename to src/test-support/server-harness.ts index 17c1780..54b06db 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,34 @@ 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', - ); - }); +/** 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; +} - 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; - }); -}); +/** 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); + }, + }; +} diff --git a/src/version.test.ts b/src/version.test.ts index 5924034..2cd7ac8 100644 --- a/src/version.test.ts +++ b/src/version.test.ts @@ -1,9 +1,16 @@ +// 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'; import path from 'node:path'; -import { cleanupTmpHome, getFreePort, makeTmpHome, waitForServer } from './utils.test'; -import { getVersion } from './version'; +import { + cleanupTmpHome, + getFreePort, + makeTmpHome, + waitForServer, +} from './test-support/server-harness'; +import { findPackageVersion, getVersion } from './version'; const REPO_ROOT = path.resolve(import.meta.dir, '..'); const FAKE_VERSION = '9.9.9'; @@ -14,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 @@ -33,7 +82,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 +97,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 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'), + ); cliEntry = path.join(pkgRoot, 'dist/cli/index.js'); serverEntry = path.join(pkgRoot, 'dist/server/index.js'); }); 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; +}