diff --git a/README.md b/README.md index 78bbf71..06b9216 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,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. + ### Mouse encoding `encodeMouse` returns a `Buffer` containing the terminal input bytes for one diff --git a/native/README.md b/native/README.md index 75864eb..5236fa9 100644 --- a/native/README.md +++ b/native/README.md @@ -22,8 +22,8 @@ JavaScript exports: - `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] - `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] -- `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] @@ -76,5 +76,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`, 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. +- `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. - 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/native/terminal.cc b/native/terminal.cc index a18f648..fe1fcd9 100644 --- a/native/terminal.cc +++ b/native/terminal.cc @@ -584,6 +584,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); @@ -600,6 +603,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); @@ -746,6 +750,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/README.md b/src/README.md index a0c31e7..5bc3498 100644 --- a/src/README.md +++ b/src/README.md @@ -14,8 +14,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] - `MouseAction`: `"press" | "release" | "motion"`. `MouseButton`: `"left" | "right" | "middle" | "four"` to `"eleven"` [src/types.ts:MouseButton] - `MouseInputEvent`: `{ action; button?; x; y; modifiers?: MouseModifiers }`, and `MouseModifiers`: `{ shift?; ctrl?; alt? }` [src/types.ts:MouseInputEvent] [src/types.ts:MouseModifiers] - `MouseEncoderOptions`: `{ geometry: MouseGeometry; anyButtonPressed?; trackLastCell? }`. `MouseGeometry`: `{ screenWidth; screenHeight; cellWidth; cellHeight; paddingTop?; paddingBottom?; paddingRight?; paddingLeft? }` [src/types.ts:MouseEncoderOptions] [src/types.ts:MouseGeometry] diff --git a/src/types.ts b/src/types.ts index a28c0aa..09e4fbf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -68,6 +68,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; } @@ -77,6 +88,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 cc1ffd2..e28215a 100644 --- a/test/terminal.test.ts +++ b/test/terminal.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createTerminal, supportsMouseInput } from "../src/index.js"; +import { createTerminal, supportsMouseInput, type SnapshotCell, type TerminalSnapshot } from "../src/index.js"; import { loadNative } from "../src/loader.js"; import type { CreateTerminalOptions } from "../src/types.js"; import { nativeSupport } from "./native-support.js"; @@ -7,6 +7,15 @@ 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; +} + const unitGeometry = { screenWidth: 80, screenHeight: 24, @@ -51,6 +60,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 {