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));
+16 -1
View File
@@ -2104,6 +2104,19 @@ export const TerminalCellSchema = z.object({
bold: z.boolean().optional(),
italic: z.boolean().optional(),
underline: z.boolean().optional(),
dim: z.boolean().optional(),
inverse: z.boolean().optional(),
strikethrough: z.boolean().optional(),
}).strict();
export const TerminalCursorStyleSchema = z.enum(["block", "underline", "bar"]);
export const TerminalCursorSchema = z.object({
row: z.number(),
col: z.number(),
hidden: z.boolean().optional(),
style: TerminalCursorStyleSchema.optional(),
blink: z.boolean().optional(),
}).strict();
export const TerminalStateSchema = z.object({
@@ -2111,7 +2124,7 @@ export const TerminalStateSchema = z.object({
cols: z.number(),
grid: z.array(z.array(TerminalCellSchema)),
scrollback: z.array(z.array(TerminalCellSchema)),
cursor: z.object({ row: z.number(), col: z.number() }).strict(),
cursor: TerminalCursorSchema,
}).strict();
export const ListTerminalsResponseSchema = z.object({
@@ -2363,6 +2376,8 @@ export type SubscribeTerminalResponse = z.infer<typeof SubscribeTerminalResponse
export type UnsubscribeTerminalRequest = z.infer<typeof UnsubscribeTerminalRequestSchema>;
export type TerminalInput = z.infer<typeof TerminalInputSchema>;
export type TerminalCell = z.infer<typeof TerminalCellSchema>;
export type TerminalCursorStyle = z.infer<typeof TerminalCursorStyleSchema>;
export type TerminalCursor = z.infer<typeof TerminalCursorSchema>;
export type TerminalState = z.infer<typeof TerminalStateSchema>;
export type KillTerminalRequest = z.infer<typeof KillTerminalRequestSchema>;
export type KillTerminalResponse = z.infer<typeof KillTerminalResponseSchema>;
@@ -53,6 +53,23 @@ async function waitForLines(
);
}
async function waitForState(
session: TerminalSession,
predicate: (state: ReturnType<TerminalSession["getState"]>) => boolean,
timeoutMs = 5000,
): Promise<ReturnType<TerminalSession["getState"]>> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const state = session.getState();
if (predicate(state)) {
return state;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error("Timeout waiting for terminal state predicate to match");
}
describe("Terminal", () => {
const sessions: TerminalSession[] = [];
const temporaryDirs: string[] = [];
@@ -587,6 +604,35 @@ describe("Terminal", () => {
expect(typeof state.cursor.col).toBe("number");
});
it("captures cursor presentation modes emitted by terminal apps", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
rows: 24,
cols: 80,
}),
);
await waitForLines(session, ["$"]);
session.send({ type: "input", data: "printf '\\033[2 q\\033[?25l'\r" });
const state = await waitForState(
session,
(current) =>
current.cursor.style === "block" &&
current.cursor.blink === false &&
current.cursor.hidden === true,
);
expect(state.cursor).toMatchObject({
style: "block",
blink: false,
hidden: true,
});
});
it("grid cells have char and color attributes", async () => {
const session = trackSession(
await createTerminal({
+37 -36
View File
@@ -4,36 +4,13 @@ import { randomUUID } from "crypto";
import { chmodSync, existsSync, statSync } from "node:fs";
import { dirname, join } from "node:path";
import { createRequire } from "node:module";
import type { TerminalCell, TerminalState } from "../shared/messages.js";
const { Terminal } = xterm;
const require = createRequire(import.meta.url);
let nodePtySpawnHelperChecked = false;
const STATE_BROADCAST_INTERVAL_MS = 33;
export interface Cell {
char: string;
fg: number | undefined;
bg: number | undefined;
fgMode?: number; // 0=default, 1=16 ANSI, 2=256, 3=RGB
bgMode?: number;
bold?: boolean;
italic?: boolean;
underline?: boolean;
}
export interface Pos {
row: number;
col: number;
}
export interface TerminalState {
rows: number;
cols: number;
grid: Cell[][];
scrollback: Cell[][];
cursor: Pos;
}
export type ClientMessage =
| { type: "input"; data: string }
| { type: "resize"; rows: number; cols: number }
@@ -130,7 +107,7 @@ export function ensureNodePtySpawnHelperExecutableForCurrentPlatform(
}
}
function extractCell(terminal: TerminalType, row: number, col: number): Cell {
function extractCell(terminal: TerminalType, row: number, col: number): TerminalCell {
const buffer = terminal.buffer.active;
const line = buffer.getLine(row);
if (!line) {
@@ -162,17 +139,20 @@ function extractCell(terminal: TerminalType, row: number, col: number): Cell {
bold: cell.isBold() !== 0,
italic: cell.isItalic() !== 0,
underline: cell.isUnderline() !== 0,
dim: cell.isDim() !== 0,
inverse: cell.isInverse() !== 0,
strikethrough: cell.isStrikethrough() !== 0,
};
}
function extractGrid(terminal: TerminalType): Cell[][] {
const grid: Cell[][] = [];
function extractGrid(terminal: TerminalType): TerminalCell[][] {
const grid: TerminalCell[][] = [];
const buffer = terminal.buffer.active;
// Visible viewport starts at baseY
const baseY = buffer.baseY;
for (let row = 0; row < terminal.rows; row++) {
const rowCells: Cell[] = [];
const rowCells: TerminalCell[] = [];
for (let col = 0; col < terminal.cols; col++) {
rowCells.push(extractCell(terminal, baseY + row, col));
}
@@ -182,15 +162,15 @@ function extractGrid(terminal: TerminalType): Cell[][] {
return grid;
}
function extractScrollback(terminal: TerminalType): Cell[][] {
const scrollback: Cell[][] = [];
function extractScrollback(terminal: TerminalType): TerminalCell[][] {
const scrollback: TerminalCell[][] = [];
const buffer = terminal.buffer.active;
// baseY is the first row of the visible viewport (0-indexed)
// Lines 0 to baseY-1 are in scrollback, lines baseY onwards are visible
const scrollbackLines = buffer.baseY;
for (let row = 0; row < scrollbackLines; row++) {
const rowCells: Cell[] = [];
const rowCells: TerminalCell[] = [];
const line = buffer.getLine(row);
for (let col = 0; col < terminal.cols; col++) {
if (line) {
@@ -211,6 +191,9 @@ function extractScrollback(terminal: TerminalType): Cell[][] {
bold: cell.isBold() !== 0,
italic: cell.isItalic() !== 0,
underline: cell.isUnderline() !== 0,
dim: cell.isDim() !== 0,
inverse: cell.isInverse() !== 0,
strikethrough: cell.isStrikethrough() !== 0,
});
} else {
rowCells.push({ char: " ", fg: undefined, bg: undefined });
@@ -225,6 +208,28 @@ function extractScrollback(terminal: TerminalType): Cell[][] {
return scrollback;
}
function extractCursorState(terminal: TerminalType): TerminalState["cursor"] {
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: terminal.buffer.active.cursorY,
col: terminal.buffer.active.cursorX,
...(hidden ? { hidden: true } : {}),
...(normalizedCursorStyle ? { style: normalizedCursorStyle } : {}),
...(typeof cursorBlink === "boolean" ? { blink: cursorBlink } : {}),
};
}
export async function createTerminal(options: CreateTerminalOptions): Promise<TerminalSession> {
const {
cwd,
@@ -345,13 +350,9 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
cols: terminal.cols,
grid: extractGrid(terminal),
scrollback: extractScrollback(terminal),
cursor: {
row: terminal.buffer.active.cursorY,
col: terminal.buffer.active.cursorX,
},
cursor: extractCursorState(terminal),
};
}
function send(msg: ClientMessage): void {
if (killed) return;