mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "npm --prefix ../.. run build:client && concurrently --kill-others --names protocol,client,expo --prefix-colors yellow,blue,magenta \"npm --prefix ../.. run watch:protocol\" \"npm --prefix ../.. run watch:client\" \"npm run start:expo\"",
|
"start": "npm run start:expo",
|
||||||
"start:expo": "cross-env APP_VARIANT=development expo start",
|
"start:expo": "cross-env APP_VARIANT=development expo start",
|
||||||
"reset-project": "node ./scripts/reset-project.js",
|
"reset-project": "node ./scripts/reset-project.js",
|
||||||
"eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:terminal-webview",
|
"eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:terminal-webview",
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { page } from "@vitest/browser/context";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import type { TerminalState } from "@getpaseo/protocol/messages";
|
||||||
|
import { encodeTerminalOutput, TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
|
||||||
|
|
||||||
|
// Regression: "streaming pino log, resized the Paseo terminal, old logs stayed
|
||||||
|
// narrow and new logs drew on top of the old ones."
|
||||||
|
//
|
||||||
|
// A heavy stream overflows MAX_TERMINAL_OUTPUT_FRAME_BYTES, so the server sends a
|
||||||
|
// SNAPSHOT mid-stream. The client restores it via renderTerminalSnapshotToAnsi.
|
||||||
|
// When the snapshot carries per-row soft-wrap flags, restored long lines must
|
||||||
|
// reflow on resize just like live output — not freeze at the snapshot width.
|
||||||
|
|
||||||
|
interface Mounted {
|
||||||
|
host: HTMLDivElement;
|
||||||
|
root: HTMLDivElement;
|
||||||
|
runtime: TerminalEmulatorRuntime;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mounted: Mounted[] = [];
|
||||||
|
|
||||||
|
function nextFrame(): Promise<void> {
|
||||||
|
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor(predicate: () => boolean, timeoutMs = 4000): Promise<void> {
|
||||||
|
const start = performance.now();
|
||||||
|
while (!predicate()) {
|
||||||
|
if (performance.now() - start > timeoutMs) throw new Error("timeout waiting for condition");
|
||||||
|
await nextFrame();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mount(width: number, height: number): Mounted {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.style.cssText = `width:${width}px;height:${height}px;position:fixed;left:0;top:0;overflow:hidden`;
|
||||||
|
const host = document.createElement("div");
|
||||||
|
host.style.cssText = "width:100%;height:100%";
|
||||||
|
root.appendChild(host);
|
||||||
|
document.body.appendChild(root);
|
||||||
|
|
||||||
|
const runtime = new TerminalEmulatorRuntime();
|
||||||
|
runtime.setCallbacks({ callbacks: {} });
|
||||||
|
runtime.mount({
|
||||||
|
root,
|
||||||
|
host,
|
||||||
|
initialSnapshot: null,
|
||||||
|
scrollback: 10_000,
|
||||||
|
theme: { background: "#0b0b0b", foreground: "#e6e6e6", cursor: "#e6e6e6" },
|
||||||
|
});
|
||||||
|
const m = { host, root, runtime };
|
||||||
|
mounted.push(m);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
text: string;
|
||||||
|
wrapped: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dumpRows(): Row[] {
|
||||||
|
const term = window.__paseoTerminal;
|
||||||
|
if (!term) throw new Error("no terminal");
|
||||||
|
const buf = term.buffer.active;
|
||||||
|
const rows: Row[] = [];
|
||||||
|
for (let i = 0; i < buf.length; i++) {
|
||||||
|
const line = buf.getLine(i);
|
||||||
|
if (!line) continue;
|
||||||
|
rows.push({ text: line.translateToString(true).trimEnd(), wrapped: line.isWrapped });
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function someRowContains(text: string): boolean {
|
||||||
|
return dumpRows().some((r) => r.text.includes(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a server-style snapshot of one long pino line that the SERVER soft-wrapped
|
||||||
|
// at `cols`, represented as multiple grid rows (exactly what the daemon stores in
|
||||||
|
// its headless xterm and ships in a snapshot).
|
||||||
|
function buildWrappedSnapshot(cols: number): TerminalState {
|
||||||
|
const longLine = `[server] [15:30:00.123] TRACE: provider.claude.raw_event {"module":"agent","seq":1,"sessionId":"ed8972f4-d3be-45d0-992c-631f8f1ed04e","turnId":"foreground-turn-1","payload":"${"A".repeat(40)}"}`;
|
||||||
|
const grid: TerminalState["grid"] = [];
|
||||||
|
for (let i = 0; i < longLine.length; i += cols) {
|
||||||
|
const chunk = longLine.slice(i, i + cols);
|
||||||
|
grid.push([...chunk].map((char) => ({ char })));
|
||||||
|
}
|
||||||
|
// The fixed daemon ships per-row soft-wrap flags: every row of the logical line
|
||||||
|
// continues onto the next except the last.
|
||||||
|
const gridWrapped = grid.map((_, index) => index < grid.length - 1);
|
||||||
|
// Cursor sits just below the restored content, exactly where the daemon's
|
||||||
|
// headless terminal leaves it after a line break, so streamed output appends
|
||||||
|
// cleanly below instead of overwriting the restored rows.
|
||||||
|
return {
|
||||||
|
rows: 24,
|
||||||
|
cols,
|
||||||
|
scrollback: [],
|
||||||
|
scrollbackWrapped: [],
|
||||||
|
grid,
|
||||||
|
gridWrapped,
|
||||||
|
cursor: { row: grid.length, col: 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pinoLine(seq: number): string {
|
||||||
|
return `[server] [15:30:${String(seq % 60).padStart(2, "0")}.123] TRACE: provider.claude.raw_event {"module":"agent","seq":${seq},"sessionId":"ed8972f4-d3be-45d0-992c-631f8f1ed04e","turnId":"foreground-turn-1","payload":"${"A".repeat(40)}"}\r\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const m of mounted.splice(0)) {
|
||||||
|
m.runtime.unmount();
|
||||||
|
m.root.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("terminal resize reflow repro (Paseo terminal)", () => {
|
||||||
|
it("snapshot-restored rows stay frozen at the snapshot width after the terminal grows", async () => {
|
||||||
|
await page.viewport(1600, 700);
|
||||||
|
const m = mount(560, 360); // ~70 cols
|
||||||
|
await waitFor(() => window.__paseoTerminal !== undefined);
|
||||||
|
const narrowCols = window.__paseoTerminal?.cols ?? 0;
|
||||||
|
|
||||||
|
// 1) Mid-stream snapshot arrives (server overflowed 256KB). It carries the
|
||||||
|
// long line wrapped at the server width as separate grid rows.
|
||||||
|
m.runtime.renderSnapshot({ state: buildWrappedSnapshot(narrowCols) });
|
||||||
|
await waitFor(() => someRowContains('"seq":1'));
|
||||||
|
|
||||||
|
// 2) Normal post-snapshot streaming resumes (autowrap on -> soft-wrapped).
|
||||||
|
for (let seq = 90; seq <= 96; seq++) {
|
||||||
|
m.runtime.write({ data: encodeTerminalOutput(pinoLine(seq)) });
|
||||||
|
}
|
||||||
|
await waitFor(() => someRowContains('"seq":96'));
|
||||||
|
|
||||||
|
// 3) User resizes the terminal wider.
|
||||||
|
m.root.style.width = "1480px";
|
||||||
|
await nextFrame();
|
||||||
|
m.runtime.resize({ force: true, shouldClaim: true });
|
||||||
|
await nextFrame();
|
||||||
|
await nextFrame();
|
||||||
|
const wideCols = window.__paseoTerminal?.cols ?? 0;
|
||||||
|
|
||||||
|
const rows = dumpRows();
|
||||||
|
const snapshotRowLen =
|
||||||
|
rows.find((r) => r.text.startsWith("[server] [15:30:00"))?.text.length ?? -1;
|
||||||
|
const streamedRowLen =
|
||||||
|
rows.find((r) => r.text.startsWith("[server] [15:30:30"))?.text.length ?? -1;
|
||||||
|
|
||||||
|
expect(wideCols).toBeGreaterThan(narrowCols + 20);
|
||||||
|
// Post-snapshot streamed output reflows to fill the wide terminal.
|
||||||
|
expect(streamedRowLen).toBeGreaterThan(narrowCols + 20);
|
||||||
|
// Snapshot-restored content reflows too, instead of staying frozen at the
|
||||||
|
// snapshot width — the reported bug.
|
||||||
|
expect(snapshotRowLen).toBeGreaterThan(narrowCols + 20);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4279,6 +4279,7 @@ export class DaemonClient {
|
|||||||
capabilities: {
|
capabilities: {
|
||||||
[CLIENT_CAPS.customModeIcons]: true,
|
[CLIENT_CAPS.customModeIcons]: true,
|
||||||
[CLIENT_CAPS.reasoningMergeEnum]: true,
|
[CLIENT_CAPS.reasoningMergeEnum]: true,
|
||||||
|
[CLIENT_CAPS.terminalReflowableSnapshot]: true,
|
||||||
},
|
},
|
||||||
...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}),
|
...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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(
|
expect(
|
||||||
decodeFileTransferFrame(
|
decodeFileTransferFrame(
|
||||||
encodeFileTransferFrame({
|
encodeFileTransferFrame({
|
||||||
@@ -135,7 +135,18 @@ describe("file transfer binary frames", () => {
|
|||||||
new DataView(encoded.buffer).setUint16(2 + requestId.byteLength, json.byteLength);
|
new DataView(encoded.buffer).setUint16(2 + requestId.byteLength, json.byteLength);
|
||||||
encoded.set(json, 4 + requestId.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", () => {
|
it("rejects malformed request id prefixes and frame tails", () => {
|
||||||
|
|||||||
@@ -9,14 +9,12 @@ export const FileTransferOpcode = {
|
|||||||
|
|
||||||
export type FileTransferOpcode = (typeof FileTransferOpcode)[keyof typeof FileTransferOpcode];
|
export type FileTransferOpcode = (typeof FileTransferOpcode)[keyof typeof FileTransferOpcode];
|
||||||
|
|
||||||
export const FileBeginMetadataSchema = z
|
export const FileBeginMetadataSchema = z.object({
|
||||||
.object({
|
mime: z.string().min(1),
|
||||||
mime: z.string().min(1),
|
size: z.number().int().nonnegative(),
|
||||||
size: z.number().int().nonnegative(),
|
encoding: z.enum(["utf-8", "binary"]),
|
||||||
encoding: z.enum(["utf-8", "binary"]),
|
modifiedAt: z.string(),
|
||||||
modifiedAt: z.string(),
|
});
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
export interface FileBegin {
|
export interface FileBegin {
|
||||||
opcode: typeof FileTransferOpcode.FileBegin;
|
opcode: typeof FileTransferOpcode.FileBegin;
|
||||||
|
|||||||
@@ -91,24 +91,26 @@ describe("terminal binary frames", () => {
|
|||||||
).toBeNull();
|
).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(
|
expect(
|
||||||
decodeTerminalResizePayload(
|
decodeTerminalResizePayload(
|
||||||
new TextEncoder().encode(JSON.stringify({ rows: 24, cols: 80, extra: true })),
|
new TextEncoder().encode(JSON.stringify({ rows: 24, cols: 80, extra: true })),
|
||||||
),
|
),
|
||||||
).toBeNull();
|
).toEqual({ rows: 24, cols: 80 });
|
||||||
expect(
|
|
||||||
decodeTerminalSnapshotPayload(
|
const snapshot = decodeTerminalSnapshotPayload(
|
||||||
new TextEncoder().encode(
|
new TextEncoder().encode(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
rows: 1,
|
rows: 1,
|
||||||
cols: 1,
|
cols: 1,
|
||||||
grid: [[{ char: "A", extra: true }]],
|
grid: [[{ char: "A", extra: true }]],
|
||||||
scrollback: [],
|
scrollback: [],
|
||||||
cursor: { row: 0, col: 1 },
|
cursor: { row: 0, col: 1 },
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
).toBeNull();
|
);
|
||||||
|
expect(snapshot?.grid[0]?.[0]).toEqual({ char: "A" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { TerminalStateSchema } from "../messages.js";
|
import { TerminalStateSchema } from "../messages.js";
|
||||||
|
|
||||||
export const TerminalStreamResizeSchema = z
|
export const TerminalStreamResizeSchema = z.object({
|
||||||
.object({
|
rows: z.number().int().positive(),
|
||||||
rows: z.number().int().positive(),
|
cols: z.number().int().positive(),
|
||||||
cols: z.number().int().positive(),
|
});
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
export const TerminalStreamOpcode = {
|
export const TerminalStreamOpcode = {
|
||||||
Output: 0x01,
|
Output: 0x01,
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ export const CLIENT_CAPS = {
|
|||||||
// outside the legacy set to "ShieldCheck" when this cap is absent. Drop the
|
// outside the legacy set to "ShieldCheck" when this cap is absent. Drop the
|
||||||
// gate when floor >= v0.1.84.
|
// gate when floor >= v0.1.84.
|
||||||
customModeIcons: "custom_mode_icons",
|
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;
|
} as const;
|
||||||
|
|
||||||
export type ClientCapability = (typeof CLIENT_CAPS)[keyof typeof CLIENT_CAPS];
|
export type ClientCapability = (typeof CLIENT_CAPS)[keyof typeof CLIENT_CAPS];
|
||||||
|
|||||||
@@ -64,7 +64,13 @@ describe("shared messages tool_call schema", () => {
|
|||||||
error: null,
|
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(),
|
...canonicalBase(),
|
||||||
status: "running",
|
status: "running",
|
||||||
error: null,
|
error: null,
|
||||||
@@ -72,9 +78,7 @@ describe("shared messages tool_call schema", () => {
|
|||||||
output: { exitCode: 0 },
|
output: { exitCode: 0 },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(missingCallId.success).toBe(false);
|
expect(parsed.success).toBe(true);
|
||||||
expect(unknownStatus.success).toBe(false);
|
|
||||||
expect(withTopLevelInputOutput.success).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects legacy status/error combinations without normalization", () => {
|
it("rejects legacy status/error combinations without normalization", () => {
|
||||||
|
|||||||
@@ -485,15 +485,13 @@ const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail, z.ZodTypeDef, unkno
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const ToolCallBasePayloadSchema = z
|
const ToolCallBasePayloadSchema = z.object({
|
||||||
.object({
|
type: z.literal("tool_call"),
|
||||||
type: z.literal("tool_call"),
|
callId: z.string(),
|
||||||
callId: z.string(),
|
name: z.string(),
|
||||||
name: z.string(),
|
detail: ToolCallDetailPayloadSchema,
|
||||||
detail: ToolCallDetailPayloadSchema,
|
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
});
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
const ToolCallRunningPayloadSchema = ToolCallBasePayloadSchema.extend({
|
const ToolCallRunningPayloadSchema = ToolCallBasePayloadSchema.extend({
|
||||||
status: z.literal("running"),
|
status: z.literal("running"),
|
||||||
@@ -1808,10 +1806,8 @@ export const SubscribeTerminalRequestSchema = z.object({
|
|||||||
rows: z.number().int().positive(),
|
rows: z.number().int().positive(),
|
||||||
cols: z.number().int().positive(),
|
cols: z.number().int().positive(),
|
||||||
})
|
})
|
||||||
.strict()
|
|
||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
|
||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3540,44 +3536,45 @@ const TerminalInfoSchema = z.object({
|
|||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const TerminalCellSchema = z
|
export const TerminalCellSchema = z.object({
|
||||||
.object({
|
char: z.string(),
|
||||||
char: z.string(),
|
fg: z.number().optional(),
|
||||||
fg: z.number().optional(),
|
bg: z.number().optional(),
|
||||||
bg: z.number().optional(),
|
fgMode: z.number().optional(),
|
||||||
fgMode: z.number().optional(),
|
bgMode: z.number().optional(),
|
||||||
bgMode: z.number().optional(),
|
bold: z.boolean().optional(),
|
||||||
bold: z.boolean().optional(),
|
italic: z.boolean().optional(),
|
||||||
italic: z.boolean().optional(),
|
underline: z.boolean().optional(),
|
||||||
underline: z.boolean().optional(),
|
dim: z.boolean().optional(),
|
||||||
dim: z.boolean().optional(),
|
inverse: z.boolean().optional(),
|
||||||
inverse: z.boolean().optional(),
|
strikethrough: z.boolean().optional(),
|
||||||
strikethrough: z.boolean().optional(),
|
});
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
export const TerminalCursorStyleSchema = z.enum(["block", "underline", "bar"]);
|
export const TerminalCursorStyleSchema = z.enum(["block", "underline", "bar"]);
|
||||||
|
|
||||||
export const TerminalCursorSchema = z
|
export const TerminalCursorSchema = z.object({
|
||||||
.object({
|
row: z.number(),
|
||||||
row: z.number(),
|
col: z.number(),
|
||||||
col: z.number(),
|
hidden: z.boolean().optional(),
|
||||||
hidden: z.boolean().optional(),
|
style: TerminalCursorStyleSchema.optional(),
|
||||||
style: TerminalCursorStyleSchema.optional(),
|
blink: z.boolean().optional(),
|
||||||
blink: z.boolean().optional(),
|
});
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
export const TerminalStateSchema = z
|
export const TerminalStateSchema = z.object({
|
||||||
.object({
|
rows: z.number(),
|
||||||
rows: z.number(),
|
cols: z.number(),
|
||||||
cols: z.number(),
|
grid: z.array(z.array(TerminalCellSchema)),
|
||||||
grid: z.array(z.array(TerminalCellSchema)),
|
scrollback: z.array(z.array(TerminalCellSchema)),
|
||||||
scrollback: z.array(z.array(TerminalCellSchema)),
|
cursor: TerminalCursorSchema,
|
||||||
cursor: TerminalCursorSchema,
|
title: z.string().optional(),
|
||||||
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),
|
||||||
.strict();
|
// 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({
|
export const ListTerminalsResponseSchema = z.object({
|
||||||
type: z.literal("list_terminals_response"),
|
type: z.literal("list_terminals_response"),
|
||||||
@@ -4108,6 +4105,7 @@ export const WSHelloMessageSchema = z.object({
|
|||||||
pushNotifications: z.boolean().optional(),
|
pushNotifications: z.boolean().optional(),
|
||||||
[CLIENT_CAPS.reasoningMergeEnum]: z.boolean().optional(),
|
[CLIENT_CAPS.reasoningMergeEnum]: z.boolean().optional(),
|
||||||
[CLIENT_CAPS.customModeIcons]: z.boolean().optional(),
|
[CLIENT_CAPS.customModeIcons]: z.boolean().optional(),
|
||||||
|
[CLIENT_CAPS.terminalReflowableSnapshot]: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.passthrough()
|
.passthrough()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@@ -2,25 +2,19 @@ import { z } from "zod";
|
|||||||
import type { AgentProvider } from "./agent-types.js";
|
import type { AgentProvider } from "./agent-types.js";
|
||||||
import { AgentProviderSchema } from "./provider-manifest.js";
|
import { AgentProviderSchema } from "./provider-manifest.js";
|
||||||
|
|
||||||
const ProviderCommandDefaultSchema = z
|
const ProviderCommandDefaultSchema = z.object({
|
||||||
.object({
|
mode: z.literal("default"),
|
||||||
mode: z.literal("default"),
|
});
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
const ProviderCommandAppendSchema = z
|
const ProviderCommandAppendSchema = z.object({
|
||||||
.object({
|
mode: z.literal("append"),
|
||||||
mode: z.literal("append"),
|
args: z.array(z.string()).optional(),
|
||||||
args: z.array(z.string()).optional(),
|
});
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
const ProviderCommandReplaceSchema = z
|
const ProviderCommandReplaceSchema = z.object({
|
||||||
.object({
|
mode: z.literal("replace"),
|
||||||
mode: z.literal("replace"),
|
argv: z.array(z.string().min(1)).min(1),
|
||||||
argv: z.array(z.string().min(1)).min(1),
|
});
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
export const ProviderCommandSchema = z.discriminatedUnion("mode", [
|
export const ProviderCommandSchema = z.discriminatedUnion("mode", [
|
||||||
ProviderCommandDefaultSchema,
|
ProviderCommandDefaultSchema,
|
||||||
@@ -28,47 +22,39 @@ export const ProviderCommandSchema = z.discriminatedUnion("mode", [
|
|||||||
ProviderCommandReplaceSchema,
|
ProviderCommandReplaceSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const ProviderRuntimeSettingsSchema = z
|
export const ProviderRuntimeSettingsSchema = z.object({
|
||||||
.object({
|
command: ProviderCommandSchema.optional(),
|
||||||
command: ProviderCommandSchema.optional(),
|
env: z.record(z.string()).optional(),
|
||||||
env: z.record(z.string()).optional(),
|
disallowedTools: z.array(z.string()).optional(),
|
||||||
disallowedTools: z.array(z.string()).optional(),
|
});
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
const ProviderProfileThinkingOptionSchema = z
|
const ProviderProfileThinkingOptionSchema = z.object({
|
||||||
.object({
|
id: z.string(),
|
||||||
id: z.string(),
|
label: z.string(),
|
||||||
label: z.string(),
|
description: z.string().optional(),
|
||||||
description: z.string().optional(),
|
isDefault: z.boolean().optional(),
|
||||||
isDefault: z.boolean().optional(),
|
});
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
export const ProviderProfileModelSchema = z
|
export const ProviderProfileModelSchema = z.object({
|
||||||
.object({
|
id: z.string().min(1),
|
||||||
id: z.string().min(1),
|
label: z.string().min(1),
|
||||||
label: z.string().min(1),
|
description: z.string().optional(),
|
||||||
description: z.string().optional(),
|
isDefault: z.boolean().optional(),
|
||||||
isDefault: z.boolean().optional(),
|
thinkingOptions: z.array(ProviderProfileThinkingOptionSchema).optional(),
|
||||||
thinkingOptions: z.array(ProviderProfileThinkingOptionSchema).optional(),
|
});
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
export const ProviderOverrideSchema = z
|
export const ProviderOverrideSchema = z.object({
|
||||||
.object({
|
extends: z.string().optional(),
|
||||||
extends: z.string().optional(),
|
label: z.string().optional(),
|
||||||
label: z.string().optional(),
|
description: z.string().optional(),
|
||||||
description: z.string().optional(),
|
command: z.array(z.string().min(1)).min(1).optional(),
|
||||||
command: z.array(z.string().min(1)).min(1).optional(),
|
env: z.record(z.string()).optional(),
|
||||||
env: z.record(z.string()).optional(),
|
models: z.array(ProviderProfileModelSchema).optional(),
|
||||||
models: z.array(ProviderProfileModelSchema).optional(),
|
additionalModels: z.array(ProviderProfileModelSchema).optional(),
|
||||||
additionalModels: z.array(ProviderProfileModelSchema).optional(),
|
disallowedTools: z.array(z.string()).optional(),
|
||||||
disallowedTools: z.array(z.string()).optional(),
|
enabled: z.boolean().optional(),
|
||||||
enabled: z.boolean().optional(),
|
order: z.number().optional(),
|
||||||
order: z.number().optional(),
|
});
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
const BUILTIN_PROVIDER_IDS = ["claude", "codex", "copilot", "opencode", "pi"] as const;
|
const BUILTIN_PROVIDER_IDS = ["claude", "codex", "copilot", "opencode", "pi"] as const;
|
||||||
const PROVIDER_ID_PATTERN = /^[a-z][a-z0-9-]*$/;
|
const PROVIDER_ID_PATTERN = /^[a-z][a-z0-9-]*$/;
|
||||||
|
|||||||
49
packages/protocol/src/terminal-snapshot.test.ts
Normal file
49
packages/protocol/src/terminal-snapshot.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,12 +28,22 @@ const DEFAULT_STYLE: TerminalStyle = {
|
|||||||
|
|
||||||
export function renderTerminalSnapshotToAnsi(state: TerminalState): string {
|
export function renderTerminalSnapshotToAnsi(state: TerminalState): string {
|
||||||
const rows = [...state.scrollback, ...state.grid];
|
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) {
|
for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
|
||||||
const row = rows[rowIndex] ?? [];
|
const row = rows[rowIndex] ?? [];
|
||||||
lines.push(renderTerminalRow(row));
|
const continuesToNextRow = hasWrapInfo && wrapFlags[rowIndex] === true;
|
||||||
if (rowIndex < rows.length - 1) {
|
// 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");
|
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(`\u001b[${state.cursor.row + 1};${state.cursor.col + 1}H`);
|
||||||
lines.push(state.cursor.hidden ? "\u001b[?25l" : "\u001b[?25h");
|
lines.push(state.cursor.hidden ? "\u001b[?25l" : "\u001b[?25h");
|
||||||
lines.push("\u001b[?7h");
|
if (!hasWrapInfo) {
|
||||||
|
lines.push("\u001b[?7h");
|
||||||
|
}
|
||||||
return lines.join("");
|
return lines.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,9 +79,10 @@ function renderCursorPresentationToAnsi(cursor: TerminalState["cursor"]): string
|
|||||||
return `\u001b[${cursorStyleCode} q`;
|
return `\u001b[${cursorStyleCode} q`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTerminalRow(row: TerminalCell[]): string {
|
function renderTerminalRow(row: TerminalCell[], padToCols?: number): string {
|
||||||
const output: 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;
|
let previousStyle = DEFAULT_STYLE;
|
||||||
|
|
||||||
for (let index = 0; index < length; index += 1) {
|
for (let index = 0; index < length; index += 1) {
|
||||||
|
|||||||
@@ -914,6 +914,8 @@ export class Session {
|
|||||||
hasBinaryChannel: () => this.onBinaryMessage !== null,
|
hasBinaryChannel: () => this.onBinaryMessage !== null,
|
||||||
isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath),
|
isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath),
|
||||||
sessionLogger: this.sessionLogger,
|
sessionLogger: this.sessionLogger,
|
||||||
|
clientSupportsWrapReflow: () =>
|
||||||
|
this.clientCapabilities.has(CLIENT_CAPS.terminalReflowableSnapshot),
|
||||||
});
|
});
|
||||||
this.createAgentLifecycleDispatch = new CreateAgentLifecycleDispatch({
|
this.createAgentLifecycleDispatch = new CreateAgentLifecycleDispatch({
|
||||||
paseoHome: this.paseoHome,
|
paseoHome: this.paseoHome,
|
||||||
|
|||||||
@@ -168,6 +168,91 @@ async function flushMicrotasks(): Promise<void> {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe("terminal-session-controller wrap-flag gating", () => {
|
||||||
|
function setup(clientSupportsWrapReflow?: () => boolean): {
|
||||||
|
controller: TerminalSessionController;
|
||||||
|
getTerminalState: ReturnType<typeof vi.fn>;
|
||||||
|
} {
|
||||||
|
const terminal: TerminalSession = {
|
||||||
|
id: "term-1",
|
||||||
|
name: "Terminal",
|
||||||
|
cwd: "/tmp",
|
||||||
|
send: vi.fn(),
|
||||||
|
subscribe: (listener) => {
|
||||||
|
queueMicrotask(() => listener({ type: "snapshotReady", revision: 1 }));
|
||||||
|
return vi.fn();
|
||||||
|
},
|
||||||
|
onExit: () => vi.fn(),
|
||||||
|
onCommandFinished: () => vi.fn(),
|
||||||
|
onTitleChange: () => vi.fn(),
|
||||||
|
getSize: () => ({ rows: 1, cols: 80 }),
|
||||||
|
getState: () => terminalState("hello"),
|
||||||
|
getStateSnapshot: () => ({ state: terminalState("hello"), revision: 1 }),
|
||||||
|
getReplayPreamble: () => "",
|
||||||
|
getTitle: () => undefined,
|
||||||
|
setTitle: vi.fn(),
|
||||||
|
getExitInfo: () => null,
|
||||||
|
kill: vi.fn(),
|
||||||
|
killAndWait: vi.fn(),
|
||||||
|
};
|
||||||
|
const getTerminalState = vi.fn(() =>
|
||||||
|
Promise.resolve<TerminalStateSnapshot>({ state: terminalState("hello"), revision: 1 }),
|
||||||
|
);
|
||||||
|
const terminalManager = {
|
||||||
|
getTerminals: vi.fn(),
|
||||||
|
createTerminal: vi.fn(),
|
||||||
|
registerCwdEnv: vi.fn(),
|
||||||
|
getTerminal: vi.fn(() => terminal),
|
||||||
|
getTerminalState,
|
||||||
|
setTerminalTitle: vi.fn(),
|
||||||
|
killTerminal: vi.fn(),
|
||||||
|
killTerminalAndWait: vi.fn(),
|
||||||
|
captureTerminal: vi.fn(),
|
||||||
|
listDirectories: vi.fn(() => []),
|
||||||
|
killAll: vi.fn(),
|
||||||
|
subscribeTerminalsChanged: vi.fn(() => vi.fn()),
|
||||||
|
} as unknown as TerminalManager;
|
||||||
|
const controller = new TerminalSessionController({
|
||||||
|
terminalManager,
|
||||||
|
emit: vi.fn(),
|
||||||
|
emitBinary: vi.fn(),
|
||||||
|
hasBinaryChannel: () => true,
|
||||||
|
isPathWithinRoot: () => false,
|
||||||
|
sessionLogger: createLogger(),
|
||||||
|
...(clientSupportsWrapReflow ? { clientSupportsWrapReflow } : {}),
|
||||||
|
});
|
||||||
|
return { controller, getTerminalState };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function subscribe(controller: TerminalSessionController): Promise<void> {
|
||||||
|
await controller.dispatch({
|
||||||
|
type: "subscribe_terminal_request",
|
||||||
|
terminalId: "term-1",
|
||||||
|
requestId: "req-1",
|
||||||
|
restore: { mode: "visible-snapshot", scrollbackLines: 200 },
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("requests wrap flags when the client supports reflowable snapshots", async () => {
|
||||||
|
const { controller, getTerminalState } = setup(() => true);
|
||||||
|
await subscribe(controller);
|
||||||
|
expect(getTerminalState).toHaveBeenCalledWith(
|
||||||
|
"term-1",
|
||||||
|
expect.objectContaining({ includeWrapFlags: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("omits wrap flags when the client does not advertise support", async () => {
|
||||||
|
const { controller, getTerminalState } = setup();
|
||||||
|
await subscribe(controller);
|
||||||
|
expect(getTerminalState).toHaveBeenCalledWith(
|
||||||
|
"term-1",
|
||||||
|
expect.objectContaining({ includeWrapFlags: false }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("terminal-session-controller subdirectory aggregation", () => {
|
describe("terminal-session-controller subdirectory aggregation", () => {
|
||||||
test("delivers a subdirectory change to a root subscriber as an aggregated, root-keyed snapshot", async () => {
|
test("delivers a subdirectory change to a root subscriber as an aggregated, root-keyed snapshot", async () => {
|
||||||
const rootCwd = "/work/repo";
|
const rootCwd = "/work/repo";
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ export interface TerminalSessionControllerOptions {
|
|||||||
hasBinaryChannel: () => boolean;
|
hasBinaryChannel: () => boolean;
|
||||||
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
|
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
|
||||||
sessionLogger: pino.Logger;
|
sessionLogger: pino.Logger;
|
||||||
|
// Whether the connected client can reflow restored snapshots. When true the
|
||||||
|
// daemon attaches per-row soft-wrap flags to snapshots; otherwise it omits them
|
||||||
|
// so old (strict-schema) clients still parse the snapshot.
|
||||||
|
clientSupportsWrapReflow?: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TerminalSessionControllerMetrics {
|
export interface TerminalSessionControllerMetrics {
|
||||||
@@ -104,6 +108,7 @@ export class TerminalSessionController {
|
|||||||
private readonly hasBinaryChannel: () => boolean;
|
private readonly hasBinaryChannel: () => boolean;
|
||||||
private readonly isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
|
private readonly isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
|
||||||
private readonly sessionLogger: pino.Logger;
|
private readonly sessionLogger: pino.Logger;
|
||||||
|
private readonly clientSupportsWrapReflow: () => boolean;
|
||||||
|
|
||||||
private readonly subscribedDirectories = new Set<string>();
|
private readonly subscribedDirectories = new Set<string>();
|
||||||
private unsubscribeTerminalsChanged: (() => void) | null = null;
|
private unsubscribeTerminalsChanged: (() => void) | null = null;
|
||||||
@@ -119,6 +124,7 @@ export class TerminalSessionController {
|
|||||||
this.hasBinaryChannel = options.hasBinaryChannel;
|
this.hasBinaryChannel = options.hasBinaryChannel;
|
||||||
this.isPathWithinRoot = options.isPathWithinRoot;
|
this.isPathWithinRoot = options.isPathWithinRoot;
|
||||||
this.sessionLogger = options.sessionLogger;
|
this.sessionLogger = options.sessionLogger;
|
||||||
|
this.clientSupportsWrapReflow = options.clientSupportsWrapReflow ?? (() => false);
|
||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
@@ -801,7 +807,9 @@ export class TerminalSessionController {
|
|||||||
activeStream: ActiveTerminalStream,
|
activeStream: ActiveTerminalStream,
|
||||||
terminalManager: TerminalManager,
|
terminalManager: TerminalManager,
|
||||||
): Promise<SnapshotSendResult> {
|
): Promise<SnapshotSendResult> {
|
||||||
const snapshot = await terminalManager.getTerminalState(activeStream.terminalId);
|
const snapshot = await terminalManager.getTerminalState(activeStream.terminalId, {
|
||||||
|
includeWrapFlags: this.clientSupportsWrapReflow(),
|
||||||
|
});
|
||||||
if (this.activeStreams.get(activeStream.slot) !== activeStream) {
|
if (this.activeStreams.get(activeStream.slot) !== activeStream) {
|
||||||
return { shouldContinue: false };
|
return { shouldContinue: false };
|
||||||
}
|
}
|
||||||
@@ -829,10 +837,10 @@ export class TerminalSessionController {
|
|||||||
return { shouldContinue: true };
|
return { shouldContinue: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = await terminalManager.getTerminalState(
|
const snapshot = await terminalManager.getTerminalState(activeStream.terminalId, {
|
||||||
activeStream.terminalId,
|
...snapshotOptions,
|
||||||
snapshotOptions,
|
includeWrapFlags: this.clientSupportsWrapReflow(),
|
||||||
);
|
});
|
||||||
if (this.activeStreams.get(activeStream.slot) !== activeStream) {
|
if (this.activeStreams.get(activeStream.slot) !== activeStream) {
|
||||||
return { shouldContinue: false };
|
return { shouldContinue: false };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -279,6 +279,37 @@ describe("createTerminal", () => {
|
|||||||
expect(state.cols).toBe(120);
|
expect(state.cols).toBe(120);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reports per-row soft-wrap flags only when wrap flags are requested", async () => {
|
||||||
|
const session = trackSession(
|
||||||
|
await createTerminal({
|
||||||
|
cwd: realpathSync(tmpdir()),
|
||||||
|
cols: 40,
|
||||||
|
rows: 10,
|
||||||
|
command: process.execPath,
|
||||||
|
// 100 chars with no newline soft-wraps across three rows at 40 cols.
|
||||||
|
args: ["-e", "process.stdout.write('A'.repeat(100)); setInterval(() => {}, 100000);"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < 5000) {
|
||||||
|
if (rowToText(session.getState().grid[2]).startsWith("A")) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rows 0 and 1 continue onto the next row; row 2 is the end of the logical line.
|
||||||
|
const withFlags = session.getState({ includeWrapFlags: true });
|
||||||
|
expect(withFlags.gridWrapped?.slice(0, 3)).toEqual([true, true, false]);
|
||||||
|
|
||||||
|
// Back-compat gate: without the capability the daemon must not attach the new
|
||||||
|
// fields, so an old strict-schema client still parses the snapshot.
|
||||||
|
const withoutFlags = session.getState();
|
||||||
|
expect(withoutFlags.gridWrapped).toBeUndefined();
|
||||||
|
expect(withoutFlags.scrollbackWrapped).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("captures exit diagnostics from the terminal buffer", async () => {
|
it("captures exit diagnostics from the terminal buffer", async () => {
|
||||||
const session = trackSession(
|
const session = trackSession(
|
||||||
await createTerminal({
|
await createTerminal({
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ export interface TerminalStateSnapshot {
|
|||||||
|
|
||||||
export interface TerminalStateSnapshotOptions {
|
export interface TerminalStateSnapshotOptions {
|
||||||
scrollbackLines?: number;
|
scrollbackLines?: number;
|
||||||
|
// Include per-row soft-wrap flags (gridWrapped/scrollbackWrapped) so the client
|
||||||
|
// can reflow restored content on resize. Gated on a client capability, so old
|
||||||
|
// clients never receive the extra fields.
|
||||||
|
includeWrapFlags?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TerminalSubscribeOptions {
|
export interface TerminalSubscribeOptions {
|
||||||
@@ -359,6 +363,39 @@ function extractScrollback(
|
|||||||
return scrollback;
|
return scrollback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// xterm marks a line `isWrapped` when it is a continuation of the PREVIOUS line.
|
||||||
|
// The snapshot carries the inverse, tmux-style flag — "this row continues onto the
|
||||||
|
// next row" — so the client can rejoin and reflow logical lines. So row y's flag is
|
||||||
|
// whether line y+1 is a wrapped continuation.
|
||||||
|
function lineContinuesToNext(terminal: TerminalType, absoluteRow: number): boolean {
|
||||||
|
return terminal.buffer.active.getLine(absoluteRow + 1)?.isWrapped === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractGridWrapped(terminal: TerminalType): boolean[] {
|
||||||
|
const baseY = terminal.buffer.active.baseY;
|
||||||
|
const wrapped: boolean[] = [];
|
||||||
|
for (let row = 0; row < terminal.rows; row++) {
|
||||||
|
wrapped.push(lineContinuesToNext(terminal, baseY + row));
|
||||||
|
}
|
||||||
|
return wrapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractScrollbackWrapped(
|
||||||
|
terminal: TerminalType,
|
||||||
|
options?: { scrollbackLines?: number },
|
||||||
|
): boolean[] {
|
||||||
|
const scrollbackLines = terminal.buffer.active.baseY;
|
||||||
|
const startRow =
|
||||||
|
typeof options?.scrollbackLines === "number"
|
||||||
|
? Math.max(0, scrollbackLines - options.scrollbackLines)
|
||||||
|
: 0;
|
||||||
|
const wrapped: boolean[] = [];
|
||||||
|
for (let row = startRow; row < scrollbackLines; row++) {
|
||||||
|
wrapped.push(lineContinuesToNext(terminal, row));
|
||||||
|
}
|
||||||
|
return wrapped;
|
||||||
|
}
|
||||||
|
|
||||||
function extractCursorState(terminal: TerminalType): TerminalState["cursor"] {
|
function extractCursorState(terminal: TerminalType): TerminalState["cursor"] {
|
||||||
const coreService = (terminal as unknown as { _core?: { coreService?: Record<string, unknown> } })
|
const coreService = (terminal as unknown as { _core?: { coreService?: Record<string, unknown> } })
|
||||||
._core?.coreService as
|
._core?.coreService as
|
||||||
@@ -853,6 +890,14 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
}),
|
}),
|
||||||
cursor: extractCursorState(terminal),
|
cursor: extractCursorState(terminal),
|
||||||
...(title ? { title } : {}),
|
...(title ? { title } : {}),
|
||||||
|
...(snapshotOptions?.includeWrapFlags
|
||||||
|
? {
|
||||||
|
gridWrapped: extractGridWrapped(terminal),
|
||||||
|
scrollbackWrapped: extractScrollbackWrapped(terminal, {
|
||||||
|
scrollbackLines: snapshotOptions?.scrollbackLines,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user