diff --git a/packages/app/package.json b/packages/app/package.json index d42e3f011..68734b15c 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -4,7 +4,7 @@ "private": true, "main": "index.ts", "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", "reset-project": "node ./scripts/reset-project.js", "eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:terminal-webview", diff --git a/packages/app/src/terminal/runtime/terminal-resize-repro.browser.test.ts b/packages/app/src/terminal/runtime/terminal-resize-repro.browser.test.ts new file mode 100644 index 000000000..1b733ae94 --- /dev/null +++ b/packages/app/src/terminal/runtime/terminal-resize-repro.browser.test.ts @@ -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 { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +async function waitFor(predicate: () => boolean, timeoutMs = 4000): Promise { + 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); + }); +}); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 64560f33b..d1ee7af04 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -4279,6 +4279,7 @@ export class DaemonClient { capabilities: { [CLIENT_CAPS.customModeIcons]: true, [CLIENT_CAPS.reasoningMergeEnum]: true, + [CLIENT_CAPS.terminalReflowableSnapshot]: true, }, ...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}), }), diff --git a/packages/protocol/src/binary-frames/file-transfer.test.ts b/packages/protocol/src/binary-frames/file-transfer.test.ts index 36139ed7f..15becc82e 100644 --- a/packages/protocol/src/binary-frames/file-transfer.test.ts +++ b/packages/protocol/src/binary-frames/file-transfer.test.ts @@ -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", () => { diff --git a/packages/protocol/src/binary-frames/file-transfer.ts b/packages/protocol/src/binary-frames/file-transfer.ts index 663aa0a80..1e3231c75 100644 --- a/packages/protocol/src/binary-frames/file-transfer.ts +++ b/packages/protocol/src/binary-frames/file-transfer.ts @@ -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; diff --git a/packages/protocol/src/binary-frames/terminal.test.ts b/packages/protocol/src/binary-frames/terminal.test.ts index f1a8be4af..308e2bc91 100644 --- a/packages/protocol/src/binary-frames/terminal.test.ts +++ b/packages/protocol/src/binary-frames/terminal.test.ts @@ -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" }); }); }); diff --git a/packages/protocol/src/binary-frames/terminal.ts b/packages/protocol/src/binary-frames/terminal.ts index 5c9c3839c..bf9cff055 100644 --- a/packages/protocol/src/binary-frames/terminal.ts +++ b/packages/protocol/src/binary-frames/terminal.ts @@ -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, diff --git a/packages/protocol/src/client-capabilities.ts b/packages/protocol/src/client-capabilities.ts index 0b602c137..1e82e5bab 100644 --- a/packages/protocol/src/client-capabilities.ts +++ b/packages/protocol/src/client-capabilities.ts @@ -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]; diff --git a/packages/protocol/src/messages.tool-call-schema.test.ts b/packages/protocol/src/messages.tool-call-schema.test.ts index 983ee8dd4..448bd3780 100644 --- a/packages/protocol/src/messages.tool-call-schema.test.ts +++ b/packages/protocol/src/messages.tool-call-schema.test.ts @@ -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", () => { diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 95ecf2a09..fb6303b5c 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -485,15 +485,13 @@ const ToolCallDetailPayloadSchema: z.ZodType ({ 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"); + }); +}); diff --git a/packages/protocol/src/terminal-snapshot.ts b/packages/protocol/src/terminal-snapshot.ts index ab17b0841..f978cd884 100644 --- a/packages/protocol/src/terminal-snapshot.ts +++ b/packages/protocol/src/terminal-snapshot.ts @@ -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) { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 368dfa034..ffc31024a 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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, diff --git a/packages/server/src/terminal/terminal-session-controller.test.ts b/packages/server/src/terminal/terminal-session-controller.test.ts index 58245a7e5..b22288337 100644 --- a/packages/server/src/terminal/terminal-session-controller.test.ts +++ b/packages/server/src/terminal/terminal-session-controller.test.ts @@ -168,6 +168,91 @@ async function flushMicrotasks(): Promise { await new Promise((resolve) => setTimeout(resolve, 0)); } +describe("terminal-session-controller wrap-flag gating", () => { + function setup(clientSupportsWrapReflow?: () => boolean): { + controller: TerminalSessionController; + getTerminalState: ReturnType; + } { + 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({ 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 { + 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"; diff --git a/packages/server/src/terminal/terminal-session-controller.ts b/packages/server/src/terminal/terminal-session-controller.ts index b245cabe3..678b2c6c2 100644 --- a/packages/server/src/terminal/terminal-session-controller.ts +++ b/packages/server/src/terminal/terminal-session-controller.ts @@ -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(); 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 { - 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 }; } diff --git a/packages/server/src/terminal/terminal.test.ts b/packages/server/src/terminal/terminal.test.ts index b6b49c691..7160cc25e 100644 --- a/packages/server/src/terminal/terminal.test.ts +++ b/packages/server/src/terminal/terminal.test.ts @@ -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({ diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index 4d477565c..1d2fefe53 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -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 } }) ._core?.coreService as @@ -853,6 +890,14 @@ export async function createTerminal(options: CreateTerminalOptions): Promise