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
+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;