fix(terminal): preserve cursor position after full-width replay

This commit is contained in:
Mohamed Boudra
2026-10-30 19:28:02 +01:00
parent c0af0a8115
commit 96e5c8a47f
4 changed files with 219 additions and 148 deletions
+3 -1
View File
@@ -15,7 +15,7 @@ supervisor (paseo.pid) ──► daemon worker ──► launcher ──► term
- The worker exits on its own when it has no controller and no terminals, on `shutdown`, on SIGTERM (killing its terminals), and when its record disappears (a deleted `$PASEO_HOME`: nobody can ever reach it again).
- Its stderr goes to `$PASEO_HOME/terminal-worker.log`. No pipe to the daemon survives the daemon.
The worker retains a fixed 10,000 history rows plus the screen in its existing xterm. Replay uses `@xterm/addon-serialize` 0.14 against headless xterm 6; no second cell grid or lifetime byte log is retained. This addon omits cursor visibility and DECSCUSR presentation, so the session supplements visibility and explicit program-selected presentation from xterm’s cursor state. An unset program style leaves each client’s configured cursor defaults intact. Standard modes come from the serializer; the existing input-mode tracker retains protocol modes such as Kitty and Win32 input. Commit that tracker with parsed output so replay bytes and metadata describe the same cut.
The worker retains a fixed 10,000 history rows plus the screen in its existing xterm. Replay uses `@xterm/addon-serialize` 0.14 against headless xterm 6; no second cell grid or lifetime byte log is retained. The serializer boundary owns cursor position, visibility and explicit program-selected presentation; the addon alone does not preserve all three. An unset program style leaves each client’s configured cursor defaults intact. Standard modes come from the serializer; the existing input-mode tracker retains protocol modes such as Kitty and Win32 input. Commit that tracker with parsed output so replay bytes and metadata describe the same cut.
Which triggers keep terminals and which close them is owned by the stop reason (`keepTerminals`) that flows from the request through the supervisor to `stop()`. The kill side always ends in the reaper (`terminal-worker-reaper.ts`), the only path that signals terminal processes outside a connected daemon.
@@ -50,6 +50,8 @@ Tests stand up other versions with `PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE`, re
The worker keeps the headless xterm model and generates ANSI. A replay request emits begin metadata (dimensions, retained history, revision and input modes), at most 64 KiB per data chunk, and end. Live output keeps flowing independently during replay. Capture is synchronous with PTY callbacks; flush the output coalescer before the cut so a later batch never includes bytes already in replay. Retained-history metadata counts the worker history before the request limit.
ConPTY renders a console rather than forwarding every program escape sequence unchanged. A program write callback does not establish that its hidden cursor move or input modes reached the worker. Test exact ANSI fidelity at the serializer boundary; PTY integration compares the initial replay plus committed observed output at the capture cut, including startup modes.
The worker paces replay writes on its local socket; the daemon synchronously queues each event into the application's bounded transport. App network delivery does not extend the worker's request deadline. There is no per-chunk acknowledgement or second replay delivery queue in the daemon. The physical application transport owns its 64 MiB high-water close, so a backed-up app cannot hold worker capture open. Each daemon viewer still holds at most 4 MiB of live output during capture, then drops revisions already present in the worker replay. Capture failure and tail overflow affect only that viewer; PTYs and healthy viewers keep running.
Live traffic that fills the shared worker socket beyond 4 MiB disconnects the stalled controller. Reconnection adopts the surviving sessions and requests fresh replay for existing streams. Readiness does not require a revision: adoption announces readiness without one, while the replay capture supplies the revision used for deduplication.
@@ -0,0 +1,42 @@
import type { Terminal as TerminalType } from "@xterm/headless";
import { SerializeAddon } from "@xterm/addon-serialize";
import type { TerminalReplayOptions } from "./terminal-replay.js";
/** Serialize the owned model; the PTY is a transport, not an ANSI parser oracle. */
export function createTerminalSerializer(
terminal: TerminalType,
): (options?: TerminalReplayOptions) => Uint8Array {
const addon = new SerializeAddon();
terminal.loadAddon(addon);
return (options) => {
const data = addon.serialize({ scrollback: options?.scrollbackLines });
const buffer = terminal.buffer.active;
// Addon 0.14 applies a horizontal offset after vertical movement has already
// clamped a pending-wrap column, losing a column. Restore a valid position
// absolutely; an x==cols cursor must keep its pending wrap for the next byte.
const position =
buffer.cursorX < terminal.cols ? `\x1b[${buffer.cursorY + 1};${buffer.cursorX + 1}H` : "";
return Buffer.from(data + position + serializeCursorPresentation(terminal), "utf8");
};
}
// Stable addon 0.14 serializes buffer/modes but omits DECSCUSR and cursor visibility.
// xterm 6 keeps the program-selected presentation in coreService, not public options.
function serializeCursorPresentation(terminal: TerminalType): string {
const core = (
terminal as unknown as {
_core: {
coreService: {
isCursorHidden: boolean;
decPrivateModes: { cursorStyle?: "block" | "underline" | "bar"; cursorBlink?: boolean };
};
};
}
)._core.coreService;
const visibility = `\x1b[?25${core.isCursorHidden ? "l" : "h"}`;
const cursorStyle = core.decPrivateModes.cursorStyle;
// Unset presentation belongs to each renderer's configured defaults.
if (cursorStyle === undefined) return visibility;
const style = { block: 2, underline: 4, bar: 6 }[cursorStyle];
return `${visibility}\x1b[${style - (core.decPrivateModes.cursorBlink ? 1 : 0)} q`;
}
+171 -119
View File
@@ -1,4 +1,5 @@
import { TerminalInputModeTracker } from "@getpaseo/protocol/terminal-input-mode";
import { createTerminalSerializer } from "./terminal-serializer.js";
import { readTerminalState } from "./test-utils/terminal-state.js";
import type { TerminalState } from "@getpaseo/protocol/messages";
import { describe, it, expect, afterEach, vi } from "vitest";
@@ -1563,121 +1564,28 @@ it.runIf(isPlatform("linux", "darwin"))(
it.each([false, true])(
"serializes styled Unicode, cursor presentation and modes directly (alternate=%s)",
async (alternate) => {
const gate = mkdtempSync(join(tmpdir(), "terminal-cursor-cut-"));
temporaryDirs.push(gate);
const releaseContent = join(gate, "content");
const releaseCursor = join(gate, "position");
const cursorWritten = join(gate, "cursor-written");
// Exercise the serializer's input model directly. ConPTY renders program
// output: it consumes input modes and need not publish a hidden cursor move.
const terminal = new xterm.Terminal({
rows: 4,
cols: 16,
scrollback: 10000,
allowProposedApi: true,
});
const serialize = createTerminalSerializer(terminal);
const inputModes = new TerminalInputModeTracker();
const content =
Array.from({ length: 8 }, (_, i) => "normal-" + i + "\r\n").join("") +
(alternate ? "\x1b[?1049h\x1b[H" : "") +
"\x1b[38;2;255;128;64;4m界é🙂\x1b[0m\r\nVIEW-READY\x1b[?1h\x1b[?2004h\x1b[>1u\x1b[?9001h\x1b[?7l\x1b[?1000h\x1b[5 q\x1b[?25l";
const session = trackSession(
await createTerminal({
cwd: realpathSync(tmpdir()),
workspaceId: "serialize",
rows: 4,
cols: 16,
command: process.execPath,
args: [
"-e",
`const fs=require("node:fs");let started=false,positioned=false;
function emitReleased(){
if(!started&&fs.existsSync(${JSON.stringify(releaseContent)})){
started=true;process.stdout.write(${JSON.stringify(content)});
}
if(!positioned&&fs.existsSync(${JSON.stringify(releaseCursor)})){
positioned=true;watcher.close();process.stdout.write("\\x1b[2;7H",()=>fs.writeFileSync(${JSON.stringify(cursorWritten)},"written"));
}
}
const watcher=fs.watch(${JSON.stringify(gate)},emitReleased);
emitReleased();setInterval(()=>{},1000)`,
],
}),
);
// Subscribe before releasing program output so failure diagnostics contain
// the actual PTY/ConPTY wire, rather than assuming it preserves program bytes.
const received: string[] = [];
const unsubscribe = session.subscribe((message) => {
if (message.type === "output") received.push(message.data);
});
writeFileSync(releaseContent, "content");
const deadline = Date.now() + 5000;
while (!session.captureLines().lines.some((line) => line.includes("VIEW-READY"))) {
if (Date.now() > deadline) throw new Error("source content did not parse");
await delay(20);
}
// VIEW-READY precedes the last cursor update; its visible text is not a capture barrier.
writeFileSync(releaseCursor, "position");
try {
await waitForState(session, (state) => state.cursor.row === 1 && state.cursor.col === 6);
} catch (error) {
async function inspect(data: string) {
const terminal = new xterm.Terminal({
rows: 4,
cols: 16,
scrollback: 10000,
allowProposedApi: true,
});
const presentation: number[][] = [];
const visibility: Array<{ final: string; params: number[] }> = [];
terminal.parser.registerCsiHandler({ intermediates: " ", final: "q" }, (params) => {
presentation.push(params as number[]);
return false;
});
for (const final of ["h", "l"])
terminal.parser.registerCsiHandler({ prefix: "?", final }, (params) => {
visibility.push({ final, params: params as number[] });
return false;
});
const modes = new TerminalInputModeTracker();
modes.feed(data);
try {
await new Promise<void>((resolve) => terminal.write(data, resolve));
const buffer = terminal.buffer.active;
return {
buffer: buffer.type,
cursor: { row: buffer.cursorY, col: buffer.cursorX },
lines: Array.from({ length: buffer.length }, (_, i) =>
buffer.getLine(i)!.translateToString(true),
),
modes: terminal.modes,
inputModes: modes.getState(),
presentation,
visibility,
};
} finally {
terminal.dispose();
}
}
const replay = session.serializeReplay();
const ansi = new TextDecoder().decode(replay.data);
throw new Error(
"Terminal fidelity capture failed: " +
JSON.stringify({
platform: process.platform,
alternate,
error: String(error),
cursorWriteCompleted: existsSync(cursorWritten),
received,
raw: await inspect(received.join("")),
replay: {
metadata: replay.metadata,
ansi,
parsed: await inspect(ansi + replay.metadata.replayPreamble),
},
capture: session.captureLines(),
}),
{ cause: error },
);
} finally {
unsubscribe();
}
const replay = session.serializeReplay();
const ansi = new TextDecoder().decode(replay.data);
const output = content + "\x1b[2;7H";
await new Promise<void>((resolve) => terminal.write(output, resolve));
inputModes.feed(output);
const data = serialize();
const ansi = new TextDecoder().decode(data);
expect(ansi).toContain("\x1b[?25l\x1b[5 q");
expect(ansi).not.toContain("NaN");
expect(replay.metadata.retainedHistoryLines).toBeGreaterThan(0);
expect(terminal.buffer.normal.baseY).toBeGreaterThan(0);
const receiver = new xterm.Terminal({
rows: 4,
cols: 16,
@@ -1685,21 +1593,20 @@ it.each([false, true])(
allowProposedApi: true,
});
try {
await new Promise<void>((resolve) => receiver.write(replay.data, resolve));
await new Promise<void>((resolve) => receiver.write(replay.metadata.replayPreamble, resolve));
await new Promise<void>((resolve) => receiver.write(data, resolve));
await new Promise<void>((resolve) => receiver.write(inputModes.getPreamble(), resolve));
expect(receiver.buffer.active.type).toBe(alternate ? "alternate" : "normal");
const lines = Array.from({ length: receiver.buffer.active.length }, (_, i) =>
receiver.buffer.active.getLine(i)!.translateToString(true),
);
expect(lines.join("\n")).toContain("界é🙂");
expect(lines.join("\n")).toContain("VIEW-READY");
const colored = receiver.buffer.active.getLine(receiver.buffer.active.baseY)!.getCell(0)!;
if (alternate) {
expect(colored.getChars()).toBe("界");
expect(colored.getFgColor()).toBe(0xff8040);
expect(colored.isUnderline()).not.toBe(0);
expect(receiver.buffer.normal.getLine(0)!.translateToString(true)).toBe("normal-0");
}
const coloredIndex = lines.findIndex((line) => line.includes("界"));
const colored = receiver.buffer.active.getLine(coloredIndex)!.getCell(0)!;
expect(colored.getChars()).toBe("界");
expect(colored.getFgColor()).toBe(0xff8040);
expect(colored.isUnderline()).not.toBe(0);
expect(receiver.buffer.normal.getLine(0)!.translateToString(true)).toBe("normal-0");
expect({ row: receiver.buffer.active.cursorY, col: receiver.buffer.active.cursorX }).toEqual({
row: 1,
col: 6,
@@ -1711,7 +1618,7 @@ it.each([false, true])(
mouseTrackingMode: "vt200",
});
const modes = new TerminalInputModeTracker();
modes.feed(ansi + replay.metadata.replayPreamble);
modes.feed(ansi + inputModes.getPreamble());
expect(modes.getState()).toMatchObject({
kittyKeyboardFlags: 1,
win32InputMode: true,
@@ -1720,6 +1627,7 @@ it.each([false, true])(
});
} finally {
receiver.dispose();
terminal.dispose();
}
},
);
@@ -1814,3 +1722,147 @@ it("captures input modes at the same committed cut as serialized bytes", async (
unsubscribe();
}
});
it.each(
[false, true].flatMap((alternate) => [
{
alternate,
pendingWrap: false,
position: "\x1b[3;16H",
cursor: { row: 2, col: 15 },
after: { row: 2, col: 16 },
},
{
alternate,
pendingWrap: true,
position: "",
cursor: { row: 0, col: 16 },
after: { row: 1, col: 1 },
},
]),
)(
"replays a full-width row without losing cursor state ($alternate, pendingWrap=$pendingWrap)",
async (input) => {
const source = new xterm.Terminal({
rows: 4,
cols: 16,
scrollback: 10000,
allowProposedApi: true,
});
const serialize = createTerminalSerializer(source);
const receiver = new xterm.Terminal({
rows: 4,
cols: 16,
scrollback: 10000,
allowProposedApi: true,
});
try {
await new Promise<void>((resolve) =>
source.write(
(input.alternate ? "\x1b[?1049h" : "") + "x".repeat(16) + input.position,
resolve,
),
);
expect({ row: source.buffer.active.cursorY, col: source.buffer.active.cursorX }).toEqual(
input.cursor,
);
await new Promise<void>((resolve) => receiver.write(serialize(), resolve));
expect({ row: receiver.buffer.active.cursorY, col: receiver.buffer.active.cursorX }).toEqual(
input.cursor,
);
await new Promise<void>((resolve) => receiver.write("Z", resolve));
expect({ row: receiver.buffer.active.cursorY, col: receiver.buffer.active.cursorX }).toEqual(
input.after,
);
} finally {
receiver.dispose();
source.dispose();
}
},
);
it.each([false, true])(
"replays the observed PTY projection including startup modes (alternate=%s)",
async (alternate) => {
const gate = mkdtempSync(join(tmpdir(), "terminal-projection-"));
temporaryDirs.push(gate);
const release = join(gate, "output");
const content =
Array.from({ length: 8 }, (_, i) => `normal-${i}\r\n`).join("") +
(alternate ? "\x1b[?1049h\x1b[H" : "") +
"\x1b[38;2;255;128;64;4m界é🙂\x1b[0m\r\nVIEW-READY\x1b[?1h\x1b[?2004h\x1b[>1u\x1b[?9001h\x1b[?7l\x1b[?1000h\x1b[5 q\x1b[?25l\x1b[2;7H";
const session = trackSession(
await createTerminal({
cwd: realpathSync(tmpdir()),
workspaceId: "projection",
rows: 4,
cols: 16,
command: process.execPath,
args: [
"-e",
`const fs=require("node:fs");let sent=false;
function emit(){if(!sent&&fs.existsSync(${JSON.stringify(release)})){sent=true;watcher.close();process.stdout.write(${JSON.stringify(content)});}}
const watcher=fs.watch(${JSON.stringify(gate)},emit);emit();setInterval(()=>{},1000);`,
],
}),
);
const bootstrap = session.serializeReplay();
const output: string[] = [];
const unsubscribe = session.subscribe((message) => {
if (message.type === "output") output.push(message.data);
});
const reference = new xterm.Terminal({
rows: 4,
cols: 16,
scrollback: 10000,
allowProposedApi: true,
});
const receiver = new xterm.Terminal({
rows: 4,
cols: 16,
scrollback: 10000,
allowProposedApi: true,
});
try {
await new Promise<void>((resolve) => reference.write(bootstrap.data, resolve));
await new Promise<void>((resolve) =>
reference.write(bootstrap.metadata.replayPreamble, resolve),
);
writeFileSync(release, "release");
const deadline = Date.now() + 5000;
while (!session.captureLines().lines.some((line) => line.includes("VIEW-READY"))) {
if (Date.now() > deadline) throw new Error("PTY did not render fixture output");
await delay(20);
}
// Bootstrap and these committed output events cover precisely this source cut.
// Program bytes are not the oracle: ConPTY consumes modes and renders cells.
const replay = session.serializeReplay();
const committed = output.join("");
await new Promise<void>((resolve) => reference.write(committed, resolve));
await new Promise<void>((resolve) => receiver.write(replay.data, resolve));
await new Promise<void>((resolve) => receiver.write(replay.metadata.replayPreamble, resolve));
const text = (terminal: typeof receiver) =>
Array.from({ length: terminal.buffer.active.length }, (_, i) =>
terminal.buffer.active.getLine(i)!.translateToString(true).trimEnd(),
);
expect(text(receiver)).toEqual(text(reference));
expect(text(receiver).join("\n")).toContain("界é🙂");
expect(text(receiver).join("\n")).toContain("VIEW-READY");
expect(receiver.buffer.active.type).toBe(alternate ? "alternate" : "normal");
expect({ row: receiver.buffer.active.cursorY, col: receiver.buffer.active.cursorX }).toEqual({
row: reference.buffer.active.cursorY,
col: reference.buffer.active.cursorX,
});
expect(receiver.modes).toEqual(reference.modes);
const expectedModes = new TerminalInputModeTracker();
expectedModes.feed(bootstrap.metadata.replayPreamble + committed);
const actualModes = new TerminalInputModeTracker();
actualModes.feed(new TextDecoder().decode(replay.data) + replay.metadata.replayPreamble);
expect(actualModes.getState()).toEqual(expectedModes.getState());
} finally {
unsubscribe();
receiver.dispose();
reference.dispose();
}
},
);
+3 -28
View File
@@ -25,7 +25,7 @@ import {
findExecutable,
findExecutableWithoutProbing,
} from "../executable-resolution/executable-resolution.js";
import { SerializeAddon } from "@xterm/addon-serialize";
import { createTerminalSerializer } from "./terminal-serializer.js";
import {
captureTerminalLines,
type CaptureTerminalLinesOptions,
@@ -534,27 +534,6 @@ function prependPathEntry(currentPath: string, entry: string): string {
return [entry, ...entries].join(delimiter);
}
// Stable addon 0.14 serializes buffer/modes but omits DECSCUSR and cursor visibility.
// xterm 6 keeps the program-selected presentation in coreService, not public options.
function serializeCursorPresentation(terminal: TerminalType): string {
const core = (
terminal as unknown as {
_core: {
coreService: {
isCursorHidden: boolean;
decPrivateModes: { cursorStyle?: "block" | "underline" | "bar"; cursorBlink?: boolean };
};
};
}
)._core.coreService;
const visibility = `\x1b[?25${core.isCursorHidden ? "l" : "h"}`;
const cursorStyle = core.decPrivateModes.cursorStyle;
// Unset presentation belongs to each renderer's configured defaults.
if (cursorStyle === undefined) return visibility;
const style = { block: 2, underline: 4, bar: 6 }[cursorStyle];
return `${visibility}\x1b[${style - (core.decPrivateModes.cursorBlink ? 1 : 0)} q`;
}
function normalizeProcessToken(token: string): string {
if (token.length === 0) {
return token;
@@ -825,8 +804,7 @@ export async function createTerminal(
allowProposedApi: true,
});
const serializer = new SerializeAddon();
terminal.loadAddon(serializer);
const serialize = createTerminalSerializer(terminal);
ensureNodePtySpawnHelperExecutableForCurrentPlatform();
@@ -1143,10 +1121,7 @@ export async function createTerminal(
revision: stateRevision,
replayPreamble: getReplayPreamble(),
};
const ansi =
serializer.serialize({ scrollback: replayOptions?.scrollbackLines }) +
serializeCursorPresentation(terminal);
return { metadata, data: Buffer.from(ansi, "utf8") };
return { metadata, data: serialize(replayOptions) };
}
function getSize(): { rows: number; cols: number } {