Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
49 changes: 48 additions & 1 deletion docs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<TerminalRegionResponse>`

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<StatsResult>`

Query live metrics from a running session.
Expand Down Expand Up @@ -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
};
```

Expand Down
7 changes: 5 additions & 2 deletions src/client-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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";
85 changes: 85 additions & 0 deletions src/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -243,3 +252,79 @@ export function peekScreen(options: PeekScreenOptions): Promise<string> {
});
});
}

/**
* 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<TerminalRegionResponse> {
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.`,
));
}
});
});
}
137 changes: 137 additions & 0 deletions src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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;

Expand Down Expand Up @@ -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<string, unknown>;
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 };
Expand Down
Loading
Loading