From f19f8e5924ce593c6704e06c4c52b579fd0de930 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 24 Sep 2026 11:05:35 +0000 Subject: [PATCH 1/2] feat: expose inverse, strikethrough, faint, invisible and cursorVisible in snapshots Read the remaining SGR flags from GhosttyStyle and DECTCEM from GHOSTTY_TERMINAL_DATA_CURSOR_VISIBLE. Cell flags are omitted when unset, matching bold/italic/underline. inverse is the raw attribute; colors are not swapped. Closes #15 Change-Id: I4e10cde9ffd9e8b79171b1945692e9560664ffb2 Signed-off-by: Thomas Kosiewski --- README.md | 2 ++ native/terminal.cc | 9 +++++ src/types.ts | 16 +++++++++ test/terminal.test.ts | 84 ++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 110 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f4d779b..6a67e6b 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ The public contract is intentionally small: 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. + ## 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. diff --git a/native/terminal.cc b/native/terminal.cc index fc68458..7328d10 100644 --- a/native/terminal.cc +++ b/native/terminal.cc @@ -292,6 +292,9 @@ Napi::Object TerminalWrap::BuildSnapshot(Napi::Env env, bool include_scrollback, if (result != GHOSTTY_SUCCESS) throw std::runtime_error(ResultMessage("ghostty_terminal_get(cursor y)", result)); result = ghostty_terminal_get(terminal_, GHOSTTY_TERMINAL_DATA_ACTIVE_SCREEN, &active_screen); if (result != GHOSTTY_SUCCESS) throw std::runtime_error(ResultMessage("ghostty_terminal_get(active screen)", result)); + bool cursor_visible = false; + result = ghostty_terminal_get(terminal_, GHOSTTY_TERMINAL_DATA_CURSOR_VISIBLE, &cursor_visible); + if (result != GHOSTTY_SUCCESS) throw std::runtime_error(ResultMessage("ghostty_terminal_get(cursor visible)", result)); std::vector cells; Napi::Array visible_lines = Napi::Array::New(env, rows); @@ -308,6 +311,7 @@ Napi::Object TerminalWrap::BuildSnapshot(Napi::Env env, bool include_scrollback, snapshot.Set("rows", Napi::Number::New(env, rows)); snapshot.Set("cursorRow", Napi::Number::New(env, cursor_row)); snapshot.Set("cursorCol", Napi::Number::New(env, cursor_col)); + snapshot.Set("cursorVisible", Napi::Boolean::New(env, cursor_visible)); snapshot.Set("isAltScreen", Napi::Boolean::New(env, active_screen == GHOSTTY_TERMINAL_SCREEN_ALTERNATE)); snapshot.Set("visibleLines", visible_lines); @@ -454,6 +458,11 @@ Napi::Object TerminalWrap::BuildCellObject( if (style.bold) obj.Set("bold", Napi::Boolean::New(env, true)); if (style.italic) obj.Set("italic", Napi::Boolean::New(env, true)); if (style.underline != 0) obj.Set("underline", Napi::Boolean::New(env, true)); + if (style.faint) obj.Set("faint", Napi::Boolean::New(env, true)); + // Raw SGR 7 flag. Foreground and background are reported unswapped. + if (style.inverse) obj.Set("inverse", Napi::Boolean::New(env, true)); + if (style.invisible) obj.Set("invisible", Napi::Boolean::New(env, true)); + if (style.strikethrough) obj.Set("strikethrough", Napi::Boolean::New(env, true)); const std::string fg = ResolveStyleColor(style.fg_color); const std::string bg = ResolveStyleColor(style.bg_color); diff --git a/src/types.ts b/src/types.ts index f203faf..22a828b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,6 +22,17 @@ export interface SnapshotCell { bold?: boolean; italic?: boolean; underline?: boolean; + /** SGR 2 (dim). Omitted when not set. */ + faint?: boolean; + /** + * SGR 7 (reverse video). Omitted when not set. This is the raw attribute: + * `foreground` and `background` are not swapped, so apply the swap yourself. + */ + inverse?: boolean; + /** SGR 8 (hidden). Omitted when not set. The cell text is still reported. */ + invisible?: boolean; + /** SGR 9 (crossed out). Omitted when not set. */ + strikethrough?: boolean; foreground?: string; background?: string; } @@ -31,6 +42,11 @@ export interface TerminalSnapshot { rows: number; cursorRow: number; cursorCol: number; + /** + * Cursor visibility from DECTCEM (`ESC[?25h` shows, `ESC[?25l` hides). + * The native binding always sets it; it is optional for compatibility. + */ + cursorVisible?: boolean; isAltScreen: boolean; visibleLines: VisibleLine[]; scrollbackLines?: VisibleLine[]; diff --git a/test/terminal.test.ts b/test/terminal.test.ts index c690fa2..7ca4585 100644 --- a/test/terminal.test.ts +++ b/test/terminal.test.ts @@ -1,10 +1,19 @@ import { describe, expect, it } from "vitest"; -import { createTerminal } from "../src/index.js"; +import { createTerminal, type SnapshotCell, type TerminalSnapshot } from "../src/index.js"; import { nativeSupport } from "./native-support.js"; const support = nativeSupport(); const describeIfNative = support.available ? describe : describe.skip; +function cellsByText(snapshot: TerminalSnapshot): Map { + const cells = new Map(); + for (const cell of snapshot.cells ?? []) { + expect(cells.has(cell.text), `duplicate cell text ${cell.text}`).toBe(false); + cells.set(cell.text, cell); + } + return cells; +} + describe("createTerminal validation", () => { it("validates positive dimensions before loading native state", () => { expect(() => createTerminal({ cols: 0, rows: 24 })).toThrow(/cols/); @@ -38,6 +47,79 @@ describeIfNative("GhosttyVtTerminal", () => { } }); + describe.each([ + { name: "inverse", set: 7, reset: 27 }, + { name: "strikethrough", set: 9, reset: 29 }, + { name: "faint", set: 2, reset: 22 }, + { name: "invisible", set: 8, reset: 28 }, + ] as const)("SGR $set/$reset $name cell attribute", ({ name, set, reset }) => { + it("reports the attribute when set and omits it after its reset or SGR 0", () => { + const term = createTerminal({ cols: 80, rows: 24 }); + try { + term.feed(`P\x1b[${set}mA\x1b[${reset}mB\x1b[${set}mC\x1b[0mD`); + const cells = cellsByText(term.snapshot({ includeCells: true })); + expect(cells.get("P")).not.toHaveProperty(name); + expect(cells.get("A")?.[name]).toBe(true); + expect(cells.get("B")).not.toHaveProperty(name); + expect(cells.get("C")?.[name]).toBe(true); + expect(cells.get("D")).not.toHaveProperty(name); + } finally { + term.dispose(); + } + }); + }); + + it("reports combined SGR attributes on one cell", () => { + const term = createTerminal({ cols: 80, rows: 24 }); + try { + term.feed("\x1b[1;2;3;4;7;8;9mX\x1b[0m"); + const cell = cellsByText(term.snapshot({ includeCells: true })).get("X"); + expect(cell).toMatchObject({ + bold: true, + faint: true, + italic: true, + underline: true, + inverse: true, + invisible: true, + strikethrough: true, + }); + } finally { + term.dispose(); + } + }); + + it("reports inverse as a raw attribute without swapping colors", () => { + const term = createTerminal({ cols: 80, rows: 24 }); + try { + term.feed("\x1b[31;42mN\x1b[7mI\x1b[0m"); + const cells = cellsByText(term.snapshot({ includeCells: true })); + const normal = cells.get("N"); + const inverse = cells.get("I"); + expect(normal?.foreground).toMatch(/^#[0-9a-f]{6}$/i); + expect(normal?.background).toMatch(/^#[0-9a-f]{6}$/i); + expect(normal?.foreground).not.toBe(normal?.background); + expect(inverse?.inverse).toBe(true); + expect(inverse?.foreground).toBe(normal?.foreground); + expect(inverse?.background).toBe(normal?.background); + } finally { + term.dispose(); + } + }); + + it("reports DECTCEM cursor visibility", () => { + const term = createTerminal({ cols: 80, rows: 24 }); + try { + expect(term.snapshot().cursorVisible).toBe(true); + term.feed("\x1b[?25l"); + expect(term.snapshot().cursorVisible).toBe(false); + expect(term.snapshot({ includeCells: true }).cursorVisible).toBe(false); + term.feed("\x1b[?25h"); + expect(term.snapshot().cursorVisible).toBe(true); + } finally { + term.dispose(); + } + }); + it("updates snapshot dimensions on resize", () => { const term = createTerminal({ cols: 80, rows: 24 }); try { From 559216cccc96041760e4307bd961f29d039c7a02 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 29 Oct 2026 22:30:22 +0000 Subject: [PATCH 2/2] docs: list the new snapshot fields in the src and native contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract READMEs from #29 listed the snapshot and cell keys before this branch added cursorVisible, faint, inverse, invisible and strikethrough, and said no test covered the cell style fields. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_ Change-Id: I9f66ac8d3bf878041531e1500c53416c92f92651 Signed-off-by: Thomas Kosiewski --- native/README.md | 6 +++--- src/README.md | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/native/README.md b/native/README.md index cd522dc..44f87a4 100644 --- a/native/README.md +++ b/native/README.md @@ -21,8 +21,8 @@ JavaScript exports: - `getNativeInfo()`: returns `{ napiVersion, ghosttyVersion?, ghosttyCommit? }`. `napiVersion` comes from `napi_get_version` at run time, not from the compiled `NAPI_VERSION`. `ghosttyVersion` comes from `GHOSTTY_BUILD_INFO_VERSION_STRING` and `ghosttyCommit` from `GHOSTTY_BUILD_INFO_VERSION_BUILD` [native/terminal.cc:GetNativeInfo] - `feed(data: string | Buffer | Uint8Array)`: writes the bytes (a string as UTF-8) [native/terminal.cc:TerminalWrap::Feed] - `resize(cols, rows)`: calls `ghostty_terminal_resize(terminal, cols, rows, 0, 0)` [native/terminal.cc:TerminalWrap::Resize] -- `snapshot(options?: { includeScrollback?, includeCells? })`: returns `{ cols, rows, cursorRow, cursorCol, isAltScreen, visibleLines, scrollbackLines?, cells? }`. Each entry of `visibleLines` and `scrollbackLines` is `{ row, text }`. `isAltScreen` is true when the active screen is `GHOSTTY_TERMINAL_SCREEN_ALTERNATE` [native/terminal.cc:TerminalWrap::BuildSnapshot] -- A snapshot cell is `{ row, col, text, width }`. A styled cell can also carry `bold`, `italic`, and `underline` (each set only when true) and `foreground` / `background` as `#rrggbb` [native/terminal.cc:TerminalWrap::BuildCellObject] +- `snapshot(options?: { includeScrollback?, includeCells? })`: returns `{ cols, rows, cursorRow, cursorCol, cursorVisible, isAltScreen, visibleLines, scrollbackLines?, cells? }`. Each entry of `visibleLines` and `scrollbackLines` is `{ row, text }`. `cursorVisible` is always set, from `GHOSTTY_TERMINAL_DATA_CURSOR_VISIBLE` (DECTCEM). `isAltScreen` is true when the active screen is `GHOSTTY_TERMINAL_SCREEN_ALTERNATE` [native/terminal.cc:TerminalWrap::BuildSnapshot] +- A snapshot cell is `{ row, col, text, width }`. A styled cell can also carry `bold`, `italic`, `underline`, `faint`, `inverse`, `invisible`, and `strikethrough` (each set only when true) and `foreground` / `background` as `#rrggbb`. `inverse` is the raw SGR 7 flag: `foreground` and `background` are not swapped [native/terminal.cc:TerminalWrap::BuildCellObject] - `getVisibleText()`: the active-screen rows joined by `\n` [native/terminal.cc:TerminalWrap::GetVisibleText] - `formatPlain()` and `formatHtml()`: libghostty-vt formatter output in plain or HTML format, with `trim = true` and `unwrap = false` [native/terminal.cc:TerminalWrap::FormatPlain] [native/terminal.cc:TerminalWrap::FormatHtml] [native/terminal.cc:TerminalWrap::Format] - `dispose()`: frees the native terminal [native/terminal.cc:TerminalWrap::Dispose] @@ -68,5 +68,5 @@ C++ declarations: - Run (after `npm ci`): `npm run build:libghostty && npm run build:native && npm test` - Every native test skips when the addon does not load (`test/native-support.ts:nativeSupport`), so check the test output for skips. - `mise run ci-ubsan` builds the addon with UBSan (Linux, GCC), then runs the tests and smoke. Undefined behavior aborts the run. The task also fails when the addon's object files contain no UBSan checks, which means the flags did not reach g++ (`mise.toml:tasks.ci-ubsan`). -- `test/workers.test.ts` checks the per-environment state in child processes with `worker_threads`. `test/terminal.test.ts` covers `feed`, `snapshot`, `resize`, `dispose`, and the `RangeError` cases of the `createTerminal` checks in the addon. No vitest test covers `formatPlain`, `formatHtml`, `includeScrollback`, the cell style fields, or the typed-array errors. +- `test/workers.test.ts` checks the per-environment state in child processes with `worker_threads`. `test/terminal.test.ts` covers `feed`, `snapshot`, `resize`, `dispose`, the `RangeError` cases of the `createTerminal` checks in the addon, `cursorVisible`, and the cell style fields. No vitest test covers `formatPlain`, `formatHtml`, `includeScrollback`, or the typed-array errors. - For changes to `TerminalWrap` or `InitAll`, also run the TLA+ lifecycle model: [`.agents/skills/verify-native-lifecycle/SKILL.md`](https://github.com/coder/libghostty-vt-node/blob/main/.agents/skills/verify-native-lifecycle/SKILL.md). diff --git a/src/README.md b/src/README.md index b5bb14b..e4ea2c4 100644 --- a/src/README.md +++ b/src/README.md @@ -13,8 +13,8 @@ Package API (exported by `index.ts`): - `CreateTerminalOptions`: `{ cols: number; rows: number; scrollbackLimit?: number }` [src/types.ts:CreateTerminalOptions] - `SnapshotOptions`: `{ includeScrollback?: boolean; includeCells?: boolean }` [src/types.ts:SnapshotOptions] - `VisibleLine`: `{ row: number; text: string }` [src/types.ts:VisibleLine] -- `SnapshotCell`: `{ row; col; text; width; bold?; italic?; underline?; foreground?; background? }` [src/types.ts:SnapshotCell] -- `TerminalSnapshot`: `{ cols; rows; cursorRow; cursorCol; isAltScreen; visibleLines; scrollbackLines?; cells? }` [src/types.ts:TerminalSnapshot] +- `SnapshotCell`: `{ row; col; text; width; bold?; italic?; underline?; faint?; inverse?; invisible?; strikethrough?; foreground?; background? }` [src/types.ts:SnapshotCell] +- `TerminalSnapshot`: `{ cols; rows; cursorRow; cursorCol; cursorVisible?; isAltScreen; visibleLines; scrollbackLines?; cells? }` [src/types.ts:TerminalSnapshot] - `GhosttyVtTerminal`: `feed(data)`, `resize(cols, rows)`, `snapshot(options?)`, `getVisibleText()`, optional `formatPlain()` and `formatHtml()`, and `dispose()` [src/types.ts:GhosttyVtTerminal]. The object that `createTerminal` returns has both format methods [src/index.ts:Terminal]. - `NativeInfo`: `{ packageVersion; napiVersion; ghosttyVersion?; ghosttyCommit?; platform; arch }` [src/types.ts:NativeInfo]