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:
@@ -914,6 +914,8 @@ export class Session {
|
||||
hasBinaryChannel: () => this.onBinaryMessage !== null,
|
||||
isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath),
|
||||
sessionLogger: this.sessionLogger,
|
||||
clientSupportsWrapReflow: () =>
|
||||
this.clientCapabilities.has(CLIENT_CAPS.terminalReflowableSnapshot),
|
||||
});
|
||||
this.createAgentLifecycleDispatch = new CreateAgentLifecycleDispatch({
|
||||
paseoHome: this.paseoHome,
|
||||
|
||||
@@ -168,6 +168,91 @@ async function flushMicrotasks(): Promise<void> {
|
||||
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", () => {
|
||||
test("delivers a subdirectory change to a root subscriber as an aggregated, root-keyed snapshot", async () => {
|
||||
const rootCwd = "/work/repo";
|
||||
|
||||
@@ -65,6 +65,10 @@ export interface TerminalSessionControllerOptions {
|
||||
hasBinaryChannel: () => boolean;
|
||||
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
|
||||
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 {
|
||||
@@ -104,6 +108,7 @@ export class TerminalSessionController {
|
||||
private readonly hasBinaryChannel: () => boolean;
|
||||
private readonly isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
|
||||
private readonly sessionLogger: pino.Logger;
|
||||
private readonly clientSupportsWrapReflow: () => boolean;
|
||||
|
||||
private readonly subscribedDirectories = new Set<string>();
|
||||
private unsubscribeTerminalsChanged: (() => void) | null = null;
|
||||
@@ -119,6 +124,7 @@ export class TerminalSessionController {
|
||||
this.hasBinaryChannel = options.hasBinaryChannel;
|
||||
this.isPathWithinRoot = options.isPathWithinRoot;
|
||||
this.sessionLogger = options.sessionLogger;
|
||||
this.clientSupportsWrapReflow = options.clientSupportsWrapReflow ?? (() => false);
|
||||
}
|
||||
|
||||
start(): void {
|
||||
@@ -801,7 +807,9 @@ export class TerminalSessionController {
|
||||
activeStream: ActiveTerminalStream,
|
||||
terminalManager: TerminalManager,
|
||||
): 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) {
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
@@ -829,10 +837,10 @@ export class TerminalSessionController {
|
||||
return { shouldContinue: true };
|
||||
}
|
||||
|
||||
const snapshot = await terminalManager.getTerminalState(
|
||||
activeStream.terminalId,
|
||||
snapshotOptions,
|
||||
);
|
||||
const snapshot = await terminalManager.getTerminalState(activeStream.terminalId, {
|
||||
...snapshotOptions,
|
||||
includeWrapFlags: this.clientSupportsWrapReflow(),
|
||||
});
|
||||
if (this.activeStreams.get(activeStream.slot) !== activeStream) {
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
|
||||
@@ -279,6 +279,37 @@ describe("createTerminal", () => {
|
||||
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 () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
|
||||
@@ -40,6 +40,10 @@ export interface TerminalStateSnapshot {
|
||||
|
||||
export interface TerminalStateSnapshotOptions {
|
||||
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 {
|
||||
@@ -359,6 +363,39 @@ function extractScrollback(
|
||||
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"] {
|
||||
const coreService = (terminal as unknown as { _core?: { coreService?: Record<string, unknown> } })
|
||||
._core?.coreService as
|
||||
@@ -853,6 +890,14 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
}),
|
||||
cursor: extractCursorState(terminal),
|
||||
...(title ? { title } : {}),
|
||||
...(snapshotOptions?.includeWrapFlags
|
||||
? {
|
||||
gridWrapped: extractGridWrapped(terminal),
|
||||
scrollbackWrapped: extractScrollbackWrapped(terminal, {
|
||||
scrollbackLines: snapshotOptions?.scrollbackLines,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user