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
@@ -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 } {