From f19f8e5924ce593c6704e06c4c52b579fd0de930 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 24 Sep 2026 11:05:35 +0000 Subject: [PATCH] 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 {