Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
Expand All @@ -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.
Expand Down Expand Up @@ -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`

Expand Down
15 changes: 15 additions & 0 deletions examples/smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
212 changes: 209 additions & 3 deletions native/terminal.cc
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

#include <algorithm>
#include <cassert>
#include <cmath>
#include <limits>
#include <stdexcept>

Expand Down Expand Up @@ -31,11 +32,95 @@ size_t NonNegativeSize(Napi::Env env, const Napi::Value& value, const char* name
return static_cast<size_t>(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<Napi::Number>().DoubleValue();
if (!std::isfinite(number) || number <= 0 || number != std::floor(number) ||
number > std::numeric_limits<uint32_t>::max()) {
throw Napi::RangeError::New(env, std::string(name) + " must be a positive 32-bit integer");
}
return static_cast<uint32_t>(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<Napi::Number>().DoubleValue();
if (!std::isfinite(number) || number < 0 || number != std::floor(number) ||
number > std::numeric_limits<uint32_t>::max()) {
throw Napi::RangeError::New(env, std::string(name) + " must be a non-negative 32-bit integer");
}
return static_cast<uint32_t>(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<Napi::Number>().DoubleValue();
if (!std::isfinite(number) || number < -std::numeric_limits<float>::max() ||
number > std::numeric_limits<float>::max()) {
throw Napi::RangeError::New(env, std::string(name) + " must be a finite 32-bit float");
}
return static_cast<float>(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<Napi::String>().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<Napi::String>().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<Napi::Boolean>().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<Napi::Object>();
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));
}
Expand All @@ -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),
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand Down Expand Up @@ -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();
}

Expand All @@ -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<Napi::Object>();
const Napi::Object options = info[1].As<Napi::Object>();
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<Napi::Object>();

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<uint8_t>::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<uint8_t> output(required);
size_t written = 0;
result = ghostty_mouse_encoder_encode(
encoder, event, reinterpret_cast<char*>(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<uint8_t>::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);
Expand Down
6 changes: 6 additions & 0 deletions native/terminal.hh
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ class TerminalWrap final : public Napi::ObjectWrap<TerminalWrap> {

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);
Expand All @@ -41,6 +43,10 @@ class TerminalWrap final : public Napi::ObjectWrap<TerminalWrap> {
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);
Expand Down
Loading