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,

View File

@@ -5,6 +5,12 @@ export const CLIENT_CAPS = {
// outside the legacy set to "ShieldCheck" when this cap is absent. Drop the
// gate when floor >= v0.1.84.
customModeIcons: "custom_mode_icons",
// COMPAT(terminalReflowableSnapshot): added in v0.1.88. The daemon attaches
// per-row soft-wrap flags (gridWrapped/scrollbackWrapped) to terminal snapshots
// only when the client advertises this, so restored content can reflow on resize.
// Old clients use a strict TerminalState schema and would reject the extra fields.
// Drop the gate (always send the flags) when floor >= v0.1.88.
terminalReflowableSnapshot: "terminal_reflowable_snapshot",
} as const;
export type ClientCapability = (typeof CLIENT_CAPS)[keyof typeof CLIENT_CAPS];

View File

@@ -64,7 +64,13 @@ describe("shared messages tool_call schema", () => {
error: null,
});
const withTopLevelInputOutput = AgentTimelineItemPayloadSchema.safeParse({
expect(missingCallId.success).toBe(false);
expect(unknownStatus.success).toBe(false);
});
it("ignores unknown top-level fields on tool_call payloads", () => {
// Non-strict protocol: extra top-level keys are stripped, not rejected.
const parsed = AgentTimelineItemPayloadSchema.safeParse({
...canonicalBase(),
status: "running",
error: null,
@@ -72,9 +78,7 @@ describe("shared messages tool_call schema", () => {
output: { exitCode: 0 },
});
expect(missingCallId.success).toBe(false);
expect(unknownStatus.success).toBe(false);
expect(withTopLevelInputOutput.success).toBe(false);
expect(parsed.success).toBe(true);
});
it("rejects legacy status/error combinations without normalization", () => {

View File

@@ -485,15 +485,13 @@ const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail, z.ZodTypeDef, unkno
}),
]);
const ToolCallBasePayloadSchema = z
.object({
type: z.literal("tool_call"),
callId: z.string(),
name: z.string(),
detail: ToolCallDetailPayloadSchema,
metadata: z.record(z.string(), z.unknown()).optional(),
})
.strict();
const ToolCallBasePayloadSchema = z.object({
type: z.literal("tool_call"),
callId: z.string(),
name: z.string(),
detail: ToolCallDetailPayloadSchema,
metadata: z.record(z.string(), z.unknown()).optional(),
});
const ToolCallRunningPayloadSchema = ToolCallBasePayloadSchema.extend({
status: z.literal("running"),
@@ -1808,10 +1806,8 @@ export const SubscribeTerminalRequestSchema = z.object({
rows: z.number().int().positive(),
cols: z.number().int().positive(),
})
.strict()
.optional(),
})
.strict()
.optional(),
});
@@ -3540,44 +3536,45 @@ const TerminalInfoSchema = z.object({
title: z.string().optional(),
});
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(),
});
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(),
});
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,
title: z.string().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,
title: z.string().optional(),
// Per-row soft-wrap flags aligned 1:1 with `grid` / `scrollback`. `true` means
// the row continued onto the next row (xterm's GRID_LINE_WRAPPED equivalent),
// so the client can re-wrap the logical line on resize instead of freezing it
// at the snapshot width. Optional: only sent to clients that advertise the
// `terminalReflowableSnapshot` capability, so old daemons/clients are unaffected.
gridWrapped: z.array(z.boolean()).optional(),
scrollbackWrapped: z.array(z.boolean()).optional(),
});
export const ListTerminalsResponseSchema = z.object({
type: z.literal("list_terminals_response"),
@@ -4108,6 +4105,7 @@ export const WSHelloMessageSchema = z.object({
pushNotifications: z.boolean().optional(),
[CLIENT_CAPS.reasoningMergeEnum]: z.boolean().optional(),
[CLIENT_CAPS.customModeIcons]: z.boolean().optional(),
[CLIENT_CAPS.terminalReflowableSnapshot]: z.boolean().optional(),
})
.passthrough()
.optional(),

View File

@@ -2,25 +2,19 @@ import { z } from "zod";
import type { AgentProvider } from "./agent-types.js";
import { AgentProviderSchema } from "./provider-manifest.js";
const ProviderCommandDefaultSchema = z
.object({
mode: z.literal("default"),
})
.strict();
const ProviderCommandDefaultSchema = z.object({
mode: z.literal("default"),
});
const ProviderCommandAppendSchema = z
.object({
mode: z.literal("append"),
args: z.array(z.string()).optional(),
})
.strict();
const ProviderCommandAppendSchema = z.object({
mode: z.literal("append"),
args: z.array(z.string()).optional(),
});
const ProviderCommandReplaceSchema = z
.object({
mode: z.literal("replace"),
argv: z.array(z.string().min(1)).min(1),
})
.strict();
const ProviderCommandReplaceSchema = z.object({
mode: z.literal("replace"),
argv: z.array(z.string().min(1)).min(1),
});
export const ProviderCommandSchema = z.discriminatedUnion("mode", [
ProviderCommandDefaultSchema,
@@ -28,47 +22,39 @@ export const ProviderCommandSchema = z.discriminatedUnion("mode", [
ProviderCommandReplaceSchema,
]);
export const ProviderRuntimeSettingsSchema = z
.object({
command: ProviderCommandSchema.optional(),
env: z.record(z.string()).optional(),
disallowedTools: z.array(z.string()).optional(),
})
.strict();
export const ProviderRuntimeSettingsSchema = z.object({
command: ProviderCommandSchema.optional(),
env: z.record(z.string()).optional(),
disallowedTools: z.array(z.string()).optional(),
});
const ProviderProfileThinkingOptionSchema = z
.object({
id: z.string(),
label: z.string(),
description: z.string().optional(),
isDefault: z.boolean().optional(),
})
.strict();
const ProviderProfileThinkingOptionSchema = z.object({
id: z.string(),
label: z.string(),
description: z.string().optional(),
isDefault: z.boolean().optional(),
});
export const ProviderProfileModelSchema = z
.object({
id: z.string().min(1),
label: z.string().min(1),
description: z.string().optional(),
isDefault: z.boolean().optional(),
thinkingOptions: z.array(ProviderProfileThinkingOptionSchema).optional(),
})
.strict();
export const ProviderProfileModelSchema = z.object({
id: z.string().min(1),
label: z.string().min(1),
description: z.string().optional(),
isDefault: z.boolean().optional(),
thinkingOptions: z.array(ProviderProfileThinkingOptionSchema).optional(),
});
export const ProviderOverrideSchema = z
.object({
extends: z.string().optional(),
label: z.string().optional(),
description: z.string().optional(),
command: z.array(z.string().min(1)).min(1).optional(),
env: z.record(z.string()).optional(),
models: z.array(ProviderProfileModelSchema).optional(),
additionalModels: z.array(ProviderProfileModelSchema).optional(),
disallowedTools: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
order: z.number().optional(),
})
.strict();
export const ProviderOverrideSchema = z.object({
extends: z.string().optional(),
label: z.string().optional(),
description: z.string().optional(),
command: z.array(z.string().min(1)).min(1).optional(),
env: z.record(z.string()).optional(),
models: z.array(ProviderProfileModelSchema).optional(),
additionalModels: z.array(ProviderProfileModelSchema).optional(),
disallowedTools: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
order: z.number().optional(),
});
const BUILTIN_PROVIDER_IDS = ["claude", "codex", "copilot", "opencode", "pi"] as const;
const PROVIDER_ID_PATTERN = /^[a-z][a-z0-9-]*$/;

View File

@@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot";
import type { TerminalState } from "./messages";
function cells(text: string): TerminalState["grid"][number] {
return [...text].map((char) => ({ char }));
}
describe("renderTerminalSnapshotToAnsi", () => {
it("renders soft-wrapped rows as one contiguous logical line when wrap flags are present", () => {
// The server soft-wrapped one logical line "ABCDEFGHIJKLMNOP" at 10 cols into
// two grid rows. gridWrapped[0] = true marks row 0 as continuing into row 1.
const state: TerminalState = {
rows: 2,
cols: 10,
scrollback: [],
scrollbackWrapped: [],
grid: [cells("ABCDEFGHIJ"), cells("KLMNOP")],
gridWrapped: [true, false],
cursor: { row: 1, col: 6 },
};
const ansi = renderTerminalSnapshotToAnsi(state);
// The rows must arrive unbroken so xterm re-wraps them itself (and can later
// reflow them) — no hard newline injected between "...IJ" and "KL...".
expect(ansi).toContain("ABCDEFGHIJKLMNOP");
// Auto-wrap must stay enabled; disabling it (ESC[?7l) is what makes xterm mark
// the rows non-wrapped and refuse to reflow them on resize.
expect(ansi).not.toContain("[?7l");
});
it("falls back to verbatim per-row replay when wrap flags are absent (old daemon)", () => {
// No gridWrapped/scrollbackWrapped: the client cannot tell soft-wraps from hard
// newlines, so it must keep today's exact behaviour rather than guess.
const state: TerminalState = {
rows: 2,
cols: 10,
scrollback: [],
grid: [cells("ABCDEFGHIJ"), cells("KLMNOP")],
cursor: { row: 1, col: 6 },
};
const ansi = renderTerminalSnapshotToAnsi(state);
expect(ansi).toContain("[?7l");
expect(ansi).toContain("ABCDEFGHIJ\r\nKLMNOP");
});
});

View File

@@ -28,12 +28,22 @@ const DEFAULT_STYLE: TerminalStyle = {
export function renderTerminalSnapshotToAnsi(state: TerminalState): string {
const rows = [...state.scrollback, ...state.grid];
const lines: string[] = ["\u001b[?7l"];
const wrapFlags = [...(state.scrollbackWrapped ?? []), ...(state.gridWrapped ?? [])];
// Soft-wrapped lines can only be re-wrapped on resize when we know which rows
// were continuations. With that per-row flag we replay each logical line as one
// unbroken run (autowrap on) so xterm marks the continuations wrapped and reflows
// them. Without it (old daemon) we keep the verbatim per-row replay: autowrap off
// plus a hard newline per row.
const hasWrapInfo = wrapFlags.length === rows.length;
const lines: string[] = hasWrapInfo ? [] : ["\u001b[?7l"];
for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
const row = rows[rowIndex] ?? [];
lines.push(renderTerminalRow(row));
if (rowIndex < rows.length - 1) {
const continuesToNextRow = hasWrapInfo && wrapFlags[rowIndex] === true;
// A continuation row must fill the full width so the next row first cell
// triggers xterm auto-wrap, which is what marks the row wrapped/reflowable.
lines.push(renderTerminalRow(row, continuesToNextRow ? state.cols : undefined));
if (rowIndex < rows.length - 1 && !continuesToNextRow) {
lines.push("\r\n");
}
}
@@ -45,7 +55,9 @@ export function renderTerminalSnapshotToAnsi(state: TerminalState): string {
}
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");
if (!hasWrapInfo) {
lines.push("\u001b[?7h");
}
return lines.join("");
}
@@ -67,9 +79,10 @@ function renderCursorPresentationToAnsi(cursor: TerminalState["cursor"]): string
return `\u001b[${cursorStyleCode} q`;
}
function renderTerminalRow(row: TerminalCell[]): string {
function renderTerminalRow(row: TerminalCell[], padToCols?: number): string {
const output: string[] = [];
const length = getTerminalRowLength(row);
const contentLength = getTerminalRowLength(row);
const length = padToCols !== undefined ? Math.max(contentLength, padToCols) : contentLength;
let previousStyle = DEFAULT_STYLE;
for (let index = 0; index < length; index += 1) {