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
2 changes: 0 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@

All notable user-facing changes to this package will be documented in this file.

## [Unreleased]

## [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
60 changes: 60 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,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.
Comment on lines +97 to +100

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: two things worth documenting here. First, ten and eleven are accepted but always return an empty buffer, because Ghostty's buttonCode has no mapping for them (src/input/mouse_encode.zig:225). Second, wheel buttons should only be sent as press, because legacy formats encode any release as button 3.

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.
Expand Down Expand Up @@ -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`

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
Loading