diff --git a/README.md b/README.md index 6ec24f8..78bbf71 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,21 @@ const term = createTerminal({ cols: 80, rows: 24, scrollbackLimit: 1000 }); term.feed("hello\n"); term.feed("\x1b[31mred\x1b[0m"); +// The child enables normal mouse tracking plus SGR encoding. +term.feed("\x1b[?1000h\x1b[?1006h"); +const mouseBytes = term.encodeMouse( + { action: "press", button: "left", x: 25, y: 45 }, + { + geometry: { + screenWidth: 800, + screenHeight: 600, + cellWidth: 10, + cellHeight: 20, + }, + }, +); +// Write mouseBytes to the child PTY. + console.log(getNativeInfo()); console.log(term.getVisibleText()); console.log(term.snapshot({ includeCells: true })); @@ -38,12 +53,56 @@ The public contract is intentionally small: - `createTerminal({ cols, rows, scrollbackLimit })` - `feed(data)`, `resize(cols, rows)`, `snapshot(options)`, `getVisibleText()` +- `encodeMouse(event, options)` for mode-aware terminal mouse bytes - optional debug formatters `formatPlain()` and `formatHtml()` - explicit, idempotent `dispose()` - `getNativeInfo()` for package, Node-API, platform, and Ghostty build metadata +- `supportsMouseInput`, an import-time capability marker that requires no native allocation All dimensions are validated as positive integers. Using a terminal after `dispose()` throws. +### Mouse encoding + +`encodeMouse` returns a `Buffer` containing the terminal input bytes for one +normalized mouse event. It returns an empty buffer when the child has mouse +tracking disabled or when its negotiated mode suppresses that event. The child +selects X10, UTF-8, SGR, URXVT, or SGR-pixels through the output previously +passed to `feed`; callers do not choose a wire format independently. + +Event coordinates are finite surface-space numbers. Geometry is explicit so +SGR-pixels remains accurate and the other formats can map the same position to +a terminal cell. `anyButtonPressed` supplies the caller-owned aggregate button +state needed for drag events outside the viewport. `trackLastCell` asks Ghostty +to suppress duplicate motion events within one unchanged cell. + +Geometry must describe the same grid as `createTerminal` or the latest `resize`: +subtract padding from the screen dimensions, then divide by cell size. The +binding does not reconcile mismatched grids. Dimensions and padding must fit +unsigned 32-bit integers, total padding must not exceed the screen dimensions, +and the resulting grid must fit 65535 cells per axis. Coordinates are converted +to 32-bit floats by the C API; after padding is removed they must fit signed +32-bit pixel coordinates and a cell index below 65536. Unrepresentable input +throws `RangeError` before reaching Ghostty, including for suppressed events. +Ordinary negative and off-screen positions remain supported. + +When UTF-8 mouse mode (1005) is set, the binding also rejects clamped cell +coordinates whose encoded value would be a Unicode surrogate, or whose row +would overflow the pinned encoder's 16-bit arithmetic. This conservative guard +applies while 1005 remains set even if another mouse format was selected later. + +With the pinned Ghostty version, any non-empty `feed()` resets motion +deduplication when the next mouse event refreshes negotiated modes. Geometry +changes also reset it. SGR-pixels reports motion even within the same cell. + +Buttons `four`, `five`, `six`, and `seven` conventionally represent wheel up, +wheel down, wheel left, and wheel right. The binding keeps Ghostty's names at +this low-level API boundary so consumers can provide their own user-facing +aliases. +Send wheel events as `press` only: a wheel release in legacy formats is encoded +as an ordinary button release. Buttons `ten` and `eleven` have no button codes in +the pinned Ghostty encoder and normally produce an empty buffer; legacy release +encoding still applies. + ## Native Build The addon uses `node-addon-api` over Node-API/N-API and is built with `node-gyp`. Runtime loading uses `node-gyp-build`, so npm packages can ship prebuilt `.node` files. @@ -130,6 +189,7 @@ The native layer currently uses these verified `libghostty-vt` C APIs: - terminal lifecycle and stream processing: `ghostty_terminal_new`, `ghostty_terminal_vt_write`, `ghostty_terminal_resize`, `ghostty_terminal_free` - metadata and state: `ghostty_terminal_get`, `ghostty_build_info` +- mode-aware mouse input: `ghostty_mouse_encoder_*`, `ghostty_mouse_event_*` - plain/HTML debug formatting: `ghostty_formatter_terminal_new`, `ghostty_formatter_format_alloc` - structured snapshots: `ghostty_terminal_grid_ref`, `ghostty_grid_ref_cell`, `ghostty_grid_ref_graphemes`, `ghostty_grid_ref_style`, `ghostty_cell_get` @@ -147,22 +207,22 @@ The upstream API is still marked unstable by Ghostty. Keep the pinned commit upd The package has four layers: 1. A consumer imports `dist/index.js`, which `tsc` compiles from [`src/`](https://github.com/coder/libghostty-vt-node/blob/main/src/README.md). `src/index.ts` checks arguments, and `src/loader.ts` loads the native addon with `node-gyp-build` on the first API call. -2. The addon in [`native/`](./native/README.md) wraps one libghostty-vt `GhosttyTerminal` per `TerminalWrap` object and exports `createTerminal` and `getNativeInfo`. +2. The addon in [`native/`](./native/README.md) wraps one libghostty-vt `GhosttyTerminal` and one `GhosttyMouseEncoder` per `TerminalWrap` object and exports `createTerminal` and `getNativeInfo`. 3. `libghostty-vt` is a static library that the addon links. `scripts/build-libghostty-vt.sh` builds it from a pinned Ghostty commit, and `binding.gyp` finds it with `scripts/resolve-libghostty-vt.mjs` (see [`scripts/`](./scripts/README.md)). 4. The release scripts in [`scripts/`](./scripts/README.md) assemble per-platform prebuilds, tag the release, and pick the npm dist-tag. [`examples/`](https://github.com/coder/libghostty-vt-node/blob/main/examples/README.md) holds the smoke script that the mise task `ci` runs last. ## Subdirectories & System Boundaries -- `src`: [`src/README.md`](https://github.com/coder/libghostty-vt-node/blob/main/src/README.md) - TypeScript package API (`createTerminal`, `getNativeInfo`, and the snapshot types), argument checks, disposal tracking, and the lazy, cached addon loader. +- `src`: [`src/README.md`](https://github.com/coder/libghostty-vt-node/blob/main/src/README.md) - TypeScript package API (`createTerminal`, `getNativeInfo`, `supportsMouseInput`, and the snapshot and mouse types), argument checks, disposal tracking, and the lazy, cached addon loader. - `native`: [`./native/README.md`](./native/README.md) - Node-API C++ addon (`TerminalWrap`, JavaScript class `GhosttyVtTerminal`) over the libghostty-vt C API, with per-environment class state for `worker_threads`. - `scripts`: [`./scripts/README.md`](./scripts/README.md) - libghostty-vt build and path resolver, prebuild assembly and check, release tag creation, and npm dist-tag choice. - `examples`: [`examples/README.md`](https://github.com/coder/libghostty-vt-node/blob/main/examples/README.md) - `smoke.ts`, run by `npm run smoke`. -- `test` (no contract): vitest suites for the loader, the terminal API, and `worker_threads` loading. +- `test` (no contract): vitest suites for the loader, the terminal API, the `encodeMouse` limits, and `worker_threads` loading. - `dogfood` (no contract): a plain-text proof bundle from the repository bootstrap. ## Global Invariants -- Arguments are checked in both layers. `createTerminal` options: [src/index.ts:normalizeCreateOptions] (before the addon loads) and [native/terminal.cc:TerminalWrap::TerminalWrap]. `resize`: [src/index.ts:Terminal] and [native/terminal.cc:TerminalWrap::Resize]. `feed`: [src/index.ts:assertFeedData] and [native/terminal.cc:TerminalWrap::Feed]. `snapshot` options: [src/index.ts:normalizeSnapshotOptions] and [native/terminal.cc:TerminalWrap::Snapshot]. +- Arguments are checked in both layers. `createTerminal` options: [src/index.ts:normalizeCreateOptions] (before the addon loads) and [native/terminal.cc:TerminalWrap::TerminalWrap]. `resize`: [src/index.ts:Terminal] and [native/terminal.cc:TerminalWrap::Resize]. `feed`: [src/index.ts:assertFeedData] and [native/terminal.cc:TerminalWrap::Feed]. `snapshot` options: [src/index.ts:normalizeSnapshotOptions] and [native/terminal.cc:TerminalWrap::Snapshot]. `encodeMouse`: [src/index.ts:normalizeMouseOptions] and [src/index.ts:normalizeMouseEvent], and [native/terminal.cc:TerminalWrap::EncodeMouse], which also guards the UTF-8 format. - The two layers throw different errors for the same input. A non-object option gets an `AssertionError` from `src/` and a `TypeError` from the addon. `src/` sets no upper bound on `cols` and `rows`, and the addon throws a `RangeError` above 65535 [native/terminal.cc:PositiveUint16]. - Both layers track disposal and throw `"GhosttyVtTerminal has been disposed"` after `dispose()`. `dispose()` is idempotent in both layers [src/index.ts:Terminal] [native/terminal.cc:TerminalWrap::DisposeNative]. - The loader requires the addon to export the functions `createTerminal` and `getNativeInfo` [src/loader.ts:assertNativeShape]. The addon sets them in [native/terminal.cc:TerminalWrap::Init] and [native/addon.cc:InitAll]. @@ -178,7 +238,7 @@ The package has four layers: - Network and disk: every `npm run build:libghostty` fetches the pinned Ghostty commit, by default into `vendor/ghostty`, and installs `libghostty-vt` and `ghostty-source.json`, by default into `vendor/libghostty-vt`. `GHOSTTY_SOURCE_DIR` and `LIBGHOSTTY_VT_PREFIX` change these paths [scripts/build-libghostty-vt.sh:GHOSTTY_REPO]. - Disk: `npm run build:native` runs `node-gyp rebuild` [package.json:scripts.build:native], and `npm run build` writes `dist/` [tsconfig.json:outDir]. `npm run build:prebuild` runs `prebuildify --napi` [package.json:scripts.build:prebuild]. `npm run assemble:prebuilds` replaces `prebuilds/` after its checks pass [scripts/assemble-prebuilds.mjs:targetRoot]. - Remote state: the release tag script pushes `v` to `origin` [scripts/create-release-tag.mjs:git]. The mise task `publish` runs `npm publish --access public --tag "$(node scripts/resolve-npm-publish-tag.mjs)"` [mise.toml:tasks.publish]. `.github/workflows/publish.yml` also creates or updates a GitHub Release, and `.github/workflows/release-changelog.yml` commits a `CHANGELOG.md` entry to the release PR branch, pushes it, and starts `ci.yml`. -- Runtime memory: each `createTerminal` call allocates a native terminal. `dispose()` or garbage collection frees it [native/terminal.cc:TerminalWrap::~TerminalWrap]. +- Runtime memory: each `createTerminal` call allocates a native terminal and a mouse encoder. `dispose()` or garbage collection frees both [native/terminal.cc:TerminalWrap::~TerminalWrap]. ## System Verification diff --git a/examples/README.md b/examples/README.md index adacf76..f8ee4f6 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,7 +2,7 @@ ## Overview -`examples/` holds one file, `smoke.ts`. It is a smoke script for the package API in [`src/`](../src/README.md). It creates one 80x24 terminal, feeds it plain text, an SGR color sequence, and a cursor-position sequence, and prints the native info, the visible text, and a snapshot summary to stdout. The mise task `ci` runs it as its last step. +`examples/` holds one file, `smoke.ts`. It is a smoke script for the package API in [`src/`](../src/README.md). It creates one 80x24 terminal, feeds it plain text, an SGR color sequence, a cursor-position sequence, and the mouse modes 1000 and 1006, encodes one mouse press, and prints the native info, the visible text, the mouse bytes, and a snapshot summary to stdout. The mise task `ci` runs it as its last step. `smoke.ts` defines no symbols, so the citations below use line numbers. @@ -11,20 +11,20 @@ - `npm run smoke` runs `tsx examples/smoke.ts` [package.json:scripts.smoke]. - Callers: the mise task `ci` runs `npm run smoke` after `npm ci`, `npm run build:libghostty`, `npm run build:native`, `npm run build`, and `npm test` [mise.toml:tasks.ci]. `npm run verify` runs it after `npm run typecheck && npm test` [package.json:scripts.verify]. - `smoke.ts` imports `createTerminal` and `getNativeInfo` from the TypeScript source `../src/index.js`, not from `dist/` [examples/smoke.ts:1]. -- Calls, in order: `createTerminal({ cols: 80, rows: 24, scrollbackLimit: 100 })` [examples/smoke.ts:3], then `term.feed` three times (`"hello\n"`, `"\x1b[31mred text\x1b[0m\n"`, `"\x1b[3;5Hcursor"`), then `term.snapshot({ includeCells: true })`, `getNativeInfo()`, and `term.getVisibleText()` [examples/smoke.ts:5], and `term.dispose()` in `finally` [examples/smoke.ts:31]. +- Calls, in order: `createTerminal({ cols: 80, rows: 24, scrollbackLimit: 100 })` [examples/smoke.ts:3], then `term.feed` four times (`"hello\n"`, `"\x1b[31mred text\x1b[0m\n"`, `"\x1b[3;5Hcursor"`, `"\x1b[?1000h\x1b[?1006h"`) [examples/smoke.ts:5], then `term.encodeMouse({ action: "press", button: "left", x: 4, y: 5 }, { geometry: { screenWidth: 80, screenHeight: 24, cellWidth: 1, cellHeight: 1 } })` [examples/smoke.ts:11], then `term.snapshot({ includeCells: true })` [examples/smoke.ts:23], `getNativeInfo()` [examples/smoke.ts:25], and `term.getVisibleText()` [examples/smoke.ts:27], and `term.dispose()` in `finally` [examples/smoke.ts:46]. ## Invariants - `smoke.ts` has no assertions. It does not compare output with expected values, and it fails only when a call throws [examples/smoke.ts:5]. -- `term.dispose()` runs in `finally`, after the `try` body succeeds or throws. The error is not caught, so it ends the script [examples/smoke.ts:31]. +- `term.dispose()` runs in `finally`, after the `try` body succeeds or throws. The error is not caught, so it ends the script [examples/smoke.ts:46]. - `createTerminal` runs before the `try`. If it throws, no dispose runs [examples/smoke.ts:3]. - The most likely failure is a missing addon: `createTerminal` then throws `"Failed to load @coder/libghostty-vt-node native addon. ..."`, which names `npm run build:native`, a matching prebuild, and `npm run build:libghostty` or `LIBGHOSTTY_VT_PREFIX` [src/loader.ts:loadNative]. ## Side Effects -- Prints `native info` and the `getNativeInfo()` JSON (the addon's info plus `packageVersion`, `platform`, and `arch`), `visible text` and the visible text, and `snapshot summary` with a JSON object of `cols`, `rows`, `cursorRow`, `cursorCol`, `isAltScreen`, `visibleLineCount`, and `cellCount` [examples/smoke.ts:11] [src/loader.ts:decorateNativeInfo]. +- Prints `native info` and the `getNativeInfo()` JSON (the addon's info plus `packageVersion`, `platform`, and `arch`), `visible text` and the visible text, `mouse bytes` and the encoded bytes as a JSON array of numbers, and `snapshot summary` with a JSON object of `cols`, `rows`, `cursorRow`, `cursorCol`, `isAltScreen`, `visibleLineCount`, and `cellCount` [examples/smoke.ts:24] [src/loader.ts:decorateNativeInfo]. - Loads the native addon through `loadNative()` on the first API call, `createTerminal` [src/index.ts:createTerminal]. -- An uncaught error ends the process with a non-zero exit code, which fails `npm run smoke` and the mise task `ci`. `smoke.ts` writes no files [examples/smoke.ts:31]. +- An uncaught error ends the process with a non-zero exit code, which fails `npm run smoke` and the mise task `ci`. `smoke.ts` writes no files [examples/smoke.ts:46]. ## Verification diff --git a/examples/smoke.ts b/examples/smoke.ts index 8e45182..ba967d6 100644 --- a/examples/smoke.ts +++ b/examples/smoke.ts @@ -6,12 +6,27 @@ try { term.feed("hello\n"); term.feed("\x1b[31mred text\x1b[0m\n"); term.feed("\x1b[3;5Hcursor"); + term.feed("\x1b[?1000h\x1b[?1006h"); + + const mouseBytes = term.encodeMouse( + { action: "press", button: "left", x: 4, y: 5 }, + { + geometry: { + screenWidth: 80, + screenHeight: 24, + cellWidth: 1, + cellHeight: 1, + }, + }, + ); const snapshot = term.snapshot({ includeCells: true }); console.log("native info"); console.log(JSON.stringify(getNativeInfo(), null, 2)); console.log("visible text"); console.log(term.getVisibleText()); + console.log("mouse bytes"); + console.log(JSON.stringify([...mouseBytes])); console.log("snapshot summary"); console.log( JSON.stringify( diff --git a/native/README.md b/native/README.md index cd522dc..75864eb 100644 --- a/native/README.md +++ b/native/README.md @@ -2,7 +2,7 @@ ## Overview -`native/` holds the C++ source of the Node-API addon. node-gyp builds it as the target `libghostty_vt_node` from `addon.cc`, `libghostty_vt_shim.cc`, and `terminal.cc`, with `node-addon-api`, and links it statically against `libghostty-vt` ([`binding.gyp`](../binding.gyp)). The ObjectWrap class `TerminalWrap` owns one libghostty-vt `GhosttyTerminal`. JavaScript sees it as `GhosttyVtTerminal`. The addon exports `createTerminal` and `getNativeInfo`. [`src/`](https://github.com/coder/libghostty-vt-node/blob/main/src/README.md) loads the addon and wraps it. The shim files turn libghostty-vt result codes into error text and encode UTF-8 and hex colors. +`native/` holds the C++ source of the Node-API addon. node-gyp builds it as the target `libghostty_vt_node` from `addon.cc`, `libghostty_vt_shim.cc`, and `terminal.cc`, with `node-addon-api`, and links it statically against `libghostty-vt` ([`binding.gyp`](../binding.gyp)). The ObjectWrap class `TerminalWrap` owns one libghostty-vt `GhosttyTerminal` and one `GhosttyMouseEncoder`. JavaScript sees it as `GhosttyVtTerminal`. The addon exports `createTerminal` and `getNativeInfo`. [`src/`](https://github.com/coder/libghostty-vt-node/blob/main/src/README.md) loads the addon and wraps it. The shim files turn libghostty-vt result codes into error text and encode UTF-8 and hex colors. ## Interface @@ -16,11 +16,12 @@ Build ([`binding.gyp`](../binding.gyp)): JavaScript exports: - Module init `InitAll(env, exports)` runs `TerminalWrap::Init` and sets `exports.getNativeInfo`. `NODE_API_MODULE(libghostty_vt_node, InitAll)` registers it [native/addon.cc:InitAll] -- `TerminalWrap::Init` defines the class `GhosttyVtTerminal` with the instance methods `feed`, `resize`, `snapshot`, `getVisibleText`, `formatPlain`, `formatHtml`, and `dispose`, and sets `exports.createTerminal`. It does not put the class constructor on `exports` [native/terminal.cc:TerminalWrap::Init] +- `TerminalWrap::Init` defines the class `GhosttyVtTerminal` with the instance methods `feed`, `resize`, `encodeMouse`, `snapshot`, `getVisibleText`, `formatPlain`, `formatHtml`, and `dispose`, and sets `exports.createTerminal`. It does not put the class constructor on `exports` [native/terminal.cc:TerminalWrap::Init] - `createTerminal(options: { cols, rows, scrollbackLimit? })`: returns a new `GhosttyVtTerminal` [native/terminal.cc:CreateTerminal] - `getNativeInfo()`: returns `{ napiVersion, ghosttyVersion?, ghosttyCommit? }`. `napiVersion` comes from `napi_get_version` at run time, not from the compiled `NAPI_VERSION`. `ghosttyVersion` comes from `GHOSTTY_BUILD_INFO_VERSION_STRING` and `ghosttyCommit` from `GHOSTTY_BUILD_INFO_VERSION_BUILD` [native/terminal.cc:GetNativeInfo] - `feed(data: string | Buffer | Uint8Array)`: writes the bytes (a string as UTF-8) [native/terminal.cc:TerminalWrap::Feed] - `resize(cols, rows)`: calls `ghostty_terminal_resize(terminal, cols, rows, 0, 0)` [native/terminal.cc:TerminalWrap::Resize] +- `encodeMouse(event: { action, button?, x, y, modifiers? }, options: { geometry, anyButtonPressed?, trackLastCell? })`: returns a `Buffer` with the mouse input bytes for the tracking mode and format that the terminal output selected. The buffer is empty when tracking is off or the mode drops the event. `geometry` has `screenWidth`, `screenHeight`, `cellWidth`, `cellHeight`, `paddingTop`, `paddingBottom`, `paddingRight`, and `paddingLeft`, all required here [native/terminal.cc:TerminalWrap::EncodeMouse] - `snapshot(options?: { includeScrollback?, includeCells? })`: returns `{ cols, rows, cursorRow, cursorCol, isAltScreen, visibleLines, scrollbackLines?, cells? }`. Each entry of `visibleLines` and `scrollbackLines` is `{ row, text }`. `isAltScreen` is true when the active screen is `GHOSTTY_TERMINAL_SCREEN_ALTERNATE` [native/terminal.cc:TerminalWrap::BuildSnapshot] - A snapshot cell is `{ row, col, text, width }`. A styled cell can also carry `bold`, `italic`, and `underline` (each set only when true) and `foreground` / `background` as `#rrggbb` [native/terminal.cc:TerminalWrap::BuildCellObject] - `getVisibleText()`: the active-screen rows joined by `\n` [native/terminal.cc:TerminalWrap::GetVisibleText] @@ -29,10 +30,10 @@ JavaScript exports: C++ declarations: -- `class TerminalWrap final : public Napi::ObjectWrap`, with the member `GhosttyTerminal terminal_ = nullptr`, and the free functions `CreateTerminal` and `GetNativeInfo` [native/terminal.hh:TerminalWrap] +- `class TerminalWrap final : public Napi::ObjectWrap`, with the members `GhosttyTerminal terminal_ = nullptr`, `GhosttyMouseEncoder mouse_encoder_ = nullptr`, `mouse_modes_dirty_` (starts `true`), `mouse_size_configured_`, and `mouse_size_`, and the free functions `CreateTerminal` and `GetNativeInfo` [native/terminal.hh:TerminalWrap] - Shim helpers `ResultName`, `ResultMessage`, `GhosttyStringToString`, `BuildInfoString`, `Utf8FromCodepoint`, and `HexColor`. The header defines `GHOSTTY_STATIC` when it is not defined, then includes `` [native/libghostty_vt_shim.hh:ResultMessage] - `ResultMessage(op, result)` returns `" failed: "` [native/libghostty_vt_shim.cc:ResultMessage]. `ResultName` maps unknown codes to `GHOSTTY_UNKNOWN_RESULT` [native/libghostty_vt_shim.cc:ResultName] -- Private helpers: `BuildLines` (rows of one point tag), `ReadLine`, `ReadCellText` (grapheme or single-codepoint text), `ResolveStyleColor`, `OptionBool`, and `ThrowResult` [native/terminal.cc:TerminalWrap::BuildLines] [native/terminal.cc:TerminalWrap::ReadCellText] +- Private helpers: `BuildLines` (rows of one point tag), `ReadLine`, `ReadCellText` (grapheme or single-codepoint text), `ResolveStyleColor`, `OptionBool`, and `ThrowResult` [native/terminal.cc:TerminalWrap::BuildLines] [native/terminal.cc:TerminalWrap::ReadCellText]. Mouse helpers: `RequireMouseEncoder`, `PositiveUint32`, `NonNegativeUint32`, `FiniteFloat`, `ValidateMouseAxis`, `MouseCoordinate`, `ValidateUtf8MouseCoordinate`, `ParseMouseAction`, `ParseMouseButton`, `ParseMouseModifiers`, and `MouseSizeEqual` [native/terminal.cc:MouseCoordinate] ## Invariants @@ -40,12 +41,19 @@ C++ declarations: - `cols` and `rows`: a non-number throws `TypeError(" must be a positive integer")`. `NaN`, an infinity, a value below 1 or above 65535, or a non-integer throws `RangeError(" must be a positive 16-bit integer")`. The range check runs before the value is cast to `uint16_t` [native/terminal.cc:PositiveUint16]. - `scrollbackLimit` is skipped when it is absent or `undefined`, and then defaults to 0. A non-number throws `TypeError`. `NaN`, an infinity, a negative value, a non-integer, or a value too large for `size_t` (2^64 or more on 64-bit targets) throws `RangeError`. Both use `"scrollbackLimit must be a non-negative integer"`, and the range check runs before the cast [native/terminal.cc:NonNegativeSize]. - Most failed libghostty-vt calls throw an `Error` with `ResultMessage` text, for example `"ghostty_terminal_new failed: GHOSTTY_OUT_OF_MEMORY"` [native/terminal.cc:ThrowResult]. These do not throw: a failed palette read leaves the color out, a failed `ghostty_build_info` leaves the field out, a first `ghostty_grid_ref_graphemes` call that does not return `GHOSTTY_OUT_OF_SPACE` with `required > 0` falls back to the single codepoint, and the code checks no result from `ghostty_terminal_vt_write` [native/terminal.cc:TerminalWrap::ResolveStyleColor] [native/terminal.cc:TerminalWrap::ReadCellText] [native/terminal.cc:TerminalWrap::Feed]. -- Every method except `dispose` calls `RequireTerminal` first. After dispose it throws `Error("GhosttyVtTerminal has been disposed")` [native/terminal.cc:TerminalWrap::RequireTerminal]. With `NAPI_CPP_EXCEPTIONS`, `RequireTerminal` reports a disposed terminal by throwing, so the `env.IsExceptionPending()` checks after it do not see that error. +- Every method except `dispose` and `encodeMouse` calls `RequireTerminal` first. After dispose it throws `Error("GhosttyVtTerminal has been disposed")` [native/terminal.cc:TerminalWrap::RequireTerminal]. With `NAPI_CPP_EXCEPTIONS`, `RequireTerminal` reports a disposed terminal by throwing, so the `env.IsExceptionPending()` checks after it do not see that error. +- `encodeMouse` reads and checks every argument first, and only then calls `RequireTerminal` and `RequireMouseEncoder` (`"GhosttyVtTerminal has been disposed"`). `Get()` runs getters, also getters on `Object.prototype` for keys that the caller leaves out, and such JS can dispose the terminal or call `encodeMouse` again. After the handles are taken, no JS runs until the call returns [native/terminal.cc:TerminalWrap::EncodeMouse]. +- `encodeMouse` throws `TypeError("encodeMouse expects event and options objects")` unless it gets exactly two objects, and `TypeError("mouse geometry must be an object")` for a non-object `geometry` [native/terminal.cc:TerminalWrap::EncodeMouse]. `screenWidth`, `screenHeight`, `cellWidth`, and `cellHeight` must be integers from 1 to 2^32-1, and the four paddings integers from 0 to 2^32-1: a non-number throws `TypeError(" must be a positive integer")` or `"... non-negative integer"`, and any other bad value `RangeError(" must be a positive 32-bit integer")` or `"... non-negative 32-bit integer"` [native/terminal.cc:PositiveUint32] [native/terminal.cc:NonNegativeUint32]. +- `encodeMouse` geometry limits, the same calculation that Ghostty makes before its unchecked casts: the padding sum of an axis, added in 64 bits, must not exceed the screen size (`RangeError("mouse geometry padding exceeds screenWidth")`, or `screenHeight`), and `f32(screen - padding) / f32(cell)` must be below 65536 (`RangeError("mouse geometry.screenWidth exceeds the 65535-cell limit")`, or `screenHeight`) [native/terminal.cc:ValidateMouseAxis]. +- `encodeMouse` coordinates: A non-number `x` or `y` throws `TypeError("mouse x must be a finite number")`, and a value that is not finite or is outside the f32 range throws `RangeError("mouse x must be a finite 32-bit float")`. After the f32 rounding and the padding subtraction, the value must fit a signed 32-bit integer, and the value divided by the cell size must be below 65536. Otherwise it throws `RangeError("mouse x is outside Ghostty's cell or pixel coordinate range")`. The check runs for every event, also one that the mode drops [native/terminal.cc:MouseCoordinate]. +- `encodeMouse` with mode 1005 (UTF-8) set: it computes the clamped cell as Ghostty does and throws `RangeError("mouse x is unsafe for Ghostty's UTF-8 mouse encoding")` when `cell + 33` is a UTF-16 surrogate (0xD800 to 0xDFFF), or for `y` also above 65535. It checks while 1005 is set, even when a later mode selected another format [native/terminal.cc:ValidateUtf8MouseCoordinate]. +- `encodeMouse` `action` must be `press`, `release`, or `motion`, and `button` (when not `undefined`) one of `left`, `right`, `middle`, `four` to `eleven`. A non-string throws `TypeError("mouse action must be a string")` or `"mouse button must be a string"`, an unknown string `RangeError("mouse action is invalid")` or `"mouse button is invalid"`. `modifiers`, when defined, must be an object (`TypeError("mouse modifiers must be an object")`), and `shift`, `ctrl`, `alt`, `anyButtonPressed`, and `trackLastCell` count only when they are boolean `true` [native/terminal.cc:ParseMouseAction] [native/terminal.cc:ParseMouseButton] [native/terminal.cc:ParseMouseModifiers]. +- Mouse mode cache: a non-empty `feed` sets `mouse_modes_dirty_`. The next `encodeMouse` that passes its checks then reloads the tracking mode and format with `ghostty_mouse_encoder_setopt_from_terminal`. The pinned Ghostty also resets its same-cell motion deduplication there (see "Mouse encoding" in the root README). A call that throws leaves `mouse_modes_dirty_` set. `encodeMouse` sets the size option only when the geometry differs from the last one [native/terminal.cc:TerminalWrap::Feed] [native/terminal.cc:TerminalWrap::EncodeMouse]. - `feed` throws `TypeError` for an argument count other than 1 (`"feed expects exactly one argument"`), for a typed array that is not a `Uint8Array` (`"feed typed array must be Uint8Array"`), and for any other value (`"feed data must be a string, Buffer, or Uint8Array"`). Zero-length input returns without a libghostty-vt call [native/terminal.cc:TerminalWrap::Feed]. - `resize` throws `TypeError("resize expects cols and rows")` for an argument count other than 2. It checks `cols` and `rows` as the constructor does [native/terminal.cc:TerminalWrap::Resize]. - `snapshot` throws `TypeError("snapshot options must be an object")` for a defined non-object argument [native/terminal.cc:TerminalWrap::Snapshot]. `includeScrollback` and `includeCells` count only when they are boolean `true` [native/terminal.cc:OptionBool]. - `snapshot`, `getVisibleText`, `formatPlain`, and `formatHtml` catch `std::exception` from the internal helpers and rethrow it as a JavaScript `Error` with the same message [native/terminal.cc:TerminalWrap::Snapshot]. -- `dispose` is idempotent. `DisposeNative` returns early when `terminal_` is `nullptr`. Otherwise it frees the terminal and sets `terminal_` to `nullptr` [native/terminal.cc:TerminalWrap::DisposeNative]. +- `dispose` is idempotent. `DisposeNative` frees `mouse_encoder_` when it is not `nullptr`, then `terminal_` when it is not `nullptr`, and sets each to `nullptr` after it frees it [native/terminal.cc:TerminalWrap::DisposeNative]. - Per-environment state: `Init` keeps the class constructor in the env's instance data, not in a static, so the main thread and each `worker_threads` Worker that loads the addon have their own. `Init` asserts that the instance data is unset before it sets it [native/terminal.cc:TerminalWrap::Init]. `Create` asserts that the constructor is set [native/terminal.cc:TerminalWrap::Create]. - Lines skip wide spacer cells, write `' '` for cells with no text, and drop trailing spaces. A cell that has text but codepoint 0 adds nothing to the line [native/terminal.cc:TerminalWrap::ReadLine] [native/terminal.cc:TerminalWrap::ReadCellText]. - Snapshot cells come from visible rows only, because `BuildLines` passes no cell list for scrollback. They include only cells with text. `width` is 2 for a wide cell and 1 otherwise [native/terminal.cc:TerminalWrap::BuildSnapshot] [native/terminal.cc:TerminalWrap::BuildCellObject]. @@ -56,9 +64,9 @@ C++ declarations: ## Side Effects -- The constructor allocates a native terminal with `ghostty_terminal_new(nullptr, &created, options)` (default allocator) and stores it in `terminal_` [native/terminal.cc:TerminalWrap::TerminalWrap]. -- `DisposeNative` frees it with `ghostty_terminal_free` [native/terminal.cc:TerminalWrap::DisposeNative]. The destructor calls `DisposeNative`, so garbage collection of an undisposed object also frees the terminal [native/terminal.cc:TerminalWrap::~TerminalWrap]. -- `feed` changes terminal state through `ghostty_terminal_vt_write` [native/terminal.cc:TerminalWrap::Feed]. `resize` changes the size through `ghostty_terminal_resize` [native/terminal.cc:TerminalWrap::Resize]. +- The constructor allocates a native terminal with `ghostty_terminal_new(nullptr, &created, options)` (default allocator) and stores it in `terminal_`. Then it allocates a mouse encoder with `ghostty_mouse_encoder_new(nullptr, ...)` and stores it in `mouse_encoder_`. When that fails, it frees the terminal, sets `terminal_` to `nullptr`, and throws the `ResultMessage` error [native/terminal.cc:TerminalWrap::TerminalWrap]. +- `DisposeNative` frees them with `ghostty_mouse_encoder_free` and `ghostty_terminal_free` [native/terminal.cc:TerminalWrap::DisposeNative]. The destructor calls `DisposeNative`, so garbage collection of an undisposed object also frees both [native/terminal.cc:TerminalWrap::~TerminalWrap]. +- `feed` changes terminal state through `ghostty_terminal_vt_write` and sets `mouse_modes_dirty_` [native/terminal.cc:TerminalWrap::Feed]. `encodeMouse` changes the encoder options and its last-cell state, and allocates one `GhosttyMouseEvent` per call that passes its checks, which it frees on every path after that [native/terminal.cc:TerminalWrap::EncodeMouse]. `resize` changes the size through `ghostty_terminal_resize` [native/terminal.cc:TerminalWrap::Resize]. - `Format` creates a formatter, calls `ghostty_formatter_format_alloc`, frees the formatter, then frees the output buffer with `ghostty_free(nullptr, ptr, len)` [native/terminal.cc:TerminalWrap::Format]. - `Init` allocates a `Napi::FunctionReference` and stores it with `env.SetInstanceData`. The code comment says Node deletes it when the env shuts down [native/terminal.cc:TerminalWrap::Init]. - The addon links the `libghostty-vt` static library that the resolver script finds [binding.gyp:libraries]. @@ -68,5 +76,5 @@ C++ declarations: - Run (after `npm ci`): `npm run build:libghostty && npm run build:native && npm test` - Every native test skips when the addon does not load (`test/native-support.ts:nativeSupport`), so check the test output for skips. - `mise run ci-ubsan` builds the addon with UBSan (Linux, GCC), then runs the tests and smoke. Undefined behavior aborts the run. The task also fails when the addon's object files contain no UBSan checks, which means the flags did not reach g++ (`mise.toml:tasks.ci-ubsan`). -- `test/workers.test.ts` checks the per-environment state in child processes with `worker_threads`. `test/terminal.test.ts` covers `feed`, `snapshot`, `resize`, `dispose`, and the `RangeError` cases of the `createTerminal` checks in the addon. No vitest test covers `formatPlain`, `formatHtml`, `includeScrollback`, the cell style fields, or the typed-array errors. +- `test/workers.test.ts` checks the per-environment state in child processes with `worker_threads`. `test/terminal.test.ts` covers `feed`, `snapshot`, `resize`, `dispose`, the `RangeError` cases of the `createTerminal` checks in the addon, and the `encodeMouse` bytes of each mouse format. `test/mouse-safety.test.ts` runs the `encodeMouse` limits and the getter re-entry cases against both the package API and the raw addon object. It does not skip when the addon is missing. Its child-process cases load `src/` through `tsx`, so they need no `dist/` build. No vitest test covers `formatPlain`, `formatHtml`, `includeScrollback`, the cell style fields, or the typed-array errors. - For changes to `TerminalWrap` or `InitAll`, also run the TLA+ lifecycle model: [`.agents/skills/verify-native-lifecycle/SKILL.md`](https://github.com/coder/libghostty-vt-node/blob/main/.agents/skills/verify-native-lifecycle/SKILL.md). diff --git a/native/terminal.cc b/native/terminal.cc index 1c116e8..a18f648 100644 --- a/native/terminal.cc +++ b/native/terminal.cc @@ -41,11 +41,139 @@ size_t NonNegativeSize(Napi::Env env, const Napi::Value& value, const char* name return result; } +uint32_t PositiveUint32(Napi::Env env, const Napi::Value& value, const char* name) { + if (!value.IsNumber()) { + throw Napi::TypeError::New(env, std::string(name) + " must be a positive integer"); + } + const double number = value.As().DoubleValue(); + if (!std::isfinite(number) || number <= 0 || number != std::floor(number) || + number > std::numeric_limits::max()) { + throw Napi::RangeError::New(env, std::string(name) + " must be a positive 32-bit integer"); + } + return static_cast(number); +} + +uint32_t NonNegativeUint32(Napi::Env env, const Napi::Value& value, const char* name) { + if (!value.IsNumber()) { + throw Napi::TypeError::New(env, std::string(name) + " must be a non-negative integer"); + } + const double number = value.As().DoubleValue(); + if (!std::isfinite(number) || number < 0 || number != std::floor(number) || + number > std::numeric_limits::max()) { + throw Napi::RangeError::New(env, std::string(name) + " must be a non-negative 32-bit integer"); + } + return static_cast(number); +} + +float FiniteFloat(Napi::Env env, const Napi::Value& value, const char* name) { + if (!value.IsNumber()) { + throw Napi::TypeError::New(env, std::string(name) + " must be a finite number"); + } + const double number = value.As().DoubleValue(); + if (!std::isfinite(number) || number < -std::numeric_limits::max() || + number > std::numeric_limits::max()) { + throw Napi::RangeError::New(env, std::string(name) + " must be a finite 32-bit float"); + } + return static_cast(number); +} + +// Validate the same f32 grid calculation Ghostty performs before its u16 cast. +// Widen padding sums first so hostile u32 input cannot wrap during validation. +void ValidateMouseAxis(Napi::Env env, uint32_t screen, uint32_t cell, + uint64_t padding, const char* name) { + if (padding > screen) { + throw Napi::RangeError::New(env, std::string("mouse geometry padding exceeds ") + name); + } + const float cells = static_cast(screen - padding) / static_cast(cell); + if (cells >= 65536) { + throw Napi::RangeError::New(env, std::string("mouse geometry.") + name + " exceeds the 65535-cell limit"); + } +} + +// Positions cross the C API as f32, then Ghostty removes padding in f64. +// Reject values that cannot reach either cell or pixel encoding safely. +float MouseCoordinate(Napi::Env env, const Napi::Value& value, const char* name, + uint32_t padding, uint32_t cell) { + const float position = FiniteFloat(env, value, name); + const double terminal_position = static_cast(position) - padding; + if (terminal_position < std::numeric_limits::min() || + terminal_position > std::numeric_limits::max() || + terminal_position / cell >= 65536) { + throw Napi::RangeError::New(env, std::string(name) + " is outside Ghostty's cell or pixel coordinate range"); + } + return position; +} + +void ValidateUtf8MouseCoordinate(Napi::Env env, float position, uint32_t screen, + uint32_t cell, uint32_t before, uint32_t after, + const char* name, bool is_row) { + // Match Ghostty's f32 grid size and f64 position conversion, including + // viewport clamping. The pinned UTF-8 encoder assumes Unicode scalars and + // adds 33 to rows in u16 arithmetic before widening. + const auto cells = std::max(1, static_cast( + static_cast(screen - before - after) / static_cast(cell))); + const auto index = static_cast( + std::max(0.0, static_cast(position) - before) / cell); + const auto codepoint = std::min(index, cells - 1) + 33; + if ((codepoint >= 0xd800 && codepoint <= 0xdfff) || + (is_row && codepoint > 65535)) { + throw Napi::RangeError::New(env, std::string(name) + " is unsafe for Ghostty's UTF-8 mouse encoding"); + } +} + +GhosttyMouseAction ParseMouseAction(Napi::Env env, const Napi::Value& value) { + if (!value.IsString()) throw Napi::TypeError::New(env, "mouse action must be a string"); + const std::string action = value.As().Utf8Value(); + if (action == "press") return GHOSTTY_MOUSE_ACTION_PRESS; + if (action == "release") return GHOSTTY_MOUSE_ACTION_RELEASE; + if (action == "motion") return GHOSTTY_MOUSE_ACTION_MOTION; + throw Napi::RangeError::New(env, "mouse action is invalid"); +} + +GhosttyMouseButton ParseMouseButton(Napi::Env env, const Napi::Value& value) { + if (!value.IsString()) throw Napi::TypeError::New(env, "mouse button must be a string"); + const std::string button = value.As().Utf8Value(); + if (button == "left") return GHOSTTY_MOUSE_BUTTON_LEFT; + if (button == "right") return GHOSTTY_MOUSE_BUTTON_RIGHT; + if (button == "middle") return GHOSTTY_MOUSE_BUTTON_MIDDLE; + if (button == "four") return GHOSTTY_MOUSE_BUTTON_FOUR; + if (button == "five") return GHOSTTY_MOUSE_BUTTON_FIVE; + if (button == "six") return GHOSTTY_MOUSE_BUTTON_SIX; + if (button == "seven") return GHOSTTY_MOUSE_BUTTON_SEVEN; + if (button == "eight") return GHOSTTY_MOUSE_BUTTON_EIGHT; + if (button == "nine") return GHOSTTY_MOUSE_BUTTON_NINE; + if (button == "ten") return GHOSTTY_MOUSE_BUTTON_TEN; + if (button == "eleven") return GHOSTTY_MOUSE_BUTTON_ELEVEN; + throw Napi::RangeError::New(env, "mouse button is invalid"); +} + bool OptionBool(const Napi::Object& options, const char* name) { const Napi::Value value = options.Get(name); return value.IsBoolean() && value.As().Value(); } +GhosttyMods ParseMouseModifiers(Napi::Env env, const Napi::Value& value) { + if (value.IsUndefined()) return 0; + if (!value.IsObject()) throw Napi::TypeError::New(env, "mouse modifiers must be an object"); + const Napi::Object modifiers = value.As(); + GhosttyMods result = 0; + if (OptionBool(modifiers, "shift")) result |= GHOSTTY_MODS_SHIFT; + if (OptionBool(modifiers, "ctrl")) result |= GHOSTTY_MODS_CTRL; + if (OptionBool(modifiers, "alt")) result |= GHOSTTY_MODS_ALT; + return result; +} + +bool MouseSizeEqual(const GhosttyMouseEncoderSize& left, const GhosttyMouseEncoderSize& right) { + return left.screen_width == right.screen_width && + left.screen_height == right.screen_height && + left.cell_width == right.cell_width && + left.cell_height == right.cell_height && + left.padding_top == right.padding_top && + left.padding_bottom == right.padding_bottom && + left.padding_right == right.padding_right && + left.padding_left == right.padding_left; +} + void ThrowResult(Napi::Env env, const char* operation, GhosttyResult result) { throw Napi::Error::New(env, ResultMessage(operation, result)); } @@ -59,6 +187,7 @@ void TerminalWrap::Init(Napi::Env env, Napi::Object exports) { { InstanceMethod("feed", &TerminalWrap::Feed), InstanceMethod("resize", &TerminalWrap::Resize), + InstanceMethod("encodeMouse", &TerminalWrap::EncodeMouse), InstanceMethod("snapshot", &TerminalWrap::Snapshot), InstanceMethod("getVisibleText", &TerminalWrap::GetVisibleText), InstanceMethod("formatPlain", &TerminalWrap::FormatPlain), @@ -109,14 +238,29 @@ TerminalWrap::TerminalWrap(const Napi::CallbackInfo& info) } assert(created != nullptr); terminal_ = created; + + GhosttyMouseEncoder mouse_encoder = nullptr; + const GhosttyResult mouse_result = ghostty_mouse_encoder_new(nullptr, &mouse_encoder); + if (mouse_result != GHOSTTY_SUCCESS) { + ghostty_terminal_free(terminal_); + terminal_ = nullptr; + ThrowResult(env, "ghostty_mouse_encoder_new", mouse_result); + } + assert(mouse_encoder != nullptr); + mouse_encoder_ = mouse_encoder; } TerminalWrap::~TerminalWrap() { DisposeNative(); } void TerminalWrap::DisposeNative() { - if (terminal_ == nullptr) return; - ghostty_terminal_free(terminal_); - terminal_ = nullptr; + if (mouse_encoder_ != nullptr) { + ghostty_mouse_encoder_free(mouse_encoder_); + mouse_encoder_ = nullptr; + } + if (terminal_ != nullptr) { + ghostty_terminal_free(terminal_); + terminal_ = nullptr; + } } GhosttyTerminal TerminalWrap::RequireTerminal(Napi::Env env) { @@ -126,6 +270,13 @@ GhosttyTerminal TerminalWrap::RequireTerminal(Napi::Env env) { return terminal_; } +GhosttyMouseEncoder TerminalWrap::RequireMouseEncoder(Napi::Env env) { + if (mouse_encoder_ == nullptr) { + throw Napi::Error::New(env, "GhosttyVtTerminal has been disposed"); + } + return mouse_encoder_; +} + Napi::Value TerminalWrap::Feed(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); GhosttyTerminal terminal = RequireTerminal(env); @@ -162,6 +313,7 @@ Napi::Value TerminalWrap::Feed(const Napi::CallbackInfo& info) { if (len == 0) return env.Undefined(); assert(data != nullptr); ghostty_terminal_vt_write(terminal, data, len); + mouse_modes_dirty_ = true; return env.Undefined(); } @@ -182,6 +334,131 @@ Napi::Value TerminalWrap::Resize(const Napi::CallbackInfo& info) { return env.Undefined(); } +// Encode against the child-negotiated terminal state while caching geometry so +// Ghostty's same-cell motion deduplication survives unchanged input dimensions. +// +// Every Get() below can run JS: a getter on the argument objects, or one on +// Object.prototype for a key that the caller left out. That JS can dispose this +// terminal or call encodeMouse again with other geometry. So read and validate +// every argument first, then take the handles, and run no JS after that. +Napi::Value TerminalWrap::EncodeMouse(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() != 2 || !info[0].IsObject() || !info[1].IsObject()) { + throw Napi::TypeError::New(env, "encodeMouse expects event and options objects"); + } + + const Napi::Object input = info[0].As(); + const Napi::Object options = info[1].As(); + const Napi::Value geometry_value = options.Get("geometry"); + if (!geometry_value.IsObject()) { + throw Napi::TypeError::New(env, "mouse geometry must be an object"); + } + const Napi::Object geometry = geometry_value.As(); + + GhosttyMouseEncoderSize size = {}; + size.size = sizeof(GhosttyMouseEncoderSize); + size.screen_width = PositiveUint32(env, geometry.Get("screenWidth"), "screenWidth"); + size.screen_height = PositiveUint32(env, geometry.Get("screenHeight"), "screenHeight"); + size.cell_width = PositiveUint32(env, geometry.Get("cellWidth"), "cellWidth"); + size.cell_height = PositiveUint32(env, geometry.Get("cellHeight"), "cellHeight"); + size.padding_top = NonNegativeUint32(env, geometry.Get("paddingTop"), "paddingTop"); + size.padding_bottom = NonNegativeUint32(env, geometry.Get("paddingBottom"), "paddingBottom"); + size.padding_right = NonNegativeUint32(env, geometry.Get("paddingRight"), "paddingRight"); + size.padding_left = NonNegativeUint32(env, geometry.Get("paddingLeft"), "paddingLeft"); + ValidateMouseAxis(env, size.screen_width, size.cell_width, + static_cast(size.padding_left) + size.padding_right, "screenWidth"); + ValidateMouseAxis(env, size.screen_height, size.cell_height, + static_cast(size.padding_top) + size.padding_bottom, "screenHeight"); + const GhosttyMousePosition position = { + MouseCoordinate(env, input.Get("x"), "mouse x", size.padding_left, size.cell_width), + MouseCoordinate(env, input.Get("y"), "mouse y", size.padding_top, size.cell_height), + }; + const GhosttyMouseAction action = ParseMouseAction(env, input.Get("action")); + const Napi::Value button_value = input.Get("button"); + const bool has_button = !button_value.IsUndefined(); + const GhosttyMouseButton button = + has_button ? ParseMouseButton(env, button_value) : GHOSTTY_MOUSE_BUTTON_UNKNOWN; + const GhosttyMods mods = ParseMouseModifiers(env, input.Get("modifiers")); + const bool any_button_pressed = OptionBool(options, "anyButtonPressed"); + const bool track_last_cell = OptionBool(options, "trackLastCell"); + + // No JS runs from here on, so the handles stay valid until we return. + GhosttyTerminal terminal = RequireTerminal(env); + GhosttyMouseEncoder encoder = RequireMouseEncoder(env); + + bool utf8_mouse = false; + const GhosttyResult mode_result = ghostty_terminal_mode_get( + terminal, GHOSTTY_MODE_UTF8_MOUSE, &utf8_mouse); + if (mode_result != GHOSTTY_SUCCESS) ThrowResult(env, "ghostty_terminal_mode_get", mode_result); + if (utf8_mouse) { + // Mode bits cannot identify the last selected format. Conservatively guard + // these exceptional cells whenever 1005 is set, even if another format was + // selected later. Ordinary geometry and SGR-only large grids are unchanged. + ValidateUtf8MouseCoordinate(env, position.x, size.screen_width, size.cell_width, + size.padding_left, size.padding_right, "mouse x", false); + ValidateUtf8MouseCoordinate(env, position.y, size.screen_height, size.cell_height, + size.padding_top, size.padding_bottom, "mouse y", true); + } + + if (mouse_modes_dirty_) { + ghostty_mouse_encoder_setopt_from_terminal(encoder, terminal); + mouse_modes_dirty_ = false; + } + if (!mouse_size_configured_ || !MouseSizeEqual(size, mouse_size_)) { + ghostty_mouse_encoder_setopt(encoder, GHOSTTY_MOUSE_ENCODER_OPT_SIZE, &size); + mouse_size_ = size; + mouse_size_configured_ = true; + } + ghostty_mouse_encoder_setopt( + encoder, GHOSTTY_MOUSE_ENCODER_OPT_ANY_BUTTON_PRESSED, &any_button_pressed); + ghostty_mouse_encoder_setopt( + encoder, GHOSTTY_MOUSE_ENCODER_OPT_TRACK_LAST_CELL, &track_last_cell); + + GhosttyMouseEvent event = nullptr; + GhosttyResult result = ghostty_mouse_event_new(nullptr, &event); + if (result != GHOSTTY_SUCCESS) ThrowResult(env, "ghostty_mouse_event_new", result); + assert(event != nullptr); + + try { + ghostty_mouse_event_set_action(event, action); + if (has_button) { + ghostty_mouse_event_set_button(event, button); + } else { + ghostty_mouse_event_clear_button(event); + } + ghostty_mouse_event_set_mods(event, mods); + ghostty_mouse_event_set_position(event, position); + + size_t required = 0; + result = ghostty_mouse_encoder_encode(encoder, event, nullptr, 0, &required); + if (result == GHOSTTY_SUCCESS) { + assert(required == 0); + ghostty_mouse_event_free(event); + return Napi::Buffer::New(env, 0); + } + if (result != GHOSTTY_OUT_OF_SPACE) { + ThrowResult(env, "ghostty_mouse_encoder_encode(size)", result); + } + if (required == 0) { + throw Napi::Error::New(env, "ghostty_mouse_encoder_encode returned an empty size"); + } + + std::vector output(required); + size_t written = 0; + result = ghostty_mouse_encoder_encode( + encoder, event, reinterpret_cast(output.data()), output.size(), &written); + if (result != GHOSTTY_SUCCESS) { + ThrowResult(env, "ghostty_mouse_encoder_encode", result); + } + assert(written <= output.size()); + ghostty_mouse_event_free(event); + return Napi::Buffer::Copy(env, output.data(), written); + } catch (...) { + ghostty_mouse_event_free(event); + throw; + } +} + Napi::Value TerminalWrap::Snapshot(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); RequireTerminal(env); diff --git a/native/terminal.hh b/native/terminal.hh index bdf8606..e53080a 100644 --- a/native/terminal.hh +++ b/native/terminal.hh @@ -22,8 +22,10 @@ class TerminalWrap final : public Napi::ObjectWrap { private: void DisposeNative(); GhosttyTerminal RequireTerminal(Napi::Env env); + GhosttyMouseEncoder RequireMouseEncoder(Napi::Env env); Napi::Value Feed(const Napi::CallbackInfo& info); Napi::Value Resize(const Napi::CallbackInfo& info); + Napi::Value EncodeMouse(const Napi::CallbackInfo& info); Napi::Value Snapshot(const Napi::CallbackInfo& info); Napi::Value GetVisibleText(const Napi::CallbackInfo& info); Napi::Value FormatPlain(const Napi::CallbackInfo& info); @@ -39,6 +41,10 @@ class TerminalWrap final : public Napi::ObjectWrap { std::string ResolveStyleColor(const GhosttyStyleColor& color); GhosttyTerminal terminal_ = nullptr; + GhosttyMouseEncoder mouse_encoder_ = nullptr; + bool mouse_modes_dirty_ = true; + bool mouse_size_configured_ = false; + GhosttyMouseEncoderSize mouse_size_ = {}; }; Napi::Value CreateTerminal(const Napi::CallbackInfo& info); diff --git a/src/README.md b/src/README.md index b5bb14b..a0c31e7 100644 --- a/src/README.md +++ b/src/README.md @@ -10,12 +10,16 @@ Package API (exported by `index.ts`): - `createTerminal(options: CreateTerminalOptions): GhosttyVtTerminal` [src/index.ts:createTerminal] - `getNativeInfo(): NativeInfo`: the addon's info plus `packageVersion`, `platform`, and `arch` [src/index.ts:getNativeInfo] +- `supportsMouseInput`: the constant `true`. Reading it does not load the addon [src/index.ts:supportsMouseInput] - `CreateTerminalOptions`: `{ cols: number; rows: number; scrollbackLimit?: number }` [src/types.ts:CreateTerminalOptions] - `SnapshotOptions`: `{ includeScrollback?: boolean; includeCells?: boolean }` [src/types.ts:SnapshotOptions] - `VisibleLine`: `{ row: number; text: string }` [src/types.ts:VisibleLine] - `SnapshotCell`: `{ row; col; text; width; bold?; italic?; underline?; foreground?; background? }` [src/types.ts:SnapshotCell] - `TerminalSnapshot`: `{ cols; rows; cursorRow; cursorCol; isAltScreen; visibleLines; scrollbackLines?; cells? }` [src/types.ts:TerminalSnapshot] -- `GhosttyVtTerminal`: `feed(data)`, `resize(cols, rows)`, `snapshot(options?)`, `getVisibleText()`, optional `formatPlain()` and `formatHtml()`, and `dispose()` [src/types.ts:GhosttyVtTerminal]. The object that `createTerminal` returns has both format methods [src/index.ts:Terminal]. +- `MouseAction`: `"press" | "release" | "motion"`. `MouseButton`: `"left" | "right" | "middle" | "four"` to `"eleven"` [src/types.ts:MouseButton] +- `MouseInputEvent`: `{ action; button?; x; y; modifiers?: MouseModifiers }`, and `MouseModifiers`: `{ shift?; ctrl?; alt? }` [src/types.ts:MouseInputEvent] [src/types.ts:MouseModifiers] +- `MouseEncoderOptions`: `{ geometry: MouseGeometry; anyButtonPressed?; trackLastCell? }`. `MouseGeometry`: `{ screenWidth; screenHeight; cellWidth; cellHeight; paddingTop?; paddingBottom?; paddingRight?; paddingLeft? }` [src/types.ts:MouseEncoderOptions] [src/types.ts:MouseGeometry] +- `GhosttyVtTerminal`: `feed(data)`, `resize(cols, rows)`, `encodeMouse(event, options): Buffer`, `snapshot(options?)`, `getVisibleText()`, optional `formatPlain()` and `formatHtml()`, and `dispose()` [src/types.ts:GhosttyVtTerminal]. The object that `createTerminal` returns has both format methods [src/index.ts:Terminal]. - `NativeInfo`: `{ packageVersion; napiVersion; ghosttyVersion?; ghosttyCommit?; platform; arch }` [src/types.ts:NativeInfo] Internal (not exported by the package): @@ -24,7 +28,7 @@ Internal (not exported by the package): - `loader.ts` module exports `loadNative(): NativeBindings`, `getPackageVersion(): string`, and `decorateNativeInfo(info): NativeInfo`. `index.ts` imports `loadNative` and `decorateNativeInfo` and does not re-export them [src/loader.ts:loadNative] [src/loader.ts:getPackageVersion] [src/loader.ts:decorateNativeInfo] - `NativeTerminal`: the methods that the addon's terminal object has, with `formatPlain` and `formatHtml` required [src/types.ts:NativeTerminal] - `NativeBindings`: the addon exports that the loader expects (`createTerminal`, `getNativeInfo`) [src/types.ts:NativeBindings] -- Private helpers: `assertPositiveInteger`, `assertNonNegativeInteger`, `normalizeCreateOptions`, `normalizeSnapshotOptions`, and `assertFeedData` in `index.ts`, and `assertNativeShape`, `packageJson`, `packageRoot`, and `cachedBindings` in `loader.ts` [src/index.ts:assertFeedData] [src/loader.ts:assertNativeShape] +- Private helpers: `assertPositiveInteger`, `assertNonNegativeInteger`, `normalizeCreateOptions`, `normalizeSnapshotOptions`, `assertFeedData`, `assertObject`, `assertFiniteNumber`, `optionalBoolean`, `normalizeMouseOptions`, `normalizeMouseEvent`, `assertMouseAxis`, and `assertMouseCoordinate` in `index.ts`, and `assertNativeShape`, `packageJson`, `packageRoot`, and `cachedBindings` in `loader.ts` [src/index.ts:assertFeedData] [src/loader.ts:assertNativeShape] ## Invariants @@ -33,9 +37,13 @@ Internal (not exported by the package): - `scrollbackLimit`, when it is not `undefined`, must be an integer of 0 or more. Otherwise the code throws `TypeError("scrollbackLimit must be a non-negative integer")` [src/index.ts:assertNonNegativeInteger]. - `createTerminal` options must be a non-null object. Otherwise `node:assert/strict` throws an `AssertionError` with `"options must be an object"` or `"options must not be null"`. The addon receives a new object with `cols`, `rows`, and `scrollbackLimit` (only when defined). Other properties are dropped [src/index.ts:normalizeCreateOptions]. - `snapshot()` with no options passes `{}`. Other options must be a non-null object, or an `AssertionError` says `"snapshot options must be an object"` or `"snapshot options must not be null"`. `includeScrollback` and `includeCells` become `true` only when they are strictly `=== true` [src/index.ts:normalizeSnapshotOptions]. +- `encodeMouse` checks the options, then the event. `options`, `geometry`, the event, and `modifiers` (when defined) must be non-array objects (`TypeError(" must be an object")`). The four geometry sizes must be positive integers and the paddings non-negative integers, with the `TypeError` messages of `assertPositiveInteger` and `assertNonNegativeInteger` and the name prefix `mouse geometry.`. A field above 2^32-1 throws `RangeError("mouse geometry. exceeds the 32-bit limit")` [src/index.ts:normalizeMouseOptions]. +- `encodeMouse` repeats the native padding, cell-count, and coordinate-range limits with the same three messages: `"mouse geometry padding exceeds screenWidth"`, `"mouse geometry.screenWidth exceeds the 65535-cell limit"`, and `"mouse x is outside Ghostty's cell or pixel coordinate range"` (all `RangeError`) [src/index.ts:assertMouseAxis] [src/index.ts:assertMouseCoordinate]. `src/` has no f32-range check: for `x: 1e39` it throws the coordinate-range error, where the addon throws `"mouse x must be a finite 32-bit float"`. The native layer checks every limit again, and adds the UTF-8 check, which needs the terminal's modes [native/terminal.cc:TerminalWrap::EncodeMouse]. +- `encodeMouse` event: an unknown `action` or `button` throws `TypeError("mouse action is invalid")` or `"mouse button is invalid"`, a `press` or `release` without `button` throws `TypeError(" mouse event requires a button")`, a non-finite `x` or `y` throws `TypeError("mouse x must be a finite number")`, and a non-boolean modifier, `anyButtonPressed`, or `trackLastCell` throws `TypeError(" must be a boolean")` [src/index.ts:normalizeMouseEvent] [src/index.ts:optionalBoolean]. +- The addon receives new objects: the event with `action`, `x`, `y`, `button` only when given, and `modifiers` only when given (with all three booleans), and the options with all eight geometry fields (missing paddings as 0), `anyButtonPressed`, and `trackLastCell` [src/index.ts:normalizeMouseEvent] [src/index.ts:normalizeMouseOptions]. - `feed` accepts a string or a `Uint8Array` (a `Buffer` is one). Any other value throws `TypeError("feed data must be a string, Buffer, or Uint8Array")` [src/index.ts:assertFeedData]. - The `Terminal` constructor asserts that the addon returned a non-null object (`"native terminal must be an object"`, `"native terminal must not be null"`) [src/index.ts:Terminal]. -- After `dispose()`, `feed`, `resize`, `snapshot`, `getVisibleText`, `formatPlain`, and `formatHtml` throw `Error("GhosttyVtTerminal has been disposed")`. The disposed check runs before the argument checks [src/index.ts:Terminal]. +- After `dispose()`, `feed`, `resize`, `encodeMouse`, `snapshot`, `getVisibleText`, `formatPlain`, and `formatHtml` throw `Error("GhosttyVtTerminal has been disposed")`. The disposed check runs before the argument checks [src/index.ts:Terminal]. - `dispose()` is idempotent: a second call returns without calling the addon. `#disposed` becomes `true` after the native `dispose()` returns [src/index.ts:Terminal]. - The addon export must be a non-null object with the functions `createTerminal` and `getNativeInfo`. The loader checks only these two exports, not the methods of the terminal object [src/loader.ts:assertNativeShape]. - A failed load or a bad addon shape throws an `Error` that starts with `"Failed to load @coder/libghostty-vt-node native addon."`, names `npm run build:native`, a matching prebuild, and `npm run build:libghostty` or `LIBGHOSTTY_VT_PREFIX`, and carries the original error (for a bad shape, the `AssertionError`) as `cause`. A failure is not cached, so the next call tries again [src/loader.ts:loadNative]. @@ -54,4 +62,4 @@ Internal (not exported by the package): ## Verification - Run (after `npm ci`): `npm run typecheck && npm test` -- Without a built addon, only the `createTerminal validation` suite in `test/terminal.test.ts` runs, and `test/load.test.ts` accepts the load error instead. The native suite in `test/terminal.test.ts` and all of `test/workers.test.ts` skip (`test/native-support.ts:nativeSupport`). `test/workers.test.ts` loads the addon directly, so it tests `native/`, not `src/`. To run the native suite, build the addon first: `npm run build:libghostty && npm run build:native`. +- Without a built addon, only the `createTerminal validation` suite in `test/terminal.test.ts` runs, and `test/load.test.ts` accepts the load error instead. The native suite in `test/terminal.test.ts` and all of `test/workers.test.ts` skip (`test/native-support.ts:nativeSupport`). `test/mouse-safety.test.ts` does not skip: it fails without a built addon. Its child-process cases load `src/` through `tsx`, so they need no `dist/` build. `test/workers.test.ts` loads the addon directly, so it tests `native/`, not `src/`. To run the native suite, build the addon first: `npm run build:libghostty && npm run build:native`. diff --git a/src/index.ts b/src/index.ts index 7e4d69e..6060a0f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,12 @@ import { decorateNativeInfo, loadNative } from "./loader.js"; import type { CreateTerminalOptions, GhosttyVtTerminal, + MouseAction, + MouseButton, + MouseEncoderOptions, + MouseGeometry, + MouseInputEvent, + MouseModifiers, NativeInfo, NativeTerminal, SnapshotOptions, @@ -12,6 +18,12 @@ import type { export type { CreateTerminalOptions, GhosttyVtTerminal, + MouseAction, + MouseButton, + MouseEncoderOptions, + MouseGeometry, + MouseInputEvent, + MouseModifiers, NativeInfo, SnapshotCell, SnapshotOptions, @@ -19,6 +31,9 @@ export type { VisibleLine, } from "./types.js"; +/** Import-time capability marker for consumers which must avoid native allocation. */ +export const supportsMouseInput = true; + function assertPositiveInteger(name: string, value: unknown): asserts value is number { if (!Number.isInteger(value) || (value as number) <= 0) { throw new TypeError(`${name} must be a positive integer`); @@ -66,6 +81,126 @@ function assertFeedData(data: Uint8Array | Buffer | string): void { throw new TypeError("feed data must be a string, Buffer, or Uint8Array"); } +const mouseActions = new Set(["press", "release", "motion"]); +const mouseButtons = new Set([ + "left", + "right", + "middle", + "four", + "five", + "six", + "seven", + "eight", + "nine", + "ten", + "eleven", +]); + +function assertObject(name: string, value: unknown): asserts value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new TypeError(`${name} must be an object`); + } +} + +function assertFiniteNumber(name: string, value: unknown): asserts value is number { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new TypeError(`${name} must be a finite number`); + } +} + +function optionalBoolean(name: string, value: unknown): boolean { + if (value === undefined) return false; + if (typeof value !== "boolean") throw new TypeError(`${name} must be a boolean`); + return value; +} + +// Reject ambiguous input before native allocation and retain only the fields +// that Ghostty's mouse event model can represent. +function normalizeMouseEvent(event: MouseInputEvent, geometry: MouseGeometry): MouseInputEvent { + assertObject("mouse event", event); + if (!mouseActions.has(event.action)) throw new TypeError("mouse action is invalid"); + if (event.button !== undefined && !mouseButtons.has(event.button)) { + throw new TypeError("mouse button is invalid"); + } + if (event.action !== "motion" && event.button === undefined) { + throw new TypeError(`${event.action} mouse event requires a button`); + } + assertFiniteNumber("mouse x", event.x); + assertFiniteNumber("mouse y", event.y); + assertMouseCoordinate("mouse x", event.x, geometry.paddingLeft ?? 0, geometry.cellWidth); + assertMouseCoordinate("mouse y", event.y, geometry.paddingTop ?? 0, geometry.cellHeight); + + let modifiers: MouseModifiers | undefined; + if (event.modifiers !== undefined) { + assertObject("mouse modifiers", event.modifiers); + modifiers = { + shift: optionalBoolean("mouse modifiers.shift", event.modifiers.shift), + ctrl: optionalBoolean("mouse modifiers.ctrl", event.modifiers.ctrl), + alt: optionalBoolean("mouse modifiers.alt", event.modifiers.alt), + }; + } + + return { + action: event.action, + ...(event.button === undefined ? {} : { button: event.button }), + x: event.x, + y: event.y, + ...(modifiers === undefined ? {} : { modifiers }), + }; +} + +// Ghostty stores positions as f32 before converting to u16 cells and i32 pixels. +// Check the rounded representation, including off-screen releases and drags. +function assertMouseCoordinate(name: string, value: number, padding: number, cell: number): void { + const position = Math.fround(value) - padding; + if (position < -2147483648 || position > 2147483647 || position / cell >= 65536) { + throw new RangeError(`${name} is outside Ghostty's cell or pixel coordinate range`); + } +} + +function assertMouseAxis(name: string, screen: number, cell: number, padding: number): void { + if (padding > screen) throw new RangeError(`mouse geometry padding exceeds ${name}`); + const cells = Math.fround(Math.fround(screen - padding) / Math.fround(cell)); + if (cells >= 65536) throw new RangeError(`mouse geometry.${name} exceeds the 65535-cell limit`); +} + +// Fill stable defaults while preserving explicit renderer geometry for both +// cell-based and pixel-based terminal protocols. +function normalizeMouseOptions(options: MouseEncoderOptions): MouseEncoderOptions { + assertObject("mouse encoder options", options); + assertObject("mouse geometry", options.geometry); + const geometry = options.geometry; + assertPositiveInteger("mouse geometry.screenWidth", geometry.screenWidth); + assertPositiveInteger("mouse geometry.screenHeight", geometry.screenHeight); + assertPositiveInteger("mouse geometry.cellWidth", geometry.cellWidth); + assertPositiveInteger("mouse geometry.cellHeight", geometry.cellHeight); + + const normalizedGeometry: MouseGeometry = { + screenWidth: geometry.screenWidth, + screenHeight: geometry.screenHeight, + cellWidth: geometry.cellWidth, + cellHeight: geometry.cellHeight, + }; + for (const key of ["paddingTop", "paddingBottom", "paddingRight", "paddingLeft"] as const) { + const value = geometry[key]; + if (value !== undefined) assertNonNegativeInteger(`mouse geometry.${key}`, value); + normalizedGeometry[key] = value ?? 0; + } + for (const [key, value] of Object.entries(normalizedGeometry)) { + if (value > 0xffffffff) throw new RangeError(`mouse geometry.${key} exceeds the 32-bit limit`); + } + assertMouseAxis("screenWidth", geometry.screenWidth, geometry.cellWidth, + normalizedGeometry.paddingLeft! + normalizedGeometry.paddingRight!); + assertMouseAxis("screenHeight", geometry.screenHeight, geometry.cellHeight, + normalizedGeometry.paddingTop! + normalizedGeometry.paddingBottom!); + + return { + geometry: normalizedGeometry, + anyButtonPressed: optionalBoolean("anyButtonPressed", options.anyButtonPressed), + trackLastCell: optionalBoolean("trackLastCell", options.trackLastCell), + }; +} + class Terminal implements GhosttyVtTerminal { readonly #native: NativeTerminal; #disposed = false; @@ -89,6 +224,12 @@ class Terminal implements GhosttyVtTerminal { this.#native.resize(cols, rows); } + encodeMouse(event: MouseInputEvent, options: MouseEncoderOptions): Buffer { + this.#assertUsable(); + const normalizedOptions = normalizeMouseOptions(options); + return this.#native.encodeMouse(normalizeMouseEvent(event, normalizedOptions.geometry), normalizedOptions); + } + snapshot(options?: SnapshotOptions): TerminalSnapshot { this.#assertUsable(); return this.#native.snapshot(normalizeSnapshotOptions(options)); diff --git a/src/types.ts b/src/types.ts index f203faf..a28c0aa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,6 +9,52 @@ export interface SnapshotOptions { includeCells?: boolean; } +export type MouseAction = "press" | "release" | "motion"; + +export type MouseButton = + | "left" + | "right" + | "middle" + | "four" + | "five" + | "six" + | "seven" + | "eight" + | "nine" + | "ten" + | "eleven"; + +export interface MouseModifiers { + shift?: boolean; + ctrl?: boolean; + alt?: boolean; +} + +export interface MouseInputEvent { + action: MouseAction; + button?: MouseButton; + x: number; + y: number; + modifiers?: MouseModifiers; +} + +export interface MouseGeometry { + screenWidth: number; + screenHeight: number; + cellWidth: number; + cellHeight: number; + paddingTop?: number; + paddingBottom?: number; + paddingRight?: number; + paddingLeft?: number; +} + +export interface MouseEncoderOptions { + geometry: MouseGeometry; + anyButtonPressed?: boolean; + trackLastCell?: boolean; +} + export interface VisibleLine { row: number; text: string; @@ -40,6 +86,7 @@ export interface TerminalSnapshot { export interface GhosttyVtTerminal { feed(data: Uint8Array | Buffer | string): void; resize(cols: number, rows: number): void; + encodeMouse(event: MouseInputEvent, options: MouseEncoderOptions): Buffer; snapshot(options?: SnapshotOptions): TerminalSnapshot; getVisibleText(): string; formatPlain?(): string; @@ -59,6 +106,7 @@ export interface NativeInfo { export interface NativeTerminal { feed(data: Uint8Array | Buffer | string): void; resize(cols: number, rows: number): void; + encodeMouse(event: MouseInputEvent, options: MouseEncoderOptions): Buffer; snapshot(options?: SnapshotOptions): TerminalSnapshot; getVisibleText(): string; formatPlain(): string; diff --git a/test/mouse-safety.test.ts b/test/mouse-safety.test.ts new file mode 100644 index 0000000..05d4760 --- /dev/null +++ b/test/mouse-safety.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from "vitest"; +import { spawnSync } from "node:child_process"; +import { createTerminal } from "../src/index.js"; +import { loadNative } from "../src/loader.js"; + +const geometry = { + screenWidth: 80, screenHeight: 24, cellWidth: 1, cellHeight: 1, + paddingTop: 0, paddingBottom: 0, paddingLeft: 0, paddingRight: 0, +}; + +// Child processes load src/ through tsx, as vitest does, so they need no dist/ build. +const childArgs = ["--import", "tsx", "--input-type=module", "-e"]; + +// Exercise the C++ boundary independently of the public TypeScript normalizer. +describe.each([ + ["public", createTerminal], + ["native", (options: { cols: number; rows: number }) => loadNative().createTerminal(options)], +] as const)("%s mouse safety", (_name, create) => { + it("rejects unsafe UTF-8 codepoints without terminating the process", () => { + // Run untrusted native edge cases in a child so a regression cannot abort + // the entire suite when Ghostty is built with runtime safety enabled. + const child = spawnSync(process.execPath, [...childArgs, ` + import assert from "node:assert/strict"; + import { createRequire } from "node:module"; + const require = createRequire(import.meta.url); + const { createTerminal } = ${_name === "public" + ? 'await import("./src/index.ts")' + : 'require("node-gyp-build")(process.cwd())'}; + const term = createTerminal({ cols: 80, rows: 24 }); + term.feed("\\x1b[?1000h\\x1b[?1005h"); + const options = { geometry: ${JSON.stringify({ ...geometry, screenWidth: 65535, screenHeight: 65535 })} }; + for (const [x, y] of [[55263, 0], [0, 55263], [57310, 0], [0, 65503]]) { + assert.throws(() => term.encodeMouse({ action: "press", button: "left", x, y }, options), RangeError); + } + // Clamping happens before UTF-8 conversion: a distant release on a small + // viewport is safe even when the original coordinate was problematic. + const bytes = term.encodeMouse({ action: "release", button: "left", x: 55263, y: 65503 }, + { geometry: ${JSON.stringify(geometry)} }); + assert.ok(bytes.length > 0); + term.dispose(); + `], { encoding: "utf8" }); + expect({ status: child.status, signal: child.signal, stderr: child.stderr }) + .toEqual({ status: 0, signal: null, stderr: "" }); + }); + + it("keeps handles valid when JS runs while native code reads the arguments", () => { + // Native Get() runs getters, including Object.prototype getters for keys the + // TypeScript wrapper omits. Such JS can dispose the terminal or re-enter + // encodeMouse with other geometry. Run in a child: a regression can crash. + const child = spawnSync(process.execPath, [...childArgs, ` + import assert from "node:assert/strict"; + import { createRequire } from "node:module"; + const require = createRequire(import.meta.url); + const { createTerminal } = ${_name === "public" + ? 'await import("./src/index.ts")' + : 'require("node-gyp-build")(process.cwd())'}; + const geometry = ${JSON.stringify(geometry)}; + const others = []; + for (let i = 0; i < 50; i++) { + const term = createTerminal({ cols: 80, rows: 24 }); + term.feed("\\x1b[?1000h\\x1b[?1006h"); + let reads = 0; + Object.defineProperty(Object.prototype, "modifiers", { configurable: true, get() { + // The public wrapper reads it once, native code reads it next. + if (++reads === ${_name === "public" ? 2 : 1}) { + term.dispose(); + const other = createTerminal({ cols: 80, rows: 24 }); + other.feed("\\x1b[?1000h\\x1b[?1015h"); + others.push(other); + } + return undefined; + } }); + try { + assert.throws(() => term.encodeMouse({ action: "press", button: "left", x: 3, y: 3 }, { geometry }), /disposed/); + } finally { + delete Object.prototype.modifiers; + } + } + // A nested call with other geometry must not replace the geometry that + // the outer call validated its coordinates against. + const term = createTerminal({ cols: 80, rows: 24 }); + term.feed("\\x1b[?1000h\\x1b[?1006h"); + const big = { ...geometry, screenWidth: 65535 * 65536, cellWidth: 65536 }; + const options = { geometry: big }; + Object.defineProperty(options, "anyButtonPressed", { get() { + term.encodeMouse({ action: "press", button: "left", x: 1, y: 1 }, { geometry }); + return false; + } }); + const bytes = term.encodeMouse({ action: "release", button: "left", x: 2e9, y: 3 }, options); + assert.equal(bytes.toString("latin1"), "\\x1b[<0;30518;4m"); + term.dispose(); + `], { encoding: "utf8" }); + expect({ status: child.status, signal: child.signal, stderr: child.stderr }) + .toEqual({ status: 0, signal: null, stderr: "" }); + }); + + it("rejects geometry that overflows Ghostty's grid or padding arithmetic", () => { + const term = create({ cols: 80, rows: 24 }); + try { + term.feed("\x1b[?1000h\x1b[?1006h"); + const cases = [ + [{ screenWidth: 100000 }, /screenWidth/], + [{ screenHeight: 65536 }, /screenHeight/], + [{ paddingLeft: 2 ** 32 - 1, paddingRight: 2 }, /padding/], + [{ paddingTop: 2 ** 32 - 1, paddingBottom: 2 }, /padding/], + [{ paddingLeft: 81 }, /padding/], + [{ paddingTop: 25 }, /padding/], + // f32 rounding turns this almost-valid ratio into 65536. + [{ screenWidth: 2 ** 32 - 1, cellWidth: 65536 }, /screenWidth/], + ] as const; + for (const [override, error] of cases) { + expect(() => term.encodeMouse( + { action: "press", button: "left", x: 0, y: 0 }, + { geometry: { ...geometry, ...override } }, + )).toThrow(error); + } + } finally { + term.dispose(); + } + }); + + it("rejects coordinates before Ghostty's u16 and i32 conversions", () => { + const term = create({ cols: 80, rows: 24 }); + try { + for (const mode of [1006, 1016]) { + term.feed(`\x1b[?1000h\x1b[?${mode}h`); + for (const axis of ["x", "y"] as const) { + for (const value of [1e30, 1e12, 65536, 65535.9999, -1e30, Number.MAX_VALUE]) { + expect(() => term.encodeMouse( + { action: "release", button: "left", x: 3, y: 3, [axis]: value }, + { geometry }, + )).toThrow(new RegExp(`mouse ${axis}`)); + } + } + } + } finally { + term.dispose(); + } + }); + + it("preserves representable off-screen releases, drags, padding, and maximum cells", () => { + const term = create({ cols: 80, rows: 24 }); + try { + term.feed("\x1b[?1003h\x1b[?1006h"); + const event = { action: "release", button: "left", x: 65535, y: 65535 } as const; + expect(term.encodeMouse(event, { geometry })).toEqual(Buffer.from("\x1b[<0;80;24m")); + expect(term.encodeMouse({ ...event, action: "motion" }, { + geometry, anyButtonPressed: true, + })).toEqual(Buffer.from("\x1b[<32;80;24M")); + expect(term.encodeMouse({ ...event, action: "motion" }, { geometry })).toHaveLength(0); + expect(term.encodeMouse(event, { + geometry: { ...geometry, screenWidth: 65535, screenHeight: 65535 }, + })).toEqual(Buffer.from("\x1b[<0;65535;65535m")); + term.feed("\x1b[?1016h"); + expect(term.encodeMouse({ ...event, x: -100, y: -200 }, { + geometry: { ...geometry, paddingLeft: 5, paddingTop: 3 }, + })).toEqual(Buffer.from("\x1b[<0;-105;-203m")); + } finally { + term.dispose(); + } + }); + + it("guards pixel limits even when large cells keep grid indices small", () => { + const term = create({ cols: 80, rows: 24 }); + try { + term.feed("\x1b[?1000h\x1b[?1016h"); + const options = { geometry: { ...geometry, cellWidth: 65536, cellHeight: 65536 } }; + for (const axis of ["x", "y"] as const) { + for (const value of [2147483647, 2147483648, -2147483904]) { + // Even INT32_MAX rounds up to 2**31 when passed through the f32 C API. + expect(() => term.encodeMouse({ + action: "release", button: "left", x: 0, y: 0, [axis]: value, + }, options)).toThrow(new RegExp(`mouse ${axis}`)); + } + } + expect(term.encodeMouse({ + action: "release", button: "left", x: 2147483520, y: -2147483648, + }, options)).toEqual(Buffer.from("\x1b[<0;2147483520;-2147483648m")); + } finally { + term.dispose(); + } + }); +}); diff --git a/test/terminal.test.ts b/test/terminal.test.ts index 793ba5a..cc1ffd2 100644 --- a/test/terminal.test.ts +++ b/test/terminal.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createTerminal } from "../src/index.js"; +import { createTerminal, supportsMouseInput } from "../src/index.js"; import { loadNative } from "../src/loader.js"; import type { CreateTerminalOptions } from "../src/types.js"; import { nativeSupport } from "./native-support.js"; @@ -7,7 +7,18 @@ import { nativeSupport } from "./native-support.js"; const support = nativeSupport(); const describeIfNative = support.available ? describe : describe.skip; +const unitGeometry = { + screenWidth: 80, + screenHeight: 24, + cellWidth: 1, + cellHeight: 1, +}; + describe("createTerminal validation", () => { + it("advertises mouse-input support without allocating a terminal", () => { + expect(supportsMouseInput).toBe(true); + }); + it("validates positive dimensions before loading native state", () => { expect(() => createTerminal({ cols: 0, rows: 24 })).toThrow(/cols/); expect(() => createTerminal({ cols: 80, rows: 0 })).toThrow(/rows/); @@ -52,6 +63,281 @@ describeIfNative("GhosttyVtTerminal", () => { } }); + it("encodes press, release, motion, modifiers, and wheel buttons using negotiated SGR mode", () => { + const term = createTerminal({ cols: 80, rows: 24 }); + try { + term.feed("\x1b[?1003h\x1b[?1006h"); + + const cases = [ + { + event: { action: "press", button: "left", x: 4, y: 5 } as const, + expected: "\x1b[<0;5;6M", + }, + { + event: { action: "release", button: "right", x: 4, y: 5 } as const, + expected: "\x1b[<2;5;6m", + }, + { + event: { action: "motion", x: 1, y: 2 } as const, + expected: "\x1b[<35;2;3M", + }, + { + event: { + action: "press", + button: "left", + x: 2, + y: 3, + modifiers: { shift: true, alt: true, ctrl: true }, + } as const, + expected: "\x1b[<28;3;4M", + }, + ...(["four", "five", "six", "seven"] as const).map((button, index) => ({ + event: { action: "press" as const, button, x: 0, y: 0 }, + expected: `\x1b[<${64 + index};1;1M`, + })), + ...(["eight", "nine"] as const).map((button, index) => ({ + event: { action: "press" as const, button, x: 0, y: 0 }, + expected: `\x1b[<${128 + index};1;1M`, + })), + ...(["ten", "eleven"] as const).map((button) => ({ + event: { action: "press" as const, button, x: 0, y: 0 }, + expected: "", + })), + ]; + + for (const { event, expected } of cases) { + expect(term.encodeMouse(event, { geometry: unitGeometry })).toEqual(Buffer.from(expected)); + } + } finally { + term.dispose(); + } + }); + + it("encodes the same event across every Ghostty mouse wire format", () => { + const cases = [ + { + mode: "\x1b[?9h", + expected: Buffer.from([0x1b, 0x5b, 0x4d, 0x20, 0x23, 0x24]), + }, + { + mode: "\x1b[?1000h", + expected: Buffer.from([0x1b, 0x5b, 0x4d, 0x3c, 0x23, 0x24]), + }, + { + mode: "\x1b[?1000h\x1b[?1005h", + expected: Buffer.from([0x1b, 0x5b, 0x4d, 0x3c, 0x23, 0x24]), + }, + { mode: "\x1b[?1000h\x1b[?1015h", expected: Buffer.from("\x1b[60;3;4M") }, + { mode: "\x1b[?1000h\x1b[?1006h", expected: Buffer.from("\x1b[<28;3;4M") }, + { mode: "\x1b[?1000h\x1b[?1016h", expected: Buffer.from("\x1b[<28;2;3M") }, + ]; + + for (const testCase of cases) { + const term = createTerminal({ cols: 80, rows: 24 }); + try { + term.feed(testCase.mode); + expect( + term.encodeMouse( + { + action: "press", + button: "left", + x: 2, + y: 3, + modifiers: { shift: true, alt: true, ctrl: true }, + }, + { geometry: unitGeometry }, + ), + ).toEqual(testCase.expected); + } finally { + term.dispose(); + } + } + }); + + it("refreshes mouse mode after terminal output changes state", () => { + const term = createTerminal({ cols: 80, rows: 24 }); + try { + const event = { action: "press", button: "left", x: 4, y: 5 } as const; + expect(term.encodeMouse(event, { geometry: unitGeometry })).toHaveLength(0); + + term.feed("\x1b[?1000h\x1b[?1006h"); + expect(term.encodeMouse(event, { geometry: unitGeometry })).toEqual( + Buffer.from("\x1b[<0;5;6M"), + ); + + term.feed("\x1b[?1000l"); + expect(term.encodeMouse(event, { geometry: unitGeometry })).toHaveLength(0); + } finally { + term.dispose(); + } + }); + + it("covers button tracking, legacy releases, X10 limits, and UTF-8 coordinates", () => { + const cases = [ + { mode: "\x1b[?1002h\x1b[?1006h", action: "motion", button: "left", x: 2, expected: Buffer.from("\x1b[<32;3;4M") }, + { mode: "\x1b[?1002h\x1b[?1006h", action: "motion", x: 2, expected: Buffer.alloc(0) }, + { mode: "\x1b[?1000h", action: "release", button: "left", x: 2, expected: Buffer.from("\x1b[M##$") }, + { mode: "\x1b[?1000h\x1b[?1015h", action: "release", button: "left", x: 2, expected: Buffer.from("\x1b[35;3;4M") }, + { mode: "\x1b[?9h", action: "release", button: "left", x: 2, expected: Buffer.alloc(0) }, + { mode: "\x1b[?9h", action: "press", button: "four", x: 2, expected: Buffer.alloc(0) }, + { mode: "\x1b[?1000h", action: "press", button: "left", x: 222, expected: Buffer.from([27, 91, 77, 32, 255, 36]) }, + { mode: "\x1b[?1000h", action: "press", button: "left", x: 223, expected: Buffer.alloc(0) }, + { mode: "\x1b[?1000h\x1b[?1005h", action: "press", button: "left", x: 99, expected: Buffer.from([27, 91, 77, 32, 0xc2, 0x84, 36]) }, + ] as const; + for (const { mode, expected, ...event } of cases) { + const term = createTerminal({ cols: 300, rows: 24 }); + try { + term.feed(mode); + expect(term.encodeMouse({ ...event, y: 3 }, { + geometry: { ...unitGeometry, screenWidth: 300 }, + })).toEqual(expected); + } finally { + term.dispose(); + } + } + }); + + it("applies tracking-mode, pressed-button, viewport, and motion-dedup classifiers", () => { + const cases = [ + { + name: "normal mode suppresses motion", + mode: "\x1b[?1000h\x1b[?1006h", + event: { action: "motion", button: "left", x: 2, y: 3 } as const, + options: { geometry: unitGeometry }, + expected: [""], + }, + { + name: "any mode deduplicates motion in one cell", + mode: "\x1b[?1003h\x1b[?1006h", + event: { action: "motion", x: 2, y: 3 } as const, + options: { geometry: unitGeometry, trackLastCell: true }, + expected: ["\x1b[<35;3;4M", ""], + }, + { + name: "out-of-viewport motion requires a pressed button", + mode: "\x1b[?1003h\x1b[?1006h", + event: { action: "motion", button: "left", x: 100, y: 30 } as const, + options: { geometry: unitGeometry, anyButtonPressed: false }, + expected: [""], + }, + { + name: "pressed drag outside viewport clamps to the final cell", + mode: "\x1b[?1003h\x1b[?1006h", + event: { action: "motion", button: "left", x: 100, y: 30 } as const, + options: { geometry: unitGeometry, anyButtonPressed: true }, + expected: ["\x1b[<32;80;24M"], + }, + ]; + + for (const testCase of cases) { + const term = createTerminal({ cols: 80, rows: 24 }); + try { + term.feed(testCase.mode); + const actual = testCase.expected.map(() => + term.encodeMouse(testCase.event, testCase.options), + ); + expect(actual, testCase.name).toEqual(testCase.expected.map((value) => Buffer.from(value))); + } finally { + term.dispose(); + } + } + }); + + it("keeps pixel geometry explicit when the child negotiates SGR pixels", () => { + const term = createTerminal({ cols: 80, rows: 24 }); + try { + term.feed("\x1b[?1000h\x1b[?1016h"); + const bytes = term.encodeMouse( + { action: "press", button: "left", x: 50, y: 40 }, + { + geometry: { + screenWidth: 800, + screenHeight: 600, + cellWidth: 10, + cellHeight: 20, + }, + }, + ); + expect(bytes).toEqual(Buffer.from("\x1b[<0;50;40M")); + } finally { + term.dispose(); + } + }); + + it("rejects invalid mouse events and geometry as sets", () => { + const term = createTerminal({ cols: 80, rows: 24 }); + try { + expect(typeof term.encodeMouse).toBe("function"); + const invalidCalls = [ + () => + term.encodeMouse( + { action: "click" as never, button: "left", x: 0, y: 0 }, + { geometry: unitGeometry }, + ), + () => + term.encodeMouse( + { action: "press", button: "primary" as never, x: 0, y: 0 }, + { geometry: unitGeometry }, + ), + () => term.encodeMouse({ action: "press", x: 0, y: 0 }, { geometry: unitGeometry }), + () => + term.encodeMouse( + { action: "release", x: 0, y: 0 }, + { geometry: unitGeometry }, + ), + () => + term.encodeMouse( + { action: "press", button: "left", x: Number.NaN, y: 0 }, + { geometry: unitGeometry }, + ), + () => + term.encodeMouse( + { action: "press", button: "left", x: 0, y: Number.POSITIVE_INFINITY }, + { geometry: unitGeometry }, + ), + () => + term.encodeMouse( + { + action: "press", + button: "left", + x: 0, + y: 0, + modifiers: { shift: "yes" as never }, + }, + { geometry: unitGeometry }, + ), + () => + term.encodeMouse( + { action: "press", button: "left", x: 0, y: 0 }, + { geometry: { ...unitGeometry, cellWidth: 0 } }, + ), + () => + term.encodeMouse( + { action: "press", button: "left", x: 0, y: 0 }, + { geometry: { ...unitGeometry, screenWidth: 2 ** 32 } }, + ), + () => + term.encodeMouse( + { action: "press", button: "left", x: 0, y: 0 }, + { geometry: { ...unitGeometry, paddingLeft: -1 } }, + ), + () => + term.encodeMouse( + { action: "press", button: "left", x: 0, y: 0 }, + { geometry: unitGeometry, anyButtonPressed: "yes" as never }, + ), + ]; + + const errors = [/action/, /button/, /button/, /button/, /mouse x/, /mouse y/, + /shift/, /cellWidth/, /screenWidth/, /paddingLeft/, /anyButtonPressed/]; + for (const [index, invalidCall] of invalidCalls.entries()) { + expect(invalidCall).toThrow(errors[index]); + } + } finally { + term.dispose(); + } + }); + // src/ checks only Number.isInteger and the sign, so these values reach the // addon. NaN and Infinity skip src/ when the addon is called directly. it.each<[string, CreateTerminalOptions, RegExp]>([ @@ -79,5 +365,11 @@ describeIfNative("GhosttyVtTerminal", () => { expect(() => term.dispose()).not.toThrow(); expect(() => term.feed("after")).toThrow(/disposed/); expect(() => term.snapshot()).toThrow(/disposed/); + expect(() => + term.encodeMouse( + { action: "press", button: "left", x: 0, y: 0 }, + { geometry: unitGeometry }, + ), + ).toThrow(/disposed/); }); });