From 7c7cb5099e87448a74c8f015b42d86fb44622609 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:40:17 +0200 Subject: [PATCH] feat(client): add read-only terminal region queries --- DEVELOPMENT.md | 3 + README.md | 11 ++- docs/client.md | 49 ++++++++++++- src/client-api.ts | 7 +- src/connection.ts | 85 ++++++++++++++++++++++ src/protocol.ts | 137 +++++++++++++++++++++++++++++++++++ src/server.ts | 147 +++++++++++++++++++++++++++++++++++++- tests/connection.test.ts | 44 ++++++++++++ tests/integration.test.ts | 113 +++++++++++++++++++++++++++++ tests/protocol.test.ts | 96 +++++++++++++++++++++++++ 10 files changed, 686 insertions(+), 6 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index f8c2ea7..e2457aa 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -85,6 +85,9 @@ Binary packets over Unix sockets: `[type: uint8][length: uint32BE][payload]` | EXIT | 4 | Server → Client | `[exitCode: int32BE]` (4 bytes) | | SCREEN | 5 | Server → Client | ANSI escape sequences (string) | | PEEK | 6 | Client → Server | Empty | +| STATUS | 7 | Both | Empty request / JSON response | +| TERMINAL_REGION_REQUEST | 8 | Client → Server | JSON region coordinates | +| TERMINAL_REGION_RESPONSE | 9 | Server → Client | JSON generation, revision, geometry, cursor, and structured cells | `PacketReader` handles streaming reassembly of partial reads. Decoders gracefully handle truncated payloads (defaults for size, -1 for exit code). Unknown message types are silently ignored by the server. diff --git a/README.md b/README.md index 66f81fb..413035c 100644 --- a/README.md +++ b/README.md @@ -372,7 +372,7 @@ Like `git`, `pty` supports extensions: if you run `pty foo` and there's a `pty-f ```typescript import { spawnDaemon, listSessions, getSession, - SessionConnection, sendData, peekScreen, queryStats, + SessionConnection, sendData, peekScreen, queryStats, queryTerminalRegion, EventFollower, readRecentEvents, extractFilterTags, matchesAllTags, } from "@compoundingtech/pty/client"; @@ -422,8 +422,17 @@ For simpler operations: ```typescript await sendData({ name: "myserver", data: ["hello\r"] }); const screen = await peekScreen({ name: "myserver", plain: true }); +const cells = await queryTerminalRegion({ + name: "myserver", row: 0, col: 0, rows: 24, cols: 80, +}); ``` +`queryTerminalRegion()` reads a bounded, structured region from the daemon's +terminal model without attaching or affecting size negotiation. Its generation, +revision, effective geometry, cursor, and cells are captured atomically; see +[the client API reference](docs/client.md#queryterminalregionoptions-promiseterminalregionresponse) +for coordinate semantics and model limits. + ### Following events ```typescript diff --git a/docs/client.md b/docs/client.md index 4798baf..dc9713a 100644 --- a/docs/client.md +++ b/docs/client.md @@ -3,7 +3,12 @@ Import from `@compoundingtech/pty/client`. ```typescript -import { SessionConnection, spawnDaemon, listSessions } from "@compoundingtech/pty/client"; +import { + SessionConnection, + spawnDaemon, + listSessions, + queryTerminalRegion, +} from "@compoundingtech/pty/client"; import { PtyServer } from "@compoundingtech/pty/server"; import { resolveKey } from "@compoundingtech/pty/keys"; import { PacketReader, MessageType } from "@compoundingtech/pty/protocol"; @@ -231,6 +236,46 @@ const screen = await peekScreen({ name: "myserver" }); // ANSI output const plain = await peekScreen({ name: "myserver", plain: true }); // plain text ``` +### `queryTerminalRegion(options): Promise` + +Read structured cells from the daemon's xterm-headless model without attaching +or participating in terminal-size negotiation: + +```typescript +const view = await queryTerminalRegion({ + name: "myserver", + row: 0, + col: 0, + rows: 24, + cols: 80, +}); + +console.log(view.generation, view.revision); +console.log(view.terminal.rows, view.terminal.cols); +console.log(view.region.lines[0].cells[0]); +``` + +Rows are absolute indexes in the active buffer, including retained scrollback; +row 0 is the oldest retained row. `terminal.viewportRow` identifies the current +visible viewport. The returned origin and dimensions are clamped to the current +buffer and effective terminal geometry. + +`revision` is monotonic within `generation` and changes when the terminal model +consumes output or changes size. Pollers can ignore a response whose +`(generation, revision)` pair they have already rendered. Each response is +captured after an xterm parse barrier, so its revision, geometry, cursor, and +cells describe one model state. + +This is the daemon's terminal model, not a renderer snapshot. It preserves the +cell fields xterm-headless exposes, including palette indexes, RGB values, +character width, wrapping, text styles, cursor position/visibility, and terminal modes. It +does not include OSC 8 hyperlink metadata, graphics protocols, palette +definitions, fonts, glyph rendering, or other host-terminal presentation state. +It is a polling API for browse/pan/scan surfaces, not a realtime entered-mode +transport. Use `queryStats()` when only effective geometry and aggregate client +counts are needed. Requests are limited to 100,000 cells, and responses retain +the protocol's 32 MiB packet ceiling. + ### `queryStats(name: string, timeoutMs?: number): Promise` Query live metrics from a running session. @@ -384,6 +429,8 @@ const MessageType = { SCREEN: 5, // Screen replay PEEK: 6, // Read-only peek request STATUS: 7, // Stats query/response + TERMINAL_REGION_REQUEST: 8, // Read-only structured cell request + TERMINAL_REGION_RESPONSE: 9, // Structured cells at one revision }; ``` diff --git a/src/client-api.ts b/src/client-api.ts index 5bcefa4..1b31d72 100644 --- a/src/client-api.ts +++ b/src/client-api.ts @@ -19,8 +19,9 @@ export { spawnDaemon, resolveCommand, waitForSocket, setServerModulePath, type S // Session interaction (programmatic — no process.exit, no stdin/stdout) export { - SessionConnection, sendData, peekScreen, + SessionConnection, sendData, peekScreen, queryTerminalRegion, type SessionConnectionOptions, type SendDataOptions, type PeekScreenOptions, + type QueryTerminalRegionOptions, } from "./connection.ts"; // Session interaction (CLI-oriented — uses process.stdin/stdout, may call process.exit) @@ -62,5 +63,7 @@ export { resolveKey, parseSeqValue } from "./keys.ts"; // Protocol (advanced) export { PacketReader, MessageType, - type Packet, + type Packet, type TerminalCell, type TerminalCellColor, + type TerminalModes, type TerminalRegionLine, type TerminalRegionRequest, + type TerminalRegionResponse, } from "./protocol.ts"; diff --git a/src/connection.ts b/src/connection.ts index bd6552b..c6e93d1 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -8,7 +8,11 @@ import { encodeDetach, encodePeek, encodeResize, + encodeTerminalRegionRequest, + decodeTerminalRegionResponse, decodeExit, + type TerminalRegionRequest, + type TerminalRegionResponse, } from "./protocol.ts"; import { getSocketPath } from "./sessions.ts"; import { resolveKey } from "./keys.ts"; @@ -40,6 +44,11 @@ export interface PeekScreenOptions { full?: boolean; } +export interface QueryTerminalRegionOptions extends TerminalRegionRequest { + name: string; + timeoutMs?: number; +} + /** * Programmatic bidirectional connection to a pty session. * Unlike the CLI `attach()`, this does not take over stdin/stdout @@ -243,3 +252,79 @@ export function peekScreen(options: PeekScreenOptions): Promise { }); }); } + +/** + * Read a bounded region from the daemon's terminal model without attaching or + * participating in terminal-size negotiation. + * + * Rows are absolute active-buffer indexes: row 0 is the oldest retained row, + * and `terminal.viewportRow` identifies the current visible viewport. + */ +export function queryTerminalRegion( + options: QueryTerminalRegionOptions, +): Promise { + return new Promise((resolve, reject) => { + const reader = new PacketReader(); + const socket = net.createConnection(getSocketPath(options.name)); + let settled = false; + const timer = setTimeout(() => { + fail(new Error(`Timeout querying terminal region for "${options.name}"`)); + }, options.timeoutMs ?? 2000); + + const fail = (error: Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.destroy(); + reject(error); + }; + + socket.on("connect", () => { + try { + socket.write(encodeTerminalRegionRequest(options)); + } catch (error) { + fail(error instanceof Error ? error : new Error(String(error))); + } + }); + + socket.on("data", (raw: Buffer) => { + let packets; + try { + packets = reader.feed(raw); + } catch (error) { + fail(error instanceof Error ? error : new Error(String(error))); + return; + } + for (const packet of packets) { + if (packet.type !== MessageType.TERMINAL_REGION_RESPONSE) continue; + try { + const response = decodeTerminalRegionResponse(packet.payload); + if (settled) return; + settled = true; + clearTimeout(timer); + socket.destroy(); + resolve(response); + } catch (error) { + fail(error instanceof Error ? error : new Error(String(error))); + } + return; + } + }); + + socket.on("error", (err: NodeJS.ErrnoException) => { + const error = + err.code === "ENOENT" || err.code === "ECONNREFUSED" + ? new Error(`Session "${options.name}" not found or not running.`) + : new Error(`Connection error: ${err.message}`); + fail(error); + }); + + socket.on("close", () => { + if (!settled) { + fail(new Error( + `Connection to "${options.name}" closed before terminal region received.`, + )); + } + }); + }); +} diff --git a/src/protocol.ts b/src/protocol.ts index a04a6fd..ad386d7 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -9,6 +9,8 @@ export const MessageType = { SCREEN: 5, // Server → Client: screen buffer replay on attach PEEK: 6, // Client → Server: read-only attach (no input, no resize) STATUS: 7, // Client → Server: request stats; Server → Client: JSON stats response + TERMINAL_REGION_REQUEST: 8, // Client → Server: read-only structured cell query + TERMINAL_REGION_RESPONSE: 9, // Server → Client: structured cells at one revision } as const; export type MessageType = (typeof MessageType)[keyof typeof MessageType]; @@ -18,6 +20,81 @@ export interface Packet { payload: Buffer; } +export interface TerminalRegionRequest { + /** Absolute row in the active buffer, where 0 is the oldest retained row. */ + row: number; + /** Zero-based column in the active buffer. */ + col: number; + rows: number; + cols: number; +} + +export type TerminalCellColor = + | { _tag: "default" } + | { _tag: "palette"; index: number } + | { _tag: "rgb"; value: number }; + +export interface TerminalCell { + chars: string; + width: number; + fg: TerminalCellColor; + bg: TerminalCellColor; + bold: boolean; + italic: boolean; + dim: boolean; + underline: boolean; + blink: boolean; + inverse: boolean; + invisible: boolean; + strikethrough: boolean; + overline: boolean; +} + +export interface TerminalRegionLine { + wrapped: boolean; + cells: TerminalCell[]; +} + +export interface TerminalModes { + applicationCursorKeys: boolean; + applicationKeypad: boolean; + bracketedPaste: boolean; + insert: boolean; + mouseTracking: "none" | "x10" | "vt200" | "drag" | "any"; + origin: boolean; + reverseWraparound: boolean; + sendFocus: boolean; + synchronizedOutput: boolean; + wraparound: boolean; + sgrMouse: boolean; + cursorHidden: boolean; + kittyKeyboardFlags: number[]; +} + +export interface TerminalRegionResponse { + /** Identifies the daemon lifetime in which `revision` is monotonic. */ + generation: string; + /** Monotonic daemon-local revision of the terminal model. */ + revision: number; + terminal: { + rows: number; + cols: number; + buffer: "normal" | "alternate"; + bufferRows: number; + viewportRow: number; + cursor: { row: number; col: number }; + modes: TerminalModes; + }; + region: { + /** Actual, clamped origin and dimensions returned by the daemon. */ + row: number; + col: number; + rows: number; + cols: number; + lines: TerminalRegionLine[]; + }; +} + // Packet wire format: [type: uint8][length: uint32BE][payload: N bytes] const HEADER_SIZE = 5; @@ -94,6 +171,66 @@ export function encodeStatusResponse(json: string): Buffer { return encodePacket(MessageType.STATUS, Buffer.from(json)); } +/** Maximum requested cell count. Region queries are intended for bounded + * viewports and scans, not dumping the daemon's entire scrollback in one frame. */ +export const MAX_TERMINAL_REGION_CELLS = 100_000; + +function validateTerminalRegionRequest(value: unknown): TerminalRegionRequest { + if (typeof value !== "object" || value === null) { + throw new Error("Terminal region request must be an object"); + } + const request = value as Record; + const row = request.row; + const col = request.col; + const rows = request.rows; + const cols = request.cols; + if ( + !Number.isInteger(row) || (row as number) < 0 + || !Number.isInteger(col) || (col as number) < 0 + || !Number.isInteger(rows) || (rows as number) <= 0 + || !Number.isInteger(cols) || (cols as number) <= 0 + ) { + throw new Error("Terminal region coordinates must be non-negative and dimensions positive"); + } + if ((rows as number) * (cols as number) > MAX_TERMINAL_REGION_CELLS) { + throw new Error( + `Terminal region exceeds ${MAX_TERMINAL_REGION_CELLS} cells`, + ); + } + return { + row: row as number, + col: col as number, + rows: rows as number, + cols: cols as number, + }; +} + +export function encodeTerminalRegionRequest(request: TerminalRegionRequest): Buffer { + return encodePacket( + MessageType.TERMINAL_REGION_REQUEST, + Buffer.from(JSON.stringify(validateTerminalRegionRequest(request))), + ); +} + +export function decodeTerminalRegionRequest(payload: Buffer): TerminalRegionRequest { + return validateTerminalRegionRequest(JSON.parse(payload.toString())); +} + +export function encodeTerminalRegionResponse(response: TerminalRegionResponse): Buffer { + const payload = Buffer.from(JSON.stringify(response)); + if (payload.length > MAX_PACKET_LENGTH) { + throw new PacketTooLargeError(payload.length); + } + return encodePacket( + MessageType.TERMINAL_REGION_RESPONSE, + payload, + ); +} + +export function decodeTerminalRegionResponse(payload: Buffer): TerminalRegionResponse { + return JSON.parse(payload.toString()) as TerminalRegionResponse; +} + export function decodeSize(payload: Buffer): { rows: number; cols: number } { if (payload.length < 4) { return { rows: 24, cols: 80 }; diff --git a/src/server.ts b/src/server.ts index 762597f..7bebfea 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,7 +5,7 @@ import { randomBytes } from "node:crypto"; import * as pty from "node-pty"; // @xterm/headless is CJS-only, so keep its default import. The serialize addon // ships native ESM with named exports, so import its runtime namespace. -import type { Terminal } from "@xterm/headless"; +import type { IBufferCell, Terminal } from "@xterm/headless"; import type { SerializeAddon } from "@xterm/addon-serialize"; import xterm from "@xterm/headless"; import * as xtermSerialize from "@xterm/addon-serialize"; @@ -16,7 +16,13 @@ import { encodeExit, encodeScreen, encodeStatusResponse, + encodeTerminalRegionResponse, + decodeTerminalRegionRequest, decodeSize, + type TerminalCell, + type TerminalCellColor, + type TerminalRegionRequest, + type TerminalRegionResponse, } from "./protocol.ts"; import { getSocketPath, @@ -250,6 +256,7 @@ export class PtyServer { private mouseTracking1002 = false; // button-motion tracking private mouseTracking1003 = false; // any-motion tracking private lastResizeTime = 0; + private terminalRevision = 0; private eventWriter: EventWriter; private generation: string; private lastTitle = ""; @@ -501,7 +508,9 @@ export class PtyServer { // handlers above and must NOT be forwarded to clients — otherwise the // client's terminal responds and its response appears as garbage input. this.ptyProcess.onData((data: string) => { - this.terminal.write(data); + this.terminal.write(data, () => { + this.terminalRevision++; + }); const cleaned = stripTerminalQueries(data); if (cleaned.length > 0) { this.broadcast(encodeData(cleaned)); @@ -733,6 +742,35 @@ export class PtyServer { socket.write(encodeStatusResponse(JSON.stringify(stats))); break; } + + case MessageType.TERMINAL_REGION_REQUEST: { + if (client.attachSeq > 0) { + socket.destroy(); + break; + } + let request: TerminalRegionRequest; + try { + request = decodeTerminalRegionRequest(packet.payload); + } catch { + socket.destroy(); + break; + } + client.readonly = true; + // xterm's write pipeline is asynchronous. An empty write callback is + // a barrier behind all output already accepted by the daemon, so + // geometry, revision, and cells below describe one terminal-model cut. + this.terminal.write("", () => { + if (socket.destroyed) return; + try { + socket.write( + encodeTerminalRegionResponse(this.collectTerminalRegion(request)), + ); + } catch { + socket.destroy(); + } + }); + break; + } } } }); @@ -839,6 +877,7 @@ export class PtyServer { if (rows !== this.terminal.rows || cols !== this.terminal.cols) { this.ptyProcess.resize(cols, rows); this.terminal.resize(cols, rows); + this.terminalRevision++; this.lastResizeTime = Date.now(); return true; } @@ -856,6 +895,110 @@ export class PtyServer { this.terminal.resize(cols - 1, rows); this.ptyProcess.resize(cols, rows); this.terminal.resize(cols, rows); + this.terminalRevision++; + } + + private collectTerminalRegion( + request: TerminalRegionRequest, + ): TerminalRegionResponse { + const buffer = this.terminal.buffer.active; + const row = Math.min(request.row, buffer.length); + const col = Math.min(request.col, this.terminal.cols); + const rows = Math.min(request.rows, buffer.length - row); + const cols = Math.min(request.cols, this.terminal.cols - col); + const lines: TerminalRegionResponse["region"]["lines"] = []; + const reusableCell = buffer.getNullCell(); + + for (let y = row; y < row + rows; y++) { + const line = buffer.getLine(y); + const cells: TerminalCell[] = []; + for (let x = col; x < col + cols; x++) { + const cell = line?.getCell(x, reusableCell); + cells.push(cell ? this.readTerminalCell(cell) : this.emptyTerminalCell()); + } + lines.push({ wrapped: line?.isWrapped ?? false, cells }); + } + + return { + generation: this.generation, + revision: this.terminalRevision, + terminal: { + rows: this.terminal.rows, + cols: this.terminal.cols, + buffer: buffer.type, + bufferRows: buffer.length, + viewportRow: buffer.viewportY, + cursor: { + row: buffer.baseY + buffer.cursorY, + col: buffer.cursorX, + }, + modes: { + applicationCursorKeys: this.terminal.modes.applicationCursorKeysMode, + applicationKeypad: this.terminal.modes.applicationKeypadMode, + bracketedPaste: this.terminal.modes.bracketedPasteMode, + insert: this.terminal.modes.insertMode, + mouseTracking: this.terminal.modes.mouseTrackingMode, + origin: this.terminal.modes.originMode, + reverseWraparound: this.terminal.modes.reverseWraparoundMode, + sendFocus: this.terminal.modes.sendFocusMode, + synchronizedOutput: this.terminal.modes.synchronizedOutputMode, + wraparound: this.terminal.modes.wraparoundMode, + sgrMouse: this.sgrMouseMode, + cursorHidden: this.cursorHidden, + kittyKeyboardFlags: [...this.kittyKeyboardStack], + }, + }, + region: { row, col, rows, cols, lines }, + }; + } + + private readTerminalCell(cell: IBufferCell): TerminalCell { + return { + chars: cell.getChars(), + width: cell.getWidth(), + fg: this.readTerminalColor(cell, "fg"), + bg: this.readTerminalColor(cell, "bg"), + bold: Boolean(cell.isBold()), + italic: Boolean(cell.isItalic()), + dim: Boolean(cell.isDim()), + underline: Boolean(cell.isUnderline()), + blink: Boolean(cell.isBlink()), + inverse: Boolean(cell.isInverse()), + invisible: Boolean(cell.isInvisible()), + strikethrough: Boolean(cell.isStrikethrough()), + overline: Boolean(cell.isOverline()), + }; + } + + private readTerminalColor( + cell: IBufferCell, + channel: "fg" | "bg", + ): TerminalCellColor { + const isDefault = channel === "fg" ? cell.isFgDefault() : cell.isBgDefault(); + if (isDefault) return { _tag: "default" }; + const value = channel === "fg" ? cell.getFgColor() : cell.getBgColor(); + const isPalette = channel === "fg" ? cell.isFgPalette() : cell.isBgPalette(); + return isPalette + ? { _tag: "palette", index: value } + : { _tag: "rgb", value }; + } + + private emptyTerminalCell(): TerminalCell { + return { + chars: "", + width: 1, + fg: { _tag: "default" }, + bg: { _tag: "default" }, + bold: false, + italic: false, + dim: false, + underline: false, + blink: false, + inverse: false, + invisible: false, + strikethrough: false, + overline: false, + }; } private emitEvent(type: EventType, fields?: Record): void { diff --git a/tests/connection.test.ts b/tests/connection.test.ts index 5c161ce..aaff0e5 100644 --- a/tests/connection.test.ts +++ b/tests/connection.test.ts @@ -8,6 +8,7 @@ import { SessionConnection, sendData, peekScreen, + queryTerminalRegion, } from "../src/connection.ts"; import { terminateAndWait } from "./setup/processes.ts"; @@ -348,3 +349,46 @@ describe("peekScreen", () => { ).rejects.toThrow("not found or not running"); }, 15000); }); + +describe("queryTerminalRegion", () => { + it("returns a bounded structured view without attaching", async () => { + const dir = makeSessionDir(); + const name = uniqueName(); + process.env.PTY_SESSION_DIR = dir; + await startDaemon( + dir, + name, + "sh", + ["-c", "printf '\\033[2J\\033[H\\033[38;5;42;3mVIEW\\033[0m'; exec cat"], + ); + await new Promise((r) => setTimeout(r, 200)); + + const result = await queryTerminalRegion({ + name, + row: 0, + col: 0, + rows: 1, + cols: 4, + }); + + expect(result.terminal).toMatchObject({ rows: 24, cols: 80 }); + expect(result.region.lines[0].cells[0]).toMatchObject({ + chars: "V", + fg: { _tag: "palette", index: 42 }, + italic: true, + }); + }, 15000); + + it("rejects for a missing session", async () => { + const dir = makeSessionDir(); + process.env.PTY_SESSION_DIR = dir; + + await expect(queryTerminalRegion({ + name: "nonexistent", + row: 0, + col: 0, + rows: 1, + cols: 1, + })).rejects.toThrow("not found or not running"); + }); +}); diff --git a/tests/integration.test.ts b/tests/integration.test.ts index 379f5c2..6b36451 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -15,6 +15,8 @@ import { encodePeek, encodeResize, encodeStatus, + encodeTerminalRegionRequest, + decodeTerminalRegionResponse, decodeExit, } from "../src/protocol.ts"; import { @@ -1423,3 +1425,114 @@ describe("STATUS message", () => { client.destroy(); }); }); + +describe("TERMINAL_REGION message", () => { + it("returns styled cells at an atomic revision without participating in size negotiation", async () => { + const name = uniqueName(); + await startServer( + name, + "sh", + ["-c", "printf '\\033[2J\\033[3;5H\\033[31;44;1mAB\\033[0m'; exec cat"], + { rows: 24, cols: 80 }, + ); + await new Promise((r) => setTimeout(r, 200)); + + const attached = await connect(name); + const attachedReader = new PacketReader(); + attached.write(encodeAttach(40, 100)); + await waitForType(attached, attachedReader, MessageType.SCREEN); + + const query = async () => { + const client = await connect(name); + const reader = new PacketReader(); + client.write(encodeTerminalRegionRequest({ row: 2, col: 4, rows: 2, cols: 4 })); + const packet = await waitForType( + client, + reader, + MessageType.TERMINAL_REGION_RESPONSE, + ); + client.destroy(); + return decodeTerminalRegionResponse(packet.payload); + }; + + const first = await query(); + expect(first.generation).toEqual(expect.any(String)); + expect(first.terminal).toMatchObject({ + rows: 40, + cols: 100, + buffer: "normal", + viewportRow: 0, + }); + expect(first.region).toMatchObject({ + row: 2, + col: 4, + rows: 2, + cols: 4, + }); + expect(first.region.lines[0].cells[0]).toMatchObject({ + chars: "A", + width: 1, + fg: { _tag: "palette", index: 1 }, + bg: { _tag: "palette", index: 4 }, + bold: true, + }); + + const unchanged = await query(); + expect(unchanged.revision).toBe(first.revision); + + attached.write(encodeData("Z")); + await waitForContent(attached, attachedReader, "Z"); + const changed = await query(); + expect(changed.revision).toBeGreaterThan(first.revision); + expect(changed.terminal).toMatchObject({ rows: 40, cols: 100 }); + expect(changed.region.lines[0].cells[2].chars).toBe("Z"); + + attached.destroy(); + }); + + it("reads the active alternate buffer with every exposed cell style", async () => { + const name = uniqueName(); + await startServer(name, "sh", [ + "-c", + "printf '\\033[?1049h\\033[2J\\033[H" + + "\\033[?2004h\\033[?1000h\\033[?1006h\\033[?25l" + + "\\033[38;2;1;2;3;48;5;9;1;2;3;4;5;7;8;9;53mX\\033[0m'; sleep 30", + ]); + await new Promise((r) => setTimeout(r, 200)); + + const client = await connect(name); + const reader = new PacketReader(); + client.write(encodeTerminalRegionRequest({ row: 0, col: 0, rows: 1, cols: 1 })); + const packet = await waitForType( + client, + reader, + MessageType.TERMINAL_REGION_RESPONSE, + ); + const response = decodeTerminalRegionResponse(packet.payload); + + expect(response.terminal.buffer).toBe("alternate"); + expect(response.terminal.modes).toMatchObject({ + bracketedPaste: true, + mouseTracking: "vt200", + sgrMouse: true, + cursorHidden: true, + }); + expect(response.region.lines[0].cells[0]).toEqual({ + chars: "X", + width: 1, + fg: { _tag: "rgb", value: 0x010203 }, + bg: { _tag: "palette", index: 9 }, + bold: true, + italic: true, + dim: true, + underline: true, + blink: true, + inverse: true, + invisible: true, + strikethrough: true, + overline: true, + }); + + client.destroy(); + }); +}); diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index 56db597..10f2609 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -13,6 +13,10 @@ import { encodeScreen, encodeStatus, encodeStatusResponse, + encodeTerminalRegionRequest, + encodeTerminalRegionResponse, + decodeTerminalRegionRequest, + decodeTerminalRegionResponse, decodeSize, decodeExit, } from "../src/protocol.ts"; @@ -96,6 +100,98 @@ describe("protocol", () => { expect(packets[0].type).toBe(MessageType.SCREEN); expect(packets[0].payload.toString()).toBe(screen); }); + + it("round-trips a terminal region request", () => { + const reader = new PacketReader(); + const [packet] = reader.feed(encodeTerminalRegionRequest({ + row: 12, + col: 4, + rows: 8, + cols: 20, + })); + + expect(packet.type).toBe(MessageType.TERMINAL_REGION_REQUEST); + expect(decodeTerminalRegionRequest(packet.payload)).toEqual({ + row: 12, + col: 4, + rows: 8, + cols: 20, + }); + }); + + it("rejects invalid or unbounded terminal region requests", () => { + expect(() => encodeTerminalRegionRequest({ + row: -1, + col: 0, + rows: 1, + cols: 1, + })).toThrow("non-negative"); + expect(() => encodeTerminalRegionRequest({ + row: 0, + col: 0, + rows: 1000, + cols: 1000, + })).toThrow("exceeds"); + }); + + it("round-trips a structured terminal region response", () => { + const response = { + generation: "generation-1", + revision: 7, + terminal: { + rows: 24, + cols: 80, + buffer: "alternate" as const, + bufferRows: 24, + viewportRow: 0, + cursor: { row: 2, col: 3 }, + modes: { + applicationCursorKeys: false, + applicationKeypad: false, + bracketedPaste: true, + insert: false, + mouseTracking: "none" as const, + origin: false, + reverseWraparound: false, + sendFocus: false, + synchronizedOutput: false, + wraparound: true, + sgrMouse: false, + cursorHidden: false, + kittyKeyboardFlags: [], + }, + }, + region: { + row: 2, + col: 3, + rows: 1, + cols: 1, + lines: [{ + wrapped: false, + cells: [{ + chars: "X", + width: 1, + fg: { _tag: "palette" as const, index: 2 }, + bg: { _tag: "rgb" as const, value: 0x112233 }, + bold: true, + italic: false, + dim: false, + underline: false, + blink: false, + inverse: false, + invisible: false, + strikethrough: false, + overline: false, + }], + }], + }, + }; + const reader = new PacketReader(); + const [packet] = reader.feed(encodeTerminalRegionResponse(response)); + + expect(packet.type).toBe(MessageType.TERMINAL_REGION_RESPONSE); + expect(decodeTerminalRegionResponse(packet.payload)).toEqual(response); + }); }); describe("PacketReader streaming", () => {