diff --git a/packages/app/src/terminal/runtime/terminal-snapshot.test.ts b/packages/app/src/terminal/runtime/terminal-snapshot.test.ts index 7dd8ba5f6..97867cf6f 100644 --- a/packages/app/src/terminal/runtime/terminal-snapshot.test.ts +++ b/packages/app/src/terminal/runtime/terminal-snapshot.test.ts @@ -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); diff --git a/packages/app/src/terminal/runtime/terminal-snapshot.ts b/packages/app/src/terminal/runtime/terminal-snapshot.ts index 346565551..67a830208 100644 --- a/packages/app/src/terminal/runtime/terminal-snapshot.ts +++ b/packages/app/src/terminal/runtime/terminal-snapshot.ts @@ -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)); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index f0c0d3874..e86a4cfd2 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -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; export type TerminalInput = z.infer; export type TerminalCell = z.infer; +export type TerminalCursorStyle = z.infer; +export type TerminalCursor = z.infer; export type TerminalState = z.infer; export type KillTerminalRequest = z.infer; export type KillTerminalResponse = z.infer; diff --git a/packages/server/src/terminal/terminal.test.ts b/packages/server/src/terminal/terminal.test.ts index ed3adf92a..7a818943b 100644 --- a/packages/server/src/terminal/terminal.test.ts +++ b/packages/server/src/terminal/terminal.test.ts @@ -53,6 +53,23 @@ async function waitForLines( ); } +async function waitForState( + session: TerminalSession, + predicate: (state: ReturnType) => boolean, + timeoutMs = 5000, +): Promise> { + 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({ diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index 5902aff5f..d10d34f6f 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -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 { const { cwd, @@ -345,13 +350,9 @@ export async function createTerminal(options: CreateTerminalOptions): Promise