Add multiplexed terminal stream slots

This commit is contained in:
Mohamed Boudra
2026-03-23 12:36:35 +07:00
parent ed3ed78ec3
commit ea41dbc8e7
8 changed files with 430 additions and 154 deletions
+34 -27
View File
@@ -2095,37 +2095,43 @@ const TerminalInfoSchema = z.object({
cwd: z.string(),
});
export const TerminalCellSchema = z.object({
char: z.string(),
fg: z.number().optional(),
bg: z.number().optional(),
fgMode: z.number().optional(),
bgMode: z.number().optional(),
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 TerminalCellSchema = z
.object({
char: z.string(),
fg: z.number().optional(),
bg: z.number().optional(),
fgMode: z.number().optional(),
bgMode: z.number().optional(),
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 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({
rows: z.number(),
cols: z.number(),
grid: z.array(z.array(TerminalCellSchema)),
scrollback: z.array(z.array(TerminalCellSchema)),
cursor: TerminalCursorSchema,
}).strict();
export const TerminalStateSchema = z
.object({
rows: z.number(),
cols: z.number(),
grid: z.array(z.array(TerminalCellSchema)),
scrollback: z.array(z.array(TerminalCellSchema)),
cursor: TerminalCursorSchema,
})
.strict();
export const ListTerminalsResponseSchema = z.object({
type: z.literal("list_terminals_response"),
@@ -2157,6 +2163,7 @@ export const SubscribeTerminalResponseSchema = z.object({
type: z.literal("subscribe_terminal_response"),
payload: z.object({
terminalId: z.string(),
slot: z.number().int().min(0).max(255),
error: z.string().nullable(),
requestId: z.string(),
}),
@@ -11,19 +11,22 @@ import {
} from "./terminal-stream-protocol.js";
describe("terminal stream protocol", () => {
it("encodes output frames as a 1-byte opcode prefix plus payload", () => {
it("encodes output frames as opcode plus slot plus payload", () => {
const payload = new TextEncoder().encode("hello");
const encoded = encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
slot: 7,
payload,
});
expect(encoded[0]).toBe(TerminalStreamOpcode.Output);
expect(Array.from(encoded.subarray(1))).toEqual(Array.from(payload));
expect(encoded[1]).toBe(7);
expect(Array.from(encoded.subarray(2))).toEqual(Array.from(payload));
const decoded = decodeTerminalStreamFrame(encoded);
expect(decoded).toEqual({
opcode: TerminalStreamOpcode.Output,
slot: 7,
payload,
});
});
@@ -54,7 +57,11 @@ describe("terminal stream protocol", () => {
});
it("rejects unknown opcodes", () => {
expect(decodeTerminalStreamFrame(new Uint8Array([0xff, 0x01]))).toBeNull();
expect(decodeTerminalStreamFrame(new Uint8Array([0xff, 0x01, 0x02]))).toBeNull();
});
it("rejects frames without a slot byte", () => {
expect(decodeTerminalStreamFrame(new Uint8Array([TerminalStreamOpcode.Output]))).toBeNull();
});
it("rejects malformed JSON payloads", () => {
@@ -66,7 +73,9 @@ describe("terminal stream protocol", () => {
it("rejects invalid resize and snapshot shapes", () => {
expect(
decodeTerminalResizePayload(new TextEncoder().encode(JSON.stringify({ rows: "24", cols: 80 }))),
decodeTerminalResizePayload(
new TextEncoder().encode(JSON.stringify({ rows: "24", cols: 80 })),
),
).toBeNull();
expect(
decodeTerminalSnapshotPayload(
@@ -1,10 +1,12 @@
import { z } from "zod";
import { TerminalStateSchema } from "./messages.js";
export const TerminalStreamResizeSchema = z.object({
rows: z.number().int().positive(),
cols: z.number().int().positive(),
}).strict();
export const TerminalStreamResizeSchema = z
.object({
rows: z.number().int().positive(),
cols: z.number().int().positive(),
})
.strict();
export const TerminalStreamOpcode = {
Output: 0x01,
@@ -13,11 +15,11 @@ export const TerminalStreamOpcode = {
Snapshot: 0x04,
} as const;
export type TerminalStreamOpcode =
(typeof TerminalStreamOpcode)[keyof typeof TerminalStreamOpcode];
export type TerminalStreamOpcode = (typeof TerminalStreamOpcode)[keyof typeof TerminalStreamOpcode];
export type TerminalStreamFrame = {
opcode: TerminalStreamOpcode;
slot: number;
payload: Uint8Array;
};
@@ -61,17 +63,19 @@ function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode {
export function encodeTerminalStreamFrame(input: {
opcode: TerminalStreamOpcode;
slot: number;
payload?: Uint8Array | ArrayBuffer | string;
}): Uint8Array {
const payload = asUint8Array(input.payload ?? new Uint8Array(0)) ?? new Uint8Array(0);
const bytes = new Uint8Array(1 + payload.byteLength);
const bytes = new Uint8Array(2 + payload.byteLength);
bytes[0] = input.opcode;
bytes.set(payload, 1);
bytes[1] = input.slot & 0xff;
bytes.set(payload, 2);
return bytes;
}
export function decodeTerminalStreamFrame(bytes: Uint8Array): TerminalStreamFrame | null {
if (bytes.byteLength < 1) {
if (bytes.byteLength < 2) {
return null;
}
const opcode = bytes[0];
@@ -80,11 +84,14 @@ export function decodeTerminalStreamFrame(bytes: Uint8Array): TerminalStreamFram
}
return {
opcode,
payload: bytes.subarray(1),
slot: bytes[1] ?? 0,
payload: bytes.subarray(2),
};
}
export function encodeTerminalSnapshotPayload(state: z.infer<typeof TerminalStateSchema>): Uint8Array {
export function encodeTerminalSnapshotPayload(
state: z.infer<typeof TerminalStateSchema>,
): Uint8Array {
return encodeJsonPayload(state);
}