Skip to content

Commit e9e20d5

Browse files
authored
feat: expose inverse, strikethrough, faint, invisible and cursorVisible in snapshots (#17)
Snapshots now expose the remaining SGR cell attributes and DECTCEM cursor visibility, so coder/agent-tty can render reverse video, strikethrough, dim and hidden text, and hide the cursor. - `SnapshotCell` gains optional `faint` (SGR 2), `inverse` (SGR 7), `invisible` (SGR 8) and `strikethrough` (SGR 9). Each key is present with `true` only when set. `inverse` is the raw attribute: `foreground` and `background` are not swapped. - `TerminalSnapshot` gains `cursorVisible`, read from `GHOSTTY_TERMINAL_DATA_CURSOR_VISIBLE`. The native binding always sets it. - README, `src/README.md` and `native/README.md` document the new fields. Closes #15 _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_
1 parent 7b6c56c commit e9e20d5

6 files changed

Lines changed: 115 additions & 6 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ The public contract is intentionally small:
6161

6262
All dimensions are validated as positive integers. Using a terminal after `dispose()` throws.
6363

64+
`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.
65+
6466
### Mouse encoding
6567

6668
`encodeMouse` returns a `Buffer` containing the terminal input bytes for one

‎native/README.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ JavaScript exports:
2222
- `feed(data: string | Buffer | Uint8Array)`: writes the bytes (a string as UTF-8) [native/terminal.cc:TerminalWrap::Feed]
2323
- `resize(cols, rows)`: calls `ghostty_terminal_resize(terminal, cols, rows, 0, 0)` [native/terminal.cc:TerminalWrap::Resize]
2424
- `encodeMouse(event: { action, button?, x, y, modifiers? }, options: { geometry, anyButtonPressed?, trackLastCell? })`: returns a `Buffer` with the mouse input bytes for the tracking mode and format that the terminal output selected. The buffer is empty when tracking is off or the mode drops the event. `geometry` has `screenWidth`, `screenHeight`, `cellWidth`, `cellHeight`, `paddingTop`, `paddingBottom`, `paddingRight`, and `paddingLeft`, all required here [native/terminal.cc:TerminalWrap::EncodeMouse]
25-
- `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]
26-
- 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]
25+
- `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]
26+
- 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]
2727
- `getVisibleText()`: the active-screen rows joined by `\n` [native/terminal.cc:TerminalWrap::GetVisibleText]
2828
- `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]
2929
- `dispose()`: frees the native terminal [native/terminal.cc:TerminalWrap::Dispose]
@@ -76,5 +76,5 @@ C++ declarations:
7676
- Run (after `npm ci`): `npm run build:libghostty && npm run build:native && npm test`
7777
- Every native test skips when the addon does not load (`test/native-support.ts:nativeSupport`), so check the test output for skips.
7878
- `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`).
79-
- `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, and the `encodeMouse` bytes of each mouse format. `test/mouse-safety.test.ts` runs the `encodeMouse` limits and the getter re-entry cases against both the package API and the raw addon object. It does not skip when the addon is missing. Its child-process cases load `src/` through `tsx`, so they need no `dist/` build. No vitest test covers `formatPlain`, `formatHtml`, `includeScrollback`, the cell style fields, or the typed-array errors.
79+
- `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, the `encodeMouse` bytes of each mouse format, `cursorVisible`, and the cell style fields. `test/mouse-safety.test.ts` runs the `encodeMouse` limits and the getter re-entry cases against both the package API and the raw addon object. It does not skip when the addon is missing. Its child-process cases load `src/` through `tsx`, so they need no `dist/` build. No vitest test covers `formatPlain`, `formatHtml`, `includeScrollback`, or the typed-array errors.
8080
- 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).

‎native/terminal.cc‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,9 @@ Napi::Object TerminalWrap::BuildSnapshot(Napi::Env env, bool include_scrollback,
584584
if (result != GHOSTTY_SUCCESS) throw std::runtime_error(ResultMessage("ghostty_terminal_get(cursor y)", result));
585585
result = ghostty_terminal_get(terminal_, GHOSTTY_TERMINAL_DATA_ACTIVE_SCREEN, &active_screen);
586586
if (result != GHOSTTY_SUCCESS) throw std::runtime_error(ResultMessage("ghostty_terminal_get(active screen)", result));
587+
bool cursor_visible = false;
588+
result = ghostty_terminal_get(terminal_, GHOSTTY_TERMINAL_DATA_CURSOR_VISIBLE, &cursor_visible);
589+
if (result != GHOSTTY_SUCCESS) throw std::runtime_error(ResultMessage("ghostty_terminal_get(cursor visible)", result));
587590

588591
std::vector<Napi::Object> cells;
589592
Napi::Array visible_lines = Napi::Array::New(env, rows);
@@ -600,6 +603,7 @@ Napi::Object TerminalWrap::BuildSnapshot(Napi::Env env, bool include_scrollback,
600603
snapshot.Set("rows", Napi::Number::New(env, rows));
601604
snapshot.Set("cursorRow", Napi::Number::New(env, cursor_row));
602605
snapshot.Set("cursorCol", Napi::Number::New(env, cursor_col));
606+
snapshot.Set("cursorVisible", Napi::Boolean::New(env, cursor_visible));
603607
snapshot.Set("isAltScreen", Napi::Boolean::New(env, active_screen == GHOSTTY_TERMINAL_SCREEN_ALTERNATE));
604608
snapshot.Set("visibleLines", visible_lines);
605609

@@ -746,6 +750,11 @@ Napi::Object TerminalWrap::BuildCellObject(
746750
if (style.bold) obj.Set("bold", Napi::Boolean::New(env, true));
747751
if (style.italic) obj.Set("italic", Napi::Boolean::New(env, true));
748752
if (style.underline != 0) obj.Set("underline", Napi::Boolean::New(env, true));
753+
if (style.faint) obj.Set("faint", Napi::Boolean::New(env, true));
754+
// Raw SGR 7 flag. Foreground and background are reported unswapped.
755+
if (style.inverse) obj.Set("inverse", Napi::Boolean::New(env, true));
756+
if (style.invisible) obj.Set("invisible", Napi::Boolean::New(env, true));
757+
if (style.strikethrough) obj.Set("strikethrough", Napi::Boolean::New(env, true));
749758

750759
const std::string fg = ResolveStyleColor(style.fg_color);
751760
const std::string bg = ResolveStyleColor(style.bg_color);

‎src/README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ Package API (exported by `index.ts`):
1414
- `CreateTerminalOptions`: `{ cols: number; rows: number; scrollbackLimit?: number }` [src/types.ts:CreateTerminalOptions]
1515
- `SnapshotOptions`: `{ includeScrollback?: boolean; includeCells?: boolean }` [src/types.ts:SnapshotOptions]
1616
- `VisibleLine`: `{ row: number; text: string }` [src/types.ts:VisibleLine]
17-
- `SnapshotCell`: `{ row; col; text; width; bold?; italic?; underline?; foreground?; background? }` [src/types.ts:SnapshotCell]
18-
- `TerminalSnapshot`: `{ cols; rows; cursorRow; cursorCol; isAltScreen; visibleLines; scrollbackLines?; cells? }` [src/types.ts:TerminalSnapshot]
17+
- `SnapshotCell`: `{ row; col; text; width; bold?; italic?; underline?; faint?; inverse?; invisible?; strikethrough?; foreground?; background? }` [src/types.ts:SnapshotCell]
18+
- `TerminalSnapshot`: `{ cols; rows; cursorRow; cursorCol; cursorVisible?; isAltScreen; visibleLines; scrollbackLines?; cells? }` [src/types.ts:TerminalSnapshot]
1919
- `MouseAction`: `"press" | "release" | "motion"`. `MouseButton`: `"left" | "right" | "middle" | "four"` to `"eleven"` [src/types.ts:MouseButton]
2020
- `MouseInputEvent`: `{ action; button?; x; y; modifiers?: MouseModifiers }`, and `MouseModifiers`: `{ shift?; ctrl?; alt? }` [src/types.ts:MouseInputEvent] [src/types.ts:MouseModifiers]
2121
- `MouseEncoderOptions`: `{ geometry: MouseGeometry; anyButtonPressed?; trackLastCell? }`. `MouseGeometry`: `{ screenWidth; screenHeight; cellWidth; cellHeight; paddingTop?; paddingBottom?; paddingRight?; paddingLeft? }` [src/types.ts:MouseEncoderOptions] [src/types.ts:MouseGeometry]

‎src/types.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,17 @@ export interface SnapshotCell {
6868
bold?: boolean;
6969
italic?: boolean;
7070
underline?: boolean;
71+
/** SGR 2 (dim). Omitted when not set. */
72+
faint?: boolean;
73+
/**
74+
* SGR 7 (reverse video). Omitted when not set. This is the raw attribute:
75+
* `foreground` and `background` are not swapped, so apply the swap yourself.
76+
*/
77+
inverse?: boolean;
78+
/** SGR 8 (hidden). Omitted when not set. The cell text is still reported. */
79+
invisible?: boolean;
80+
/** SGR 9 (crossed out). Omitted when not set. */
81+
strikethrough?: boolean;
7182
foreground?: string;
7283
background?: string;
7384
}
@@ -77,6 +88,11 @@ export interface TerminalSnapshot {
7788
rows: number;
7889
cursorRow: number;
7990
cursorCol: number;
91+
/**
92+
* Cursor visibility from DECTCEM (`ESC[?25h` shows, `ESC[?25l` hides).
93+
* The native binding always sets it; it is optional for compatibility.
94+
*/
95+
cursorVisible?: boolean;
8096
isAltScreen: boolean;
8197
visibleLines: VisibleLine[];
8298
scrollbackLines?: VisibleLine[];

‎test/terminal.test.ts‎

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
11
import { describe, expect, it } from "vitest";
2-
import { createTerminal, supportsMouseInput } from "../src/index.js";
2+
import { createTerminal, supportsMouseInput, type SnapshotCell, type TerminalSnapshot } from "../src/index.js";
33
import { loadNative } from "../src/loader.js";
44
import type { CreateTerminalOptions } from "../src/types.js";
55
import { nativeSupport } from "./native-support.js";
66

77
const support = nativeSupport();
88
const describeIfNative = support.available ? describe : describe.skip;
99

10+
function cellsByText(snapshot: TerminalSnapshot): Map<string, SnapshotCell> {
11+
const cells = new Map<string, SnapshotCell>();
12+
for (const cell of snapshot.cells ?? []) {
13+
expect(cells.has(cell.text), `duplicate cell text ${cell.text}`).toBe(false);
14+
cells.set(cell.text, cell);
15+
}
16+
return cells;
17+
}
18+
1019
const unitGeometry = {
1120
screenWidth: 80,
1221
screenHeight: 24,
@@ -51,6 +60,79 @@ describeIfNative("GhosttyVtTerminal", () => {
5160
}
5261
});
5362

63+
describe.each([
64+
{ name: "inverse", set: 7, reset: 27 },
65+
{ name: "strikethrough", set: 9, reset: 29 },
66+
{ name: "faint", set: 2, reset: 22 },
67+
{ name: "invisible", set: 8, reset: 28 },
68+
] as const)("SGR $set/$reset $name cell attribute", ({ name, set, reset }) => {
69+
it("reports the attribute when set and omits it after its reset or SGR 0", () => {
70+
const term = createTerminal({ cols: 80, rows: 24 });
71+
try {
72+
term.feed(`P\x1b[${set}mA\x1b[${reset}mB\x1b[${set}mC\x1b[0mD`);
73+
const cells = cellsByText(term.snapshot({ includeCells: true }));
74+
expect(cells.get("P")).not.toHaveProperty(name);
75+
expect(cells.get("A")?.[name]).toBe(true);
76+
expect(cells.get("B")).not.toHaveProperty(name);
77+
expect(cells.get("C")?.[name]).toBe(true);
78+
expect(cells.get("D")).not.toHaveProperty(name);
79+
} finally {
80+
term.dispose();
81+
}
82+
});
83+
});
84+
85+
it("reports combined SGR attributes on one cell", () => {
86+
const term = createTerminal({ cols: 80, rows: 24 });
87+
try {
88+
term.feed("\x1b[1;2;3;4;7;8;9mX\x1b[0m");
89+
const cell = cellsByText(term.snapshot({ includeCells: true })).get("X");
90+
expect(cell).toMatchObject({
91+
bold: true,
92+
faint: true,
93+
italic: true,
94+
underline: true,
95+
inverse: true,
96+
invisible: true,
97+
strikethrough: true,
98+
});
99+
} finally {
100+
term.dispose();
101+
}
102+
});
103+
104+
it("reports inverse as a raw attribute without swapping colors", () => {
105+
const term = createTerminal({ cols: 80, rows: 24 });
106+
try {
107+
term.feed("\x1b[31;42mN\x1b[7mI\x1b[0m");
108+
const cells = cellsByText(term.snapshot({ includeCells: true }));
109+
const normal = cells.get("N");
110+
const inverse = cells.get("I");
111+
expect(normal?.foreground).toMatch(/^#[0-9a-f]{6}$/i);
112+
expect(normal?.background).toMatch(/^#[0-9a-f]{6}$/i);
113+
expect(normal?.foreground).not.toBe(normal?.background);
114+
expect(inverse?.inverse).toBe(true);
115+
expect(inverse?.foreground).toBe(normal?.foreground);
116+
expect(inverse?.background).toBe(normal?.background);
117+
} finally {
118+
term.dispose();
119+
}
120+
});
121+
122+
it("reports DECTCEM cursor visibility", () => {
123+
const term = createTerminal({ cols: 80, rows: 24 });
124+
try {
125+
expect(term.snapshot().cursorVisible).toBe(true);
126+
term.feed("\x1b[?25l");
127+
expect(term.snapshot().cursorVisible).toBe(false);
128+
expect(term.snapshot({ includeCells: true }).cursorVisible).toBe(false);
129+
term.feed("\x1b[?25h");
130+
expect(term.snapshot().cursorVisible).toBe(true);
131+
} finally {
132+
term.dispose();
133+
}
134+
});
135+
54136
it("updates snapshot dimensions on resize", () => {
55137
const term = createTerminal({ cols: 80, rows: 24 });
56138
try {

0 commit comments

Comments
 (0)