From 664693af1c9e02c4626ee308745326cc671f72c3 Mon Sep 17 00:00:00 2001 From: Peter Marreck Date: Wed, 19 Aug 2026 17:40:24 -0400 Subject: [PATCH 1/4] feat: add mode-aware mouse encoding --- CHANGELOG.md | 3 + README.md | 36 ++++++ examples/smoke.ts | 15 +++ native/terminal.cc | 212 +++++++++++++++++++++++++++++++++- native/terminal.hh | 6 + src/index.ts | 113 ++++++++++++++++++ src/types.ts | 48 ++++++++ test/terminal.test.ts | 261 ++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 691 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9ed070..c194764 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable user-facing changes to this package will be documented in this file. ## [Unreleased] +## Added +- Mode-aware mouse encoding through each terminal instance. The binding reads the child-negotiated Ghostty tracking mode and wire format, accepts explicit surface geometry and pressed-button state, and returns the exact bytes to write to the child PTY. + ## [v0.1.0-beta.1](https://github.com/coder/libghostty-vt-node/releases/tag/v0.1.0-beta.1) - 2026-04-24 ## Added diff --git a/README.md b/README.md index f4d779b..6bb28e5 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,32 @@ 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 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. + +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. + ## 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 +165,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` 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/terminal.cc b/native/terminal.cc index fc68458..c1a3a21 100644 --- a/native/terminal.cc +++ b/native/terminal.cc @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -31,11 +32,95 @@ size_t NonNegativeSize(Napi::Env env, const Napi::Value& value, const char* name return static_cast(number); } +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); +} + +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)); } @@ -51,6 +136,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), @@ -94,14 +180,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) { @@ -111,6 +212,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); @@ -147,6 +255,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(); } @@ -167,6 +276,103 @@ 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. +Napi::Value TerminalWrap::EncodeMouse(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + GhosttyTerminal terminal = RequireTerminal(env); + GhosttyMouseEncoder encoder = RequireMouseEncoder(env); + if (env.IsExceptionPending()) return env.Undefined(); + 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"); + + 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; + } + const bool any_button_pressed = OptionBool(options, "anyButtonPressed"); + ghostty_mouse_encoder_setopt( + encoder, GHOSTTY_MOUSE_ENCODER_OPT_ANY_BUTTON_PRESSED, &any_button_pressed); + const bool track_last_cell = OptionBool(options, "trackLastCell"); + 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, ParseMouseAction(env, input.Get("action"))); + const Napi::Value button = input.Get("button"); + if (button.IsUndefined()) { + ghostty_mouse_event_clear_button(event); + } else { + ghostty_mouse_event_set_button(event, ParseMouseButton(env, button)); + } + ghostty_mouse_event_set_mods(event, ParseMouseModifiers(env, input.Get("modifiers"))); + ghostty_mouse_event_set_position( + event, + GhosttyMousePosition{ + FiniteFloat(env, input.Get("x"), "mouse x"), + FiniteFloat(env, input.Get("y"), "mouse y"), + }); + + 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 bb74a3f..650ea1a 100644 --- a/native/terminal.hh +++ b/native/terminal.hh @@ -24,8 +24,10 @@ class TerminalWrap final : public Napi::ObjectWrap { 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); @@ -41,6 +43,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/index.ts b/src/index.ts index 7e4d69e..3018729 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, @@ -66,6 +78,102 @@ 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): 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); + + 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 }), + }; +} + +// 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; + } + + return { + geometry: normalizedGeometry, + anyButtonPressed: optionalBoolean("anyButtonPressed", options.anyButtonPressed), + trackLastCell: optionalBoolean("trackLastCell", options.trackLastCell), + }; +} + class Terminal implements GhosttyVtTerminal { readonly #native: NativeTerminal; #disposed = false; @@ -89,6 +197,11 @@ class Terminal implements GhosttyVtTerminal { this.#native.resize(cols, rows); } + encodeMouse(event: MouseInputEvent, options: MouseEncoderOptions): Buffer { + this.#assertUsable(); + return this.#native.encodeMouse(normalizeMouseEvent(event), normalizeMouseOptions(options)); + } + 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/terminal.test.ts b/test/terminal.test.ts index c690fa2..a1c387b 100644 --- a/test/terminal.test.ts +++ b/test/terminal.test.ts @@ -5,6 +5,13 @@ 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("validates positive dimensions before loading native state", () => { expect(() => createTerminal({ cols: 0, rows: 24 })).toThrow(/cols/); @@ -50,11 +57,265 @@ 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("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 }, + ), + ]; + + for (const invalidCall of invalidCalls) { + expect(invalidCall).toThrow(); + } + } finally { + term.dispose(); + } + }); + it("makes dispose idempotent and rejects use after dispose", () => { const term = createTerminal({ cols: 80, rows: 24 }); term.dispose(); 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/); }); }); From fae62e215ab4065022b8de0197be05fed0bab63f Mon Sep 17 00:00:00 2001 From: Peter Marreck Date: Wed, 19 Aug 2026 18:20:37 -0400 Subject: [PATCH 2/4] feat: advertise mouse input capability --- src/index.ts | 3 +++ test/terminal.test.ts | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 3018729..9c2fa92 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,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`); diff --git a/test/terminal.test.ts b/test/terminal.test.ts index a1c387b..a1561f1 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 { nativeSupport } from "./native-support.js"; const support = nativeSupport(); @@ -13,6 +13,10 @@ const unitGeometry = { }; 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/); From a5897f9ecb7abf47625f604dc791f5f55130ee06 Mon Sep 17 00:00:00 2001 From: Peter Marreck Date: Sun, 27 Sep 2026 16:13:49 -0400 Subject: [PATCH 3/4] fix: reject unsafe mouse geometry and coordinates --- CHANGELOG.md | 5 -- README.md | 19 +++++++ native/terminal.cc | 42 +++++++++++++--- src/index.ts | 29 ++++++++++- test/mouse-safety.test.ts | 101 ++++++++++++++++++++++++++++++++++++++ test/terminal.test.ts | 31 +++++++++++- 6 files changed, 212 insertions(+), 15 deletions(-) create mode 100644 test/mouse-safety.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c194764..f18bd2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,6 @@ All notable user-facing changes to this package will be documented in this file. -## [Unreleased] - -## Added -- Mode-aware mouse encoding through each terminal instance. The binding reads the child-negotiated Ghostty tracking mode and wire format, accepts explicit surface geometry and pressed-button state, and returns the exact bytes to write to the child PTY. - ## [v0.1.0-beta.1](https://github.com/coder/libghostty-vt-node/releases/tag/v0.1.0-beta.1) - 2026-04-24 ## Added diff --git a/README.md b/README.md index 6bb28e5..2353dc3 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ The public contract is intentionally small: - 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. @@ -74,10 +75,28 @@ 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. + +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 diff --git a/native/terminal.cc b/native/terminal.cc index c1a3a21..4ba2e0b 100644 --- a/native/terminal.cc +++ b/native/terminal.cc @@ -68,6 +68,33 @@ float FiniteFloat(Napi::Env env, const Napi::Value& value, const char* name) { 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; +} + 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(); @@ -305,6 +332,14 @@ Napi::Value TerminalWrap::EncodeMouse(const Napi::CallbackInfo& info) { 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), + }; if (mouse_modes_dirty_) { ghostty_mouse_encoder_setopt_from_terminal(encoder, terminal); @@ -336,12 +371,7 @@ Napi::Value TerminalWrap::EncodeMouse(const Napi::CallbackInfo& info) { ghostty_mouse_event_set_button(event, ParseMouseButton(env, button)); } ghostty_mouse_event_set_mods(event, ParseMouseModifiers(env, input.Get("modifiers"))); - ghostty_mouse_event_set_position( - event, - GhosttyMousePosition{ - FiniteFloat(env, input.Get("x"), "mouse x"), - FiniteFloat(env, input.Get("y"), "mouse y"), - }); + ghostty_mouse_event_set_position(event, position); size_t required = 0; result = ghostty_mouse_encoder_encode(encoder, event, nullptr, 0, &required); diff --git a/src/index.ts b/src/index.ts index 9c2fa92..6060a0f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -116,7 +116,7 @@ function optionalBoolean(name: string, value: unknown): boolean { // Reject ambiguous input before native allocation and retain only the fields // that Ghostty's mouse event model can represent. -function normalizeMouseEvent(event: MouseInputEvent): MouseInputEvent { +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)) { @@ -127,6 +127,8 @@ function normalizeMouseEvent(event: MouseInputEvent): MouseInputEvent { } 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) { @@ -147,6 +149,21 @@ function normalizeMouseEvent(event: MouseInputEvent): MouseInputEvent { }; } +// 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 { @@ -169,6 +186,13 @@ function normalizeMouseOptions(options: MouseEncoderOptions): MouseEncoderOption 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, @@ -202,7 +226,8 @@ class Terminal implements GhosttyVtTerminal { encodeMouse(event: MouseInputEvent, options: MouseEncoderOptions): Buffer { this.#assertUsable(); - return this.#native.encodeMouse(normalizeMouseEvent(event), normalizeMouseOptions(options)); + const normalizedOptions = normalizeMouseOptions(options); + return this.#native.encodeMouse(normalizeMouseEvent(event, normalizedOptions.geometry), normalizedOptions); } snapshot(options?: SnapshotOptions): TerminalSnapshot { diff --git a/test/mouse-safety.test.ts b/test/mouse-safety.test.ts new file mode 100644 index 0000000..ba5f721 --- /dev/null +++ b/test/mouse-safety.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +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, +}; + +// 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 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 a1561f1..8baa008 100644 --- a/test/terminal.test.ts +++ b/test/terminal.test.ts @@ -170,6 +170,31 @@ describeIfNative("GhosttyVtTerminal", () => { } }); + 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 = [ { @@ -301,8 +326,10 @@ describeIfNative("GhosttyVtTerminal", () => { ), ]; - for (const invalidCall of invalidCalls) { - expect(invalidCall).toThrow(); + 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(); From 8318d03cc4c5d417d988548f920a784abfde33af Mon Sep 17 00:00:00 2001 From: Peter Marreck Date: Sun, 27 Sep 2026 16:37:36 -0400 Subject: [PATCH 4/4] fix: guard unsafe UTF-8 mouse codepoints --- README.md | 5 +++++ native/terminal.cc | 31 +++++++++++++++++++++++++++++++ test/mouse-safety.test.ts | 28 ++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+) diff --git a/README.md b/README.md index 2353dc3..3d94e00 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,11 @@ to 32-bit floats by the C API; after padding is removed they must fit signed 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. diff --git a/native/terminal.cc b/native/terminal.cc index 4ba2e0b..8436699 100644 --- a/native/terminal.cc +++ b/native/terminal.cc @@ -95,6 +95,23 @@ float MouseCoordinate(Napi::Env env, const Napi::Value& value, const char* name, 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(); @@ -341,6 +358,20 @@ Napi::Value TerminalWrap::EncodeMouse(const Napi::CallbackInfo& info) { MouseCoordinate(env, input.Get("y"), "mouse y", size.padding_top, size.cell_height), }; + 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; diff --git a/test/mouse-safety.test.ts b/test/mouse-safety.test.ts index ba5f721..6078945 100644 --- a/test/mouse-safety.test.ts +++ b/test/mouse-safety.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { spawnSync } from "node:child_process"; import { createTerminal } from "../src/index.js"; import { loadNative } from "../src/loader.js"; @@ -12,6 +13,33 @@ 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, ["--input-type=module", "-e", ` + import assert from "node:assert/strict"; + import { createRequire } from "node:module"; + const require = createRequire(import.meta.url); + const { createTerminal } = ${_name === "public" + ? 'await import("./dist/index.js")' + : '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("rejects geometry that overflows Ghostty's grid or padding arithmetic", () => { const term = create({ cols: 80, rows: 24 }); try {