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: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions native/terminal.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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<Napi::Object> cells;
Napi::Array visible_lines = Napi::Array::New(env, rows);
Expand All @@ -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);

Expand Down Expand Up @@ -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);
Expand Down
16 changes: 16 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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[];
Expand Down
84 changes: 83 additions & 1 deletion test/terminal.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, SnapshotCell> {
const cells = new Map<string, SnapshotCell>();
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/);
Expand Down Expand Up @@ -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 {
Expand Down
Loading