Fix terminal snapshots reflowing after resize

Terminal snapshots now carry soft-wrap row metadata only to clients that advertise support, so restored output can resize like live output without breaking older clients.
This commit is contained in:
Mohamed Boudra
2026-05-31 19:39:06 +07:00
parent 794cbcef63
commit 4cc72a84e8
18 changed files with 538 additions and 146 deletions

View File

@@ -102,7 +102,7 @@ describe("file transfer binary frames", () => {
});
});
it("rejects malformed metadata and unknown metadata fields", () => {
it("rejects malformed metadata but ignores unknown metadata fields", () => {
expect(
decodeFileTransferFrame(
encodeFileTransferFrame({
@@ -135,7 +135,18 @@ describe("file transfer binary frames", () => {
new DataView(encoded.buffer).setUint16(2 + requestId.byteLength, json.byteLength);
encoded.set(json, 4 + requestId.byteLength);
expect(decodeFileTransferFrame(encoded)).toBeNull();
// Non-strict: the unknown `extra` key is stripped and the frame still decodes.
expect(decodeFileTransferFrame(encoded)).toEqual({
opcode: FileTransferOpcode.FileBegin,
requestId: "req-1",
metadata: {
mime: "image/png",
size: 1,
encoding: "binary",
modifiedAt: "2026-05-02T00:00:00.000Z",
},
payload: new Uint8Array(),
});
});
it("rejects malformed request id prefixes and frame tails", () => {

View File

@@ -9,14 +9,12 @@ export const FileTransferOpcode = {
export type FileTransferOpcode = (typeof FileTransferOpcode)[keyof typeof FileTransferOpcode];
export const FileBeginMetadataSchema = z
.object({
mime: z.string().min(1),
size: z.number().int().nonnegative(),
encoding: z.enum(["utf-8", "binary"]),
modifiedAt: z.string(),
})
.strict();
export const FileBeginMetadataSchema = z.object({
mime: z.string().min(1),
size: z.number().int().nonnegative(),
encoding: z.enum(["utf-8", "binary"]),
modifiedAt: z.string(),
});
export interface FileBegin {
opcode: typeof FileTransferOpcode.FileBegin;

View File

@@ -91,24 +91,26 @@ describe("terminal binary frames", () => {
).toBeNull();
});
it("rejects unknown fields in resize and snapshot payloads", () => {
it("ignores unknown fields in resize and snapshot payloads", () => {
// Protocol schemas are non-strict: unknown keys are stripped, not rejected, so a
// new daemon can add fields without breaking an old client's parse.
expect(
decodeTerminalResizePayload(
new TextEncoder().encode(JSON.stringify({ rows: 24, cols: 80, extra: true })),
),
).toBeNull();
expect(
decodeTerminalSnapshotPayload(
new TextEncoder().encode(
JSON.stringify({
rows: 1,
cols: 1,
grid: [[{ char: "A", extra: true }]],
scrollback: [],
cursor: { row: 0, col: 1 },
}),
),
).toEqual({ rows: 24, cols: 80 });
const snapshot = decodeTerminalSnapshotPayload(
new TextEncoder().encode(
JSON.stringify({
rows: 1,
cols: 1,
grid: [[{ char: "A", extra: true }]],
scrollback: [],
cursor: { row: 0, col: 1 },
}),
),
).toBeNull();
);
expect(snapshot?.grid[0]?.[0]).toEqual({ char: "A" });
});
});

View File

@@ -1,12 +1,10 @@
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(),
});
export const TerminalStreamOpcode = {
Output: 0x01,