From 724f98b07e9b574a492165b157651a6b4ef1053e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 29 Oct 2026 23:04:13 +0000 Subject: [PATCH 1/2] test: expect UTF-8 mouse rows above U+FFFF to encode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ghostty computes the row code point in u32, so rows 65503 and 65534 are valid 4-byte UTF-8. The binding still rejects them, so this fails. Signed-off-by: Thomas Kosiewski --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `medium`_ Change-Id: I8829cfec1c0e4c9792417c3219434367703453f1 --- test/mouse-safety.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/mouse-safety.test.ts b/test/mouse-safety.test.ts index 05d4760..86ee985 100644 --- a/test/mouse-safety.test.ts +++ b/test/mouse-safety.test.ts @@ -29,9 +29,13 @@ describe.each([ 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]]) { + for (const [x, y] of [[55263, 0], [0, 55263], [57310, 0]]) { assert.throws(() => term.encodeMouse({ action: "press", button: "left", x, y }, options), RangeError); } + for (const [y, row] of [[65503, "\\xf0\\x90\\x80\\x80"], [65534, "\\xf0\\x90\\x80\\x9f"]]) { + assert.deepEqual(term.encodeMouse({ action: "press", button: "left", x: 0, y }, options), + Buffer.from("\\x1b[M\\x20\\x21" + row, "latin1")); + } // 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 }, From b5712d7402eae04c5c33ad3f4a4f286d32dd3d4d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 29 Oct 2026 23:11:18 +0000 Subject: [PATCH 2/2] fix(native): accept UTF-8 mouse rows above U+FFFF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ghostty's row is u32, so cell.y + 33 cannot wrap. Only a surrogate reaches utf8Encode's catch unreachable, so keep that rule alone for both axes and drop the false 16-bit claim from the docs. Signed-off-by: Thomas Kosiewski --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `medium`_ Change-Id: Ib9c1ea32b54dd1646a37fc2fbf01f99a052c8bcb --- README.md | 6 +++--- native/README.md | 2 +- native/terminal.cc | 13 ++++++------- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 06b9216..79fe3c2 100644 --- a/README.md +++ b/README.md @@ -88,9 +88,9 @@ 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. +coordinates whose encoded value would be a Unicode surrogate. 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 diff --git a/native/README.md b/native/README.md index 5236fa9..49fdf20 100644 --- a/native/README.md +++ b/native/README.md @@ -46,7 +46,7 @@ C++ declarations: - `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` 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). 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]. diff --git a/native/terminal.cc b/native/terminal.cc index fe1fcd9..baa90ad 100644 --- a/native/terminal.cc +++ b/native/terminal.cc @@ -106,17 +106,16 @@ float MouseCoordinate(Napi::Env env, const Napi::Value& value, const char* name, 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) { + const char* name) { // 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. + // viewport clamping. The pinned UTF-8 encoder treats a surrogate code point + // as unreachable. 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)) { + if (codepoint >= 0xd800 && codepoint <= 0xdfff) { throw Napi::RangeError::New(env, std::string(name) + " is unsafe for Ghostty's UTF-8 mouse encoding"); } } @@ -395,9 +394,9 @@ Napi::Value TerminalWrap::EncodeMouse(const Napi::CallbackInfo& info) { // 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); + size.padding_left, size.padding_right, "mouse x"); ValidateUtf8MouseCoordinate(env, position.y, size.screen_height, size.cell_height, - size.padding_top, size.padding_bottom, "mouse y", true); + size.padding_top, size.padding_bottom, "mouse y"); } if (mouse_modes_dirty_) {