feat(terminal): capture cursor presentation modes (style, blink, hidden)

This commit is contained in:
Mohamed Boudra
2026-03-23 10:58:39 +07:00
parent 87145f1e6a
commit d6977352aa
5 changed files with 184 additions and 45 deletions
@@ -20,7 +20,13 @@ type SnapshotState = {
cols: number;
grid: SnapshotCell[][];
scrollback: SnapshotCell[][];
cursor: { row: number; col: number };
cursor: {
row: number;
col: number;
hidden?: boolean;
style?: "block" | "underline" | "bar";
blink?: boolean;
};
};
async function writeToTerminal(
@@ -50,10 +56,32 @@ function extractState(terminal: ClientTerminal | HeadlessTerminal): SnapshotStat
cols: terminal.cols,
grid,
scrollback,
cursor: {
row: buffer.cursorY,
col: buffer.cursorX,
},
cursor: extractCursorState(terminal),
};
}
function extractCursorState(
terminal: ClientTerminal | HeadlessTerminal,
): SnapshotState["cursor"] {
const buffer = terminal.buffer.active;
const coreService = (terminal as any)._core?.coreService;
const cursorStyle = coreService?.decPrivateModes?.cursorStyle;
const normalizedCursorStyle =
cursorStyle === "block" || cursorStyle === "underline" || cursorStyle === "bar"
? cursorStyle
: undefined;
const cursorBlink =
typeof coreService?.decPrivateModes?.cursorBlink === "boolean"
? coreService.decPrivateModes.cursorBlink
: undefined;
const hidden = Boolean(coreService?.isCursorHidden);
return {
row: buffer.cursorY,
col: buffer.cursorX,
...(hidden ? { hidden: true } : {}),
...(normalizedCursorStyle ? { style: normalizedCursorStyle } : {}),
...(typeof cursorBlink === "boolean" ? { blink: cursorBlink } : {}),
};
}
@@ -87,7 +115,7 @@ function extractRow(terminal: ClientTerminal | HeadlessTerminal, row: number): S
}
describe("terminal-snapshot", () => {
it("replays extracted terminal state into a client xterm with matching grid, scrollback, and cursor", async () => {
it("replays extracted terminal state into a client xterm with matching grid, scrollback, and cursor presentation", async () => {
const source = new HeadlessTerminal({
rows: 4,
cols: 12,
@@ -100,6 +128,8 @@ describe("terminal-snapshot", () => {
await writeToTerminal(source, "\u001b[1mbold\u001b[0m\r\n");
await writeToTerminal(source, "cursor");
await writeToTerminal(source, "\u001b[2D");
await writeToTerminal(source, "\u001b[2 q");
await writeToTerminal(source, "\u001b[?25l");
const snapshot = extractState(source);
@@ -8,6 +8,9 @@ type TerminalStyle = {
bold: boolean;
italic: boolean;
underline: boolean;
dim: boolean;
inverse: boolean;
strikethrough: boolean;
};
const DEFAULT_STYLE: TerminalStyle = {
@@ -18,6 +21,9 @@ const DEFAULT_STYLE: TerminalStyle = {
bold: false,
italic: false,
underline: false,
dim: false,
inverse: false,
strikethrough: false,
};
export function renderTerminalSnapshotToAnsi(state: TerminalState): string {
@@ -33,11 +39,34 @@ export function renderTerminalSnapshotToAnsi(state: TerminalState): string {
}
lines.push("\u001b[0m");
const cursorPresentationAnsi = renderCursorPresentationToAnsi(state.cursor);
if (cursorPresentationAnsi) {
lines.push(cursorPresentationAnsi);
}
lines.push(`\u001b[${state.cursor.row + 1};${state.cursor.col + 1}H`);
lines.push(state.cursor.hidden ? "\u001b[?25l" : "\u001b[?25h");
lines.push("\u001b[?7h");
return lines.join("");
}
function renderCursorPresentationToAnsi(cursor: TerminalState["cursor"]): string | null {
if (!cursor.style) {
return null;
}
const cursorStyleCode = (() => {
if (cursor.style === "block") {
return cursor.blink === false ? 2 : 1;
}
if (cursor.style === "underline") {
return cursor.blink === false ? 4 : 3;
}
return cursor.blink === false ? 6 : 5;
})();
return `\u001b[${cursorStyleCode} q`;
}
function renderTerminalRow(row: TerminalCell[]): string {
const output: string[] = [];
const length = getTerminalRowLength(row);
@@ -76,7 +105,10 @@ function getTerminalRowLength(row: TerminalCell[]): number {
cell.bgMode !== undefined ||
cell.bold ||
cell.italic ||
cell.underline
cell.underline ||
cell.dim ||
cell.inverse ||
cell.strikethrough
) {
return index + 1;
}
@@ -93,6 +125,9 @@ function getTerminalStyle(cell: TerminalCell): TerminalStyle {
bold: Boolean(cell.bold),
italic: Boolean(cell.italic),
underline: Boolean(cell.underline),
dim: Boolean(cell.dim),
inverse: Boolean(cell.inverse),
strikethrough: Boolean(cell.strikethrough),
};
}
@@ -104,7 +139,10 @@ function terminalStylesEqual(left: TerminalStyle, right: TerminalStyle): boolean
left.bgMode === right.bgMode &&
left.bold === right.bold &&
left.italic === right.italic &&
left.underline === right.underline
left.underline === right.underline &&
left.dim === right.dim &&
left.inverse === right.inverse &&
left.strikethrough === right.strikethrough
);
}
@@ -114,12 +152,21 @@ function styleToAnsi(style: TerminalStyle): string {
if (style.bold) {
codes.push("1");
}
if (style.dim) {
codes.push("2");
}
if (style.italic) {
codes.push("3");
}
if (style.underline) {
codes.push("4");
}
if (style.inverse) {
codes.push("7");
}
if (style.strikethrough) {
codes.push("9");
}
if (style.fg !== undefined && style.fgMode !== undefined) {
codes.push(...colorToSgr(style.fgMode, style.fg, false));