Skip to content

feat: expose inverse, strikethrough, faint, invisible and cursorVisible in snapshots - #17

Merged
ThomasK33 merged 4 commits into
mainfrom
feat/snapshot-sgr-attrs-cursor-visible
Oct 29, 2026
Merged

ThomasK33 merged 4 commits into
mainfrom
feat/snapshot-sgr-attrs-cursor-visible

Conversation

@ThomasK33

Copy link
Copy Markdown
Member

Closes #15

Summary

Snapshots now expose the remaining SGR cell attributes and DECTCEM cursor visibility, so coder/agent-tty (coder/agent-tty#171) 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).
    • Same convention as bold/italic/underline: the key is present with true only when set, and omitted otherwise.
    • inverse is the raw attribute. foreground/background are not swapped.
  • TerminalSnapshot gains cursorVisible?: boolean. The native binding always sets it (true/false); the type is optional for compatibility.
  • Values come from libghostty-vt's C API at the pinned commit: GhosttyStyle.{faint,inverse,invisible,strikethrough} via the existing ghostty_grid_ref_style call, and ghostty_terminal_get(GHOSTTY_TERMINAL_DATA_CURSOR_VISIBLE). No new libghostty entry points.
  • README documents the snapshot fields. CHANGELOG.md is not hand-edited: the release-changelog workflow generates it with Communique on release/v* PRs.

Existing fields are unchanged.

Tests (written first, red before the native change)

  • Per attribute (inverse 7/27, strikethrough 9/29, faint 2/22, invisible 8/28): unstyled cell, set, attribute-specific reset, set again, SGR 0 reset.
  • All attributes combined on one cell (ESC[1;2;3;4;7;8;9m).
  • Inverse keeps the unswapped ESC[31;42m colors.
  • cursorVisible: default true, ESC[?25l gives false (with and without includeCells), ESC[?25h gives true.

Validation

Gate Result
npm run build:libghostty (pinned Ghostty 48ccec18, Zig 0.15.2) pass
npm run build:native pass, no warnings
npm run build pass
npm run typecheck (and an ad hoc tsc run including test/) pass
npm test 13/13 pass (7 new)
npm run smoke pass

The repo has no lint script.

Dogfood

A standalone Node ESM script imports @coder/libghostty-vt-node (linked to this branch's build), feeds ESC[?25l, then ESC[31;42;7mI ESC[27mi ESC[0m ESC[9mS ESC[29ms ESC[2mF ESC[22mf ESC[8mH ESC[28mh ESC[1;2;7;8;9mX ESC[0mx, then ESC[?25h, and prints snapshot({ includeCells: true }):

Script
import { createTerminal, getNativeInfo } from "@coder/libghostty-vt-node";

const term = createTerminal({ cols: 40, rows: 4 });
const steps = [];
const snap = (label) => {
  const s = term.snapshot({ includeCells: true });
  steps.push({ label, cursorVisible: s.cursorVisible, cursorRow: s.cursorRow, cursorCol: s.cursorCol });
  return s;
};
snap("initial");
term.feed("\x1b[?25l");
snap("after ESC[?25l");
term.feed("\x1b[31;42;7mI\x1b[27mi\x1b[0m \x1b[9mS\x1b[29ms \x1b[2mF\x1b[22mf \x1b[8mH\x1b[28mh \x1b[1;2;7;8;9mX\x1b[0mx");
term.feed("\x1b[?25h");
const s = snap("after ESC[?25h");
console.log(JSON.stringify({ ghostty: getNativeInfo().ghosttyCommit, cursorSteps: steps, line0: s.visibleLines[0].text, cells: s.cells }, null, 1));
term.dispose();

Output (one cell per line):

{"cursorSteps":[{"label":"initial","cursorVisible":true,"cursorRow":0,"cursorCol":0},{"label":"after ESC[?25l","cursorVisible":false,"cursorRow":0,"cursorCol":0},{"label":"after ESC[?25h","cursorVisible":true,"cursorRow":0,"cursorCol":14}],"line0":"Ii Ss Ff Hh Xx"}
{"row":0,"col":0,"text":"I","width":1,"inverse":true,"foreground":"#cc6666","background":"#b5bd68"}
{"row":0,"col":1,"text":"i","width":1,"foreground":"#cc6666","background":"#b5bd68"}
{"row":0,"col":2,"text":" ","width":1}
{"row":0,"col":3,"text":"S","width":1,"strikethrough":true}
{"row":0,"col":4,"text":"s","width":1}
{"row":0,"col":5,"text":" ","width":1}
{"row":0,"col":6,"text":"F","width":1,"faint":true}
{"row":0,"col":7,"text":"f","width":1}
{"row":0,"col":8,"text":" ","width":1}
{"row":0,"col":9,"text":"H","width":1,"invisible":true}
{"row":0,"col":10,"text":"h","width":1}
{"row":0,"col":11,"text":" ","width":1}
{"row":0,"col":12,"text":"X","width":1,"bold":true,"faint":true,"inverse":true,"invisible":true,"strikethrough":true}
{"row":0,"col":13,"text":"x","width":1}

The uppercase cells carry the attribute, and the lowercase cells after each reset do not. I keeps #cc6666 on #b5bd68 (palette 1 on palette 2), the same as unswapped i.

Notes for agent-tty

  • Apply reverse video by swapping foreground/background yourself, substituting your default colors when either one is absent.
  • invisible cells still report their text, and visibleLines/getVisibleText() still include hidden text (unchanged). Skip drawing glyphs for invisible cells.
  • Unchanged limitation: cells only contains cells with text. Erased cells that have only a background color (for example, an ESC[K status bar fill) are not emitted.

Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high

…le 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 <tk@coder.com>
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review
@codex security review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-29T22:42:22.845502Z 675151e Manual request
🔒 Security Review ✅ Completed 2026-10-29T22:42:44.137531Z 675151e Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Another round soon, please!

Reviewed commit: f19f8e5924

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: f19f8e5924

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

Change-Id: Ie65e53e94e4f4aa09e80eef7db8146c9e6db4588
Signed-off-by: Thomas Kosiewski <tk@coder.com>
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 232796f289

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread README.md
@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: 232796f289

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

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 <tk@coder.com>
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: 559216cccc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: 559216cccc

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@ThomasK33

Copy link
Copy Markdown
Member Author

PASS+NOTES

I verified the behavior at 232796f289cbde3287e0555a71d653cc0de48aa2 against the parent (main) 96fb41bcd07bac39bcfe2d4b87d2a62065c697db. The current head 559216cccc96041760e4307bd961f29d039c7a02 differs from 232796f289cbde3287e0555a71d653cc0de48aa2 only in native/README.md and src/README.md (git diff --stat reports 2 files changed, 5 insertions(+), 5 deletions(-)). Both are docs, so the behavior result carries over to the current head.

I packed each commit with the repo's verify-libghostty-vt-node skill. It runs npm pack and installs the tarball into a scratch consumer project. doctor.sh exited 0 at both commits, so each consumer loaded its own packed prebuild. The journeys import @coder/libghostty-vt-node by name and assert with node:assert/strict. I ran the same journey files at both commits on Node v26.12.1, linux-x64.

Claim Parent 96fb41b Head 232796f
1. SGR 2/7/8/9 set faint / inverse / invisible / strikethrough to true. The key is absent otherwise. SGR 22/27/28/29 and SGR 0 clear them. 1/12 checks pass (only "plain cell has none of the new keys") 12/12
2. inverse is raw. ESC[31;42;7m keeps #cc6666 / #b5bd68, the same colors as ESC[31;42m. 1/3 (only "SGR 7 with default colors adds no foreground/background") 3/3
3. cursorVisible is true by default. ESC[?25l gives false with and without includeCells. ESC[?25h gives true. 0/2 (key absent) 2/2
4. Existing fields are unchanged, and the parent has none of the new keys. 0 parent snapshot views carry a new key After I remove the new keys, head equals parent across 10 inputs x 2 snapshot views plus getVisibleText()
5. src/types.ts exposes the new fields. A consumer .ts file typechecks against the packed .d.ts (tsc 5.9.3, strict, exactOptionalPropertyTypes). tsc exit 2, 10 × TS2339 tsc exit 0, each new field is boolean | undefined

Journey exit codes: claims parent 1 / head 0, edges parent 1 / head 0, types-node parent 1 / head 0, observe parent 0 / head 0.

Edge cases I tried

At the head 8/8 asserted edge checks pass. At the parent 0/8 pass.

  • Wide characters with ESC[7;9m世界 keep width 2 and both flags. The spacer cell is not listed.
  • A colon-subparameter RGB color plus SGR 7 in one sequence (ESC[38:2::1:2:3;7m) gives #010203 and inverse.
  • SGR and DECTCEM sequences split across feed() calls still apply.
  • Hiding the cursor inside the alternate screen (the vim pattern) gives cursorVisible: false with isAltScreen: true.
  • RIS (ESC c) sets cursorVisible back to true and drops the flags.
  • resize() keeps cursorVisible: false and the cell flags.
  • The flags move with their row when the screen scrolls.
  • A snapshot taken inside a worker_threads Worker carries cursorVisible and all four flags.

Notes

Neither note blocks the merge.

  1. DECSTR (ESC[!p, soft reset) does not show the cursor or clear SGR attributes. After ESC[?25l ESC[2;7m ESC[!p, the head reports cursorVisible: false with faint and inverse still set. The pinned Ghostty (48ccec1) does not implement CSI ! p. Its src/terminal/stream.zig logs "ignoring unimplemented CSI p with intermediates". The PR reports Ghostty's state as is, so this is upstream behavior. A program that re-shows the cursor with DECSTR instead of ESC[?25h leaves cursorVisible: false in the snapshot.
  2. Leaving the alternate screen (ESC[?1049l) keeps the cursor hidden until ESC[?25h. This matches xterm, where DECTCEM is not saved per screen.

Docs at 559216c

The new README statements match what I observed.

  • native/README.md says snapshot() always sets cursorVisible. The head set it on all 20 snapshot views in my corpus.
  • native/README.md says a cell carries the seven attributes only when true, and that inverse does not swap foreground and background. Claims 1 and 2 show this.
  • native/README.md now says test/terminal.test.ts covers cursorVisible and the cell style fields. The test file does, and it is unchanged between 232796f and 559216c. No file in test/ mentions includeScrollback, formatPlain, formatHtml, or Uint8Array, which matches the remaining "no vitest test covers" list.
  • src/README.md lists faint?, inverse?, invisible?, strikethrough?, and cursorVisible?. That matches the packed .d.ts that tsc accepted in claim 5.
Claims journey (run with drive.sh RUN_ROOT claims)
import assert from "node:assert/strict";
import { createTerminal } from "@coder/libghostty-vt-node";

const NEW_KEYS = ["faint", "inverse", "invisible", "strikethrough"];
const results = [];
function check(id, fn) {
  try {
    const seen = fn();
    results.push({ id, ok: true, seen });
  } catch (err) {
    results.push({ id, ok: false, error: err.message.split("\n").slice(0, 6).join(" | ") });
  }
}
function cellsOf(input, cols = 20) {
  const t = createTerminal({ cols, rows: 2 });
  t.feed(input);
  const cells = t.snapshot({ includeCells: true }).cells;
  t.dispose();
  return cells;
}
function newKeys(cell) {
  return Object.fromEntries(NEW_KEYS.filter((k) => k in cell).map((k) => [k, cell[k]]));
}

for (const [sgr, key] of [[2, "faint"], [7, "inverse"], [8, "invisible"], [9, "strikethrough"]]) {
  check(`C1 SGR ${sgr} sets ${key}: true and no other new key`, () => {
    const [c] = cellsOf(`\x1b[${sgr}mX`);
    assert.deepEqual(newKeys(c), { [key]: true });
    assert.equal(c[key], true);
    return c;
  });
}
check("C1 plain cell has none of the new keys", () => {
  const [c] = cellsOf("X");
  assert.deepEqual(c, { row: 0, col: 0, text: "X", width: 1 });
  return c;
});
for (const [on, off, key] of [[2, 22, "faint"], [7, 27, "inverse"], [8, 28, "invisible"], [9, 29, "strikethrough"]]) {
  check(`C1 SGR ${off} clears ${key}`, () => {
    const [a, b] = cellsOf(`\x1b[${on}mA\x1b[${off}mB`);
    assert.deepEqual(newKeys(a), { [key]: true });
    assert.deepEqual(newKeys(b), {});
    return [a, b];
  });
}
check("C1 SGR 0 clears all four (set together in one SGR)", () => {
  const [a, b] = cellsOf("\x1b[2;7;8;9mA\x1b[0mB");
  assert.deepEqual(newKeys(a), { faint: true, inverse: true, invisible: true, strikethrough: true });
  assert.deepEqual(b, { row: 0, col: 1, text: "B", width: 1 });
  return [a, b];
});
check("C1 SGR 22 clears bold and faint together", () => {
  const [a, b] = cellsOf("\x1b[1;2mA\x1b[22mB");
  assert.equal(a.bold, true);
  assert.equal(a.faint, true);
  assert.deepEqual(b, { row: 0, col: 1, text: "B", width: 1 });
  return [a, b];
});
check("C1 SGR 8 hidden cell still reports its text", () => {
  const t = createTerminal({ cols: 20, rows: 1 });
  t.feed("\x1b[8mpw\x1b[0m!");
  const s = t.snapshot({ includeCells: true });
  t.dispose();
  assert.deepEqual(s.cells.map((c) => [c.text, c.invisible === true]), [["p", true], ["w", true], ["!", false]]);
  assert.equal(s.visibleLines[0].text, "pw!");
  return { cells: s.cells, line: s.visibleLines[0] };
});

check("C2 ESC[31;42;7m keeps fg palette 1 and bg palette 2 (same as without 7)", () => {
  const [inv] = cellsOf("\x1b[31;42;7mA");
  const [plain] = cellsOf("\x1b[31;42mA");
  assert.equal(inv.inverse, true);
  assert.equal(inv.foreground, plain.foreground);
  assert.equal(inv.background, plain.background);
  assert.deepEqual([inv.foreground, inv.background], ["#cc6666", "#b5bd68"]);
  return { inv, plain };
});
check("C2 SGR 7 with default colors adds no foreground/background", () => {
  const [c] = cellsOf("\x1b[7mA");
  assert.ok(!("foreground" in c) && !("background" in c));
  return c;
});
check("C2 SGR 7 with RGB fg/bg keeps them unswapped", () => {
  const [c] = cellsOf("\x1b[38;2;1;2;3;48;2;4;5;6;7mA");
  assert.deepEqual([c.foreground, c.background, c.inverse], ["#010203", "#040506", true]);
  return c;
});

check("C3 cursorVisible defaults to true (plain and includeCells)", () => {
  const t = createTerminal({ cols: 10, rows: 2 });
  const a = t.snapshot().cursorVisible;
  const b = t.snapshot({ includeCells: true }).cursorVisible;
  t.dispose();
  assert.equal(a, true);
  assert.equal(b, true);
  return [a, b];
});
check("C3 ESC[?25l gives false (plain and includeCells), ESC[?25h gives true", () => {
  const t = createTerminal({ cols: 10, rows: 2 });
  t.feed("\x1b[?25l");
  const hidden = [t.snapshot().cursorVisible, t.snapshot({ includeCells: true }).cursorVisible,
    t.snapshot({ includeScrollback: true, includeCells: true }).cursorVisible];
  t.feed("\x1b[?25h");
  const shown = [t.snapshot().cursorVisible, t.snapshot({ includeCells: true }).cursorVisible];
  t.dispose();
  assert.deepEqual(hidden, [false, false, false]);
  assert.deepEqual(shown, [true, true]);
  return { hidden, shown };
});

for (const r of results) console.log(JSON.stringify(r));
const failed = results.filter((r) => !r.ok).length;
console.log(`SUMMARY ${results.length - failed}/${results.length} checks passed`);
process.exitCode = failed === 0 ? 0 : 1;
Edge journey (run with drive.sh RUN_ROOT edges)
import assert from "node:assert/strict";
import { Worker, isMainThread, parentPort } from "node:worker_threads";
import { createTerminal } from "@coder/libghostty-vt-node";

const ESC = "\x1b";
const NEW_KEYS = ["faint", "inverse", "invisible", "strikethrough"];
const pick = (c) => Object.fromEntries(Object.entries(c).filter(([k]) => !["row", "col"].includes(k)));

function workerBody() {
  const t = createTerminal({ cols: 10, rows: 2 });
  t.feed(`${ESC}[?25l${ESC}[2;7;8;9mW`);
  const s = t.snapshot({ includeCells: true });
  t.dispose();
  return { cursorVisible: s.cursorVisible, cell: s.cells[0] };
}
if (!isMainThread) {
  parentPort.postMessage(workerBody());
} else {
  const results = [];
  const run = (id, fn, kind = "assert") => {
    try { results.push({ id, kind, ok: true, seen: fn() }); }
    catch (err) { results.push({ id, kind, ok: false, error: err.message.split("\n").slice(0, 6).join(" | ") }); }
  };

  run("E1 wide chars with SGR 7;9 keep width 2 and both flags; spacer cell not listed", () => {
    const t = createTerminal({ cols: 10, rows: 1 });
    t.feed(`${ESC}[7;9m世界${ESC}[0mx`);
    const cells = t.snapshot({ includeCells: true }).cells;
    t.dispose();
    assert.deepEqual(cells.map((c) => [c.col, c.text, c.width, c.inverse === true, c.strikethrough === true]),
      [[0, "世", 2, true, true], [2, "界", 2, true, true], [4, "x", 1, false, false]]);
    return cells.map(pick);
  });
  run("E2 colon-subparam RGB fg plus SGR 7 in one sequence", () => {
    const t = createTerminal({ cols: 10, rows: 1 });
    t.feed(`${ESC}[38:2::1:2:3;7mC`);
    const [c] = t.snapshot({ includeCells: true }).cells;
    t.dispose();
    assert.deepEqual([c.foreground, c.inverse, "background" in c], ["#010203", true, false]);
    return pick(c);
  });
  run("E3 SGR and DECTCEM split across feed() calls", () => {
    const t = createTerminal({ cols: 10, rows: 1 });
    for (const chunk of [ESC, "[", "2;", "9", "m", "A", `${ESC}[?`, "25", "l"]) t.feed(chunk);
    const s = t.snapshot({ includeCells: true });
    t.dispose();
    assert.deepEqual([pick(s.cells[0]), s.cursorVisible], [{ text: "A", width: 1, faint: true, strikethrough: true }, false]);
    return { cell: pick(s.cells[0]), cursorVisible: s.cursorVisible };
  });
  run("E4 hide cursor then enter alt screen (vim-like): hidden in alt screen", () => {
    const t = createTerminal({ cols: 10, rows: 2 });
    t.feed(`${ESC}[?1049h${ESC}[?25l${ESC}[H${ESC}[7mv`);
    const s = t.snapshot({ includeCells: true });
    t.dispose();
    assert.deepEqual([s.isAltScreen, s.cursorVisible, s.cells[0].inverse], [true, false, true]);
    return { isAltScreen: s.isAltScreen, cursorVisible: s.cursorVisible, cell: pick(s.cells[0]) };
  });
  run("E4b leave alt screen without ESC[?25h (observation only)", () => {
    const t = createTerminal({ cols: 10, rows: 2 });
    t.feed(`${ESC}[?1049h${ESC}[?25l${ESC}[?1049l`);
    const s = t.snapshot();
    t.feed(`${ESC}[?25h`);
    const after = t.snapshot().cursorVisible;
    t.dispose();
    assert.equal(after, true);
    return { isAltScreen: s.isAltScreen, cursorVisibleAfterLeave: s.cursorVisible, afterShow: after };
  }, "observe");
  run("E5 RIS (ESC c) makes the cursor visible and drops SGR 7", () => {
    const t = createTerminal({ cols: 10, rows: 1 });
    t.feed(`${ESC}[?25l${ESC}[7m${ESC}cX`);
    const s = t.snapshot({ includeCells: true });
    t.dispose();
    assert.equal(s.cursorVisible, true);
    assert.deepEqual(NEW_KEYS.filter((k) => k in s.cells[0]), []);
    return { cursorVisible: s.cursorVisible, cell: pick(s.cells[0]) };
  });
  run("E5b DECSTR soft reset (ESC[!p) after ESC[?25l (observation only)", () => {
    const t = createTerminal({ cols: 10, rows: 1 });
    t.feed(`${ESC}[?25l${ESC}[2;7m${ESC}[!pY`);
    const s = t.snapshot({ includeCells: true });
    t.dispose();
    return { cursorVisible: s.cursorVisible, cell: pick(s.cells[0]) };
  }, "observe");
  run("E6 resize keeps cursorVisible false and cell flags", () => {
    const t = createTerminal({ cols: 10, rows: 2 });
    t.feed(`${ESC}[?25l${ESC}[8;9mR`);
    t.resize(20, 4);
    const s = t.snapshot({ includeCells: true });
    t.dispose();
    assert.deepEqual([s.cols, s.rows, s.cursorVisible, s.cells[0].invisible, s.cells[0].strikethrough], [20, 4, false, true, true]);
    return { cols: s.cols, rows: s.rows, cursorVisible: s.cursorVisible, cell: pick(s.cells[0]) };
  });
  run("E7 flags move with their row when the screen scrolls", () => {
    const t = createTerminal({ cols: 10, rows: 2, scrollbackLimit: 10 });
    t.feed(`top\r\n${ESC}[7minv${ESC}[0m\r\nnext`);
    const s = t.snapshot({ includeCells: true, includeScrollback: true });
    t.dispose();
    const inv = s.cells.filter((c) => c.inverse).map((c) => [c.row, c.text]);
    assert.deepEqual(inv, [[0, "i"], [0, "n"], [0, "v"]]);
    assert.deepEqual(s.scrollbackLines.map((l) => l.text), ["top"]);
    return { inverseCells: inv, scrollback: s.scrollbackLines, visible: s.visibleLines };
  });
  const w = new Worker(new URL(import.meta.url));
  const fromWorker = await new Promise((res, rej) => { w.once("message", res); w.once("error", rej); });
  await w.terminate();
  run("E8 worker_threads: snapshot in a Worker carries cursorVisible and all four flags", () => {
    assert.deepEqual([fromWorker.cursorVisible, pick(fromWorker.cell)],
      [false, { text: "W", width: 1, faint: true, inverse: true, invisible: true, strikethrough: true }]);
    return fromWorker;
  });

  for (const r of results) console.log(JSON.stringify(r));
  const failed = results.filter((r) => !r.ok).length;
  console.log(`SUMMARY ${results.length - failed}/${results.length} edge checks passed`);
  process.exitCode = failed === 0 ? 0 : 1;
}

Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: xhigh

Change-Id: I2a52cfff2d6055fc897033edd6c3f05166729371
Signed-off-by: Thomas Kosiewski <tk@coder.com>
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Chef's kiss.

Reviewed commit: 675151e117

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: 675151e117

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@ThomasK33

Copy link
Copy Markdown
Member Author

PASS+NOTES

I re-verified after the merge of main. I tested PR head 675151e117eeb655712e0f1bb243a7a1778889bb against the parent (main) 7366b7545170ad77f5151e445c60bbf5bb110d87. The PR's hunks in native/terminal.cc and src/types.ts have the same git patch-id as before the merge. My earlier verdict is #17 (comment).

After my run, main moved to 7b6c56c9935809e27c2c2c7fda7abc4c65c5566e (#32). That commit changes 12 files, all under .agents/skills/verify-native-lifecycle/. None of them are in the package.json files list, so the packed package is the same. I did not rebuild at that commit. The PR head merges into it with no conflicts (git merge-tree).

Journey Parent 7366b75 Head 675151e
claims 1 to 3 2/17 checks pass, exit 1 17/17, exit 0
claim 4, existing fields 0 snapshot views carry a new key Same as parent once the new keys are removed, across 10 inputs x 2 snapshot views plus getVisibleText()
claim 5, types tsc exit 2, 10 × TS2339 tsc exit 0
edge cases 0/8 8/8
encodeMouse from #12 reference bytes identical to parent for 28 mode × event pairs (19 non-empty)

At both commits, the stdout of claims, observe, edges, and types-node is byte-identical to my earlier run on 96fb41b and 232796f. I added the encodeMouse check because the merge brought in #12. It compares the bytes for 7 mode setups (one with tracking off) × 4 events.

The merged README.md, native/README.md, and src/README.md diffs against main add the same statements as before, and they match what I observed. The native/README.md test line now names cursorVisible and the cell style fields next to the encodeMouse tests. test/terminal.test.ts at 675151e tests cursorVisible and the cell style fields. No file in test/ mentions includeScrollback, formatPlain, formatHtml, or Uint8Array, which matches the "no vitest test covers" list.

Both notes from my earlier verdict still hold. DECSTR (ESC[!p) leaves the cursor hidden because the pinned Ghostty (48ccec1) ignores it. Leaving the alternate screen keeps the cursor hidden until ESC[?25h, as in xterm.


Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: xhigh

@ThomasK33
ThomasK33 merged commit e9e20d5 into main Oct 29, 2026
5 checks passed
ThomasK33 added a commit that referenced this pull request Oct 30, 2026
…CSTR limit (#38)

Closes #36

## Why
Snapshots from the native binding always carry `cursorVisible`, but the type marked it optional, so every reader had to handle `undefined`. Separately, a DECSTR soft reset (`ESC[!p`) leaves the cursor hidden, and nothing said so.

## What changed
- `TerminalSnapshot.cursorVisible` is now required (`boolean`), in `src/types.ts` and the `src/README.md` contract.
- The README says the pinned Ghostty ignores DECSTR and that RIS (`ESC c`) still resets. A test pins that behavior.

## Scope
- Supporting DECSTR needs a Ghostty upgrade that requires Zig 0.16 and a new C API. That is tracked in #37, not done here.

## Tradeoffs
- Keeping the field optional was the compatibility choice in #17. No published release has `cursorVisible` yet (`0.1.0-beta.1` predates it), so making it required now breaks no released consumer.

## Blast Radius
Code that builds its own `TerminalSnapshot` must now set `cursorVisible`. coder/agent-tty does this in `assertNativeSnapshot` and will get TS2741 when it upgrades. That is the line where it currently drops the field. Readers are unaffected.

## Verification
- `npm run build`, `typecheck`, `test` (68 passed) and `smoke` pass locally.
- I packed `main` and this branch and compiled a strict consumer against each. `const v: boolean = snapshot().cursorVisible` fails with TS2322 on `main` and compiles here. A `TerminalSnapshot` literal without the field compiles on `main` and fails with TS2741 here.
- Through the package, `ESC[?25l ESC[7m ESC[!p` leaves `cursorVisible: false` and `inverse: true`. A following `ESC c` gives `cursorVisible: true` and no attributes.

---
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_
@ThomasK33 ThomasK33 mentioned this pull request Oct 30, 2026
ThomasK33 added a commit that referenced this pull request Oct 30, 2026
## Why
Ship the changes merged since `0.1.0-beta.1` to npm. They include mode-aware mouse encoding (#12), the new snapshot attributes and `cursorVisible` (#17, #38), and Ghostty `v1.4.0` with DECSTR support (#43).

## What changed
- `package.json` and `package-lock.json` go from `0.1.0-beta.1` to `0.1.0-beta.2`. The branch name matches the version, so `publish.yml` creates `v0.1.0-beta.2` after merge.
- The `release-changelog` workflow adds the CHANGELOG entry to this branch.
- `communique.toml` raises `max_tokens` to 16384. With Communique 1.0.3's default of 4096, generating these notes failed twice with "submit_release_notes was malformed 3 times". The next run succeeded.
- `prepare-release-changelog.mjs` now detects Communique's `## [vX](url) - date` heading. Before, every push regenerated the entry over hand edits.
- I corrected the generated entry. `cursorVisible` and `encodeMouse` first ship in this release, so they are listed as Added, not as changes or fixes.

## Blast Radius
The merge publishes `0.1.0-beta.2` to npm under the `beta` dist-tag. `latest` stays on `0.1.0-beta.0`. Consumers that build a `TerminalSnapshot` themselves must now set `cursorVisible` (#38).

## Verification
- `RELEASE_BRANCH_NAME=release/v0.1.0-beta.2 node .github/scripts/prepare-release-changelog.mjs origin/main` prints `should_generate=true` and `tag_name=v0.1.0-beta.2`. `resolve-npm-publish-tag.mjs` prints `beta`.
- `npm run typecheck` and `npm test` (73 passed).
- Three independent verifier passes, the last PASS+NOTES on the head. They check every CHANGELOG bullet against the `v0.1.0-beta.1` tag and confirm that the packed tarball loads its addon from `prebuilds/`.
- The local linux-x64 prebuild steps from `mise run release-prebuilds` pass: `build:libghostty`, `build`, `build:prebuild`, `verify:prebuilds` and `npm pack --dry-run`.

---
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Expose inverse, strikethrough, faint, invisible and cursor visibility in snapshot cells

1 participant