feat(terminal): capture cursor presentation modes (style, blink, hidden)
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user