ABI-stable Node-API bindings for Ghostty's libghostty-vt terminal state engine.
This package exposes terminal VT/state semantics for Node.js consumers: feed terminal bytes, resize the terminal, read visible plain text, and extract a structured snapshot of visible rows and styled cells.
It is not a screenshot, PNG, video, browser, or GUI renderer. HTML/plain formatting, where exposed, is for debugging and export only. Raster rendering remains outside this package's scope.
This bootstrap was written against:
- Ghostty repository commit
48ccec182a932c2ec04c344d45a5fc553861cb13 - go-libghostty commit
542c76ff595ad533348e23aac74489ad095e0b36, used only as API inspiration - Ghostty VT docs: https://ghostty.org/docs/vt
- libghostty Doxygen: https://libghostty.tip.ghostty.org/
- Node-API docs: https://nodejs.org/api/n-api.html
The binding talks to libghostty-vt directly through the C API. It has no runtime dependency on go-libghostty.
import { createTerminal, getNativeInfo } from "@coder/libghostty-vt-node";
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 }));
term.dispose();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()andformatHtml() - explicit, idempotent
dispose() getNativeInfo()for package, Node-API, platform, and Ghostty build metadatasupportsMouseInput, an import-time capability marker that requires no native allocation
All dimensions are validated as positive integers. Using a terminal after dispose() throws.
snapshot() reports the cursor position (cursorRow, cursorCol) and its DECTCEM visibility (cursorVisible, toggled by ESC[?25h and ESC[?25l). With includeCells: true, each cell reports its text, width, optional foreground and background colors, and these SGR attributes: bold, faint, italic, underline, inverse, invisible, and strikethrough. An attribute is present only when it is set. inverse is the raw SGR 7 flag: foreground and background are not swapped, so consumers apply reverse video with their own default colors.
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.
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.
For local development:
npm install
npm run build:libghostty
npm run build:native
npm run build
npm test
npm run smokenpm run build:libghostty fetches the pinned Ghostty commit into vendor/ghostty and installs a static libghostty-vt into vendor/libghostty-vt. The default uses -Dsimd=false for a dependency-minimal static library. Set LIBGHOSTTY_VT_SIMD=true to build SIMD support when your build environment supports it.
You can also provide an existing install:
LIBGHOSTTY_VT_PREFIX=/path/to/libghostty-vt npm run build:nativeor:
LIBGHOSTTY_VT_INCLUDE_DIR=/path/to/include \
LIBGHOSTTY_VT_STATIC_LIB=/path/to/libghostty-vt.a \
npm run build:nativeIf libghostty-vt is unavailable, node-gyp fails early with a setup message from scripts/resolve-libghostty-vt.mjs. If the built addon cannot load, the ESM loader throws a clear error with the expected recovery commands.
The intended distribution path is prebuilt binaries bundled in npm packages via npm run build:prebuild and loaded by node-gyp-build. The script is named build:prebuild rather than prebuild so npm run build does not trigger npm's prebuild lifecycle hook.
GitHub Actions run the same mise tasks used in development. Keep build, test, and prebuild commands in mise.toml; the workflow YAML should only contain GitHub-specific orchestration such as runner matrices and artifact uploads.
To reset a workflow to the mise-generated baseline:
mise gen github-action --write --task ci --name ci
mise gen github-action --write --task release-prebuilds --name release-prebuildsIf a generated workflow is reset, reapply only the matrix/upload orchestration. Do not duplicate pipeline commands in .github/workflows/*.yml; those commands live in the ci and release-prebuilds mise tasks.
Default published prebuild targets:
- Linux x64
- Linux arm64
- macOS arm64
Windows is documented as unsupported for the initial package. Ghostty has C API support for Windows, but this package should only enable Windows after the build and prebuild path is verified.
Publishing is handled by .github/workflows/publish.yml with npm Trusted Publishing. The normal release path is a protected-main PR from a branch named release/v*:
git checkout main
git pull --ff-only origin main
git checkout -b release/v0.1.0-beta.1
npm version 0.1.0-beta.1 --no-git-tag-version
git add package.json package-lock.json
git commit -m "Release v0.1.0-beta.1"
git push origin release/v0.1.0-beta.1Open that branch as a PR into main. When the PR is merged, the publish workflow builds all prebuild artifacts from the merge commit, creates the matching Git tag, assembles the npm package layout, verifies the local platform prebuild, and publishes to npm. The workflow fails if the release branch does not match package.json exactly, such as release/v0.1.0-beta.1.
The npm dist-tag is derived from the package version:
0.1.0-beta.0publishes with--tag beta0.1.0-rc.0publishes with--tag rc0.1.0publishes with--tag latest
Manual dispatch and direct v*.*.* tag pushes are available for recovery, but merged release/v* PRs should be the default path.
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
The upstream API is still marked unstable by Ghostty. Keep the pinned commit updated intentionally and rerun the dogfood bundle after each pin change.
- No screenshot, PNG, WebM, browser, or GUI rendering API is provided.
- Structured snapshots expose visible cells and optional scrollback lines, not the full Ghostty render-state API.
- Grapheme and style extraction follows the current C API and may need adjustment when Ghostty changes ABI.
- On macOS, Zig native target discovery may require an explicit SDK/sysroot setup depending on the local Xcode/Zig combination.
The package has four layers:
- A consumer imports
dist/index.js, whichtsccompiles fromsrc/.src/index.tschecks arguments, andsrc/loader.tsloads the native addon withnode-gyp-buildon the first API call. - The addon in
native/wraps one libghostty-vtGhosttyTerminaland oneGhosttyMouseEncoderperTerminalWrapobject and exportscreateTerminalandgetNativeInfo. libghostty-vtis a static library that the addon links.scripts/build-libghostty-vt.shbuilds it from a pinned Ghostty commit, andbinding.gypfinds it withscripts/resolve-libghostty-vt.mjs(seescripts/).- The release scripts in
scripts/assemble per-platform prebuilds, tag the release, and pick the npm dist-tag.examples/holds the smoke script that the mise taskciruns last.
src: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- Node-API C++ addon (TerminalWrap, JavaScript classGhosttyVtTerminal) over the libghostty-vt C API, with per-environment class state forworker_threads.scripts:./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-smoke.ts, run bynpm run smoke.test(no contract): vitest suites for the loader, the terminal API, theencodeMouselimits, andworker_threadsloading.dogfood(no contract): a plain-text proof bundle from the repository bootstrap.
- Arguments are checked in both layers.
createTerminaloptions: [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].snapshotoptions: [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
AssertionErrorfromsrc/and aTypeErrorfrom the addon.src/sets no upper bound oncolsandrows, and the addon throws aRangeErrorabove 65535 [native/terminal.cc:PositiveUint16]. - Both layers track disposal and throw
"GhosttyVtTerminal has been disposed"afterdispose().dispose()is idempotent in both layers [src/index.ts:Terminal] [native/terminal.cc:TerminalWrap::DisposeNative]. - The loader requires the addon to export the functions
createTerminalandgetNativeInfo[src/loader.ts:assertNativeShape]. The addon sets them in [native/terminal.cc:TerminalWrap::Init] and [native/addon.cc:InitAll]. - The addon can load in the main thread and in each
worker_threadsWorker, becauseTerminalWrap::Initkeeps the class constructor in per-env instance data [native/terminal.cc:TerminalWrap::Init]. - The default Ghostty commit in [scripts/build-libghostty-vt.sh:GHOSTTY_COMMIT] is the commit that "Upstream References" names. Change both together.
- Build order:
npm ci,npm run build:libghostty,npm run build:native,npm run build,npm test,npm run smoke[mise.toml:tasks.ci].npm run build:nativefails early when the libghostty-vt header or static library is missing [scripts/resolve-libghostty-vt.mjs:fail]. - The npm package ships
dist,native,scripts,binding.gyp,prebuilds,CHANGELOG.md,README.md, andLICENSE[package.json:files]. - A release tag is
v<version>frompackage.json. WhenRELEASE_BRANCH_NAMEis set and not empty, it must berelease/v<version>[scripts/create-release-tag.mjs:releaseBranchName]. The tag must point at the checked-outHEAD[scripts/create-release-tag.mjs:releaseTargetSha]. - The npm dist-tag is the lower-cased first prerelease identifier, or
latestfor a plain version. A non-emptyNPM_PUBLISH_TAGoverrides it [scripts/resolve-npm-publish-tag.mjs:prerelease].
- Network and disk: every
npm run build:libghosttyfetches the pinned Ghostty commit, by default intovendor/ghostty, and installslibghostty-vtandghostty-source.json, by default intovendor/libghostty-vt.GHOSTTY_SOURCE_DIRandLIBGHOSTTY_VT_PREFIXchange these paths [scripts/build-libghostty-vt.sh:GHOSTTY_REPO]. - Disk:
npm run build:nativerunsnode-gyp rebuild[package.json:scripts.build:native], andnpm run buildwritesdist/[tsconfig.json:outDir].npm run build:prebuildrunsprebuildify --napi[package.json:scripts.build:prebuild].npm run assemble:prebuildsreplacesprebuilds/after its checks pass [scripts/assemble-prebuilds.mjs:targetRoot]. - Remote state: the release tag script pushes
v<version>toorigin[scripts/create-release-tag.mjs:git]. The mise taskpublishrunsnpm publish --access public --tag "$(node scripts/resolve-npm-publish-tag.mjs)"[mise.toml:tasks.publish]..github/workflows/publish.ymlalso creates or updates a GitHub Release, and.github/workflows/release-changelog.ymlcommits aCHANGELOG.mdentry to the release PR branch, pushes it, and startsci.yml. - Runtime memory: each
createTerminalcall allocates a native terminal and a mouse encoder.dispose()or garbage collection frees both [native/terminal.cc:TerminalWrap::~TerminalWrap].
- Run:
mise run ci .github/workflows/ci.ymlruns the same task. Without mise, run the steps in "Native Build" above.- UBSan build of the addon:
mise run ci-ubsan. CI runs it as thelinux-x64-ubsanrow. - Packaged-install journeys:
.agents/skills/verify-libghostty-vt-node/SKILL.md - TLA+ model of the native lifecycle:
.agents/skills/verify-native-lifecycle/SKILL.md