fix(terminal): preserve mouse encoding when replaying sessions (#9004)

Restore SGR and pixel mouse encoding alongside tracking modes so Copilot CLI scrolling survives switching conversations.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Patrick Wang
2026-11-07 13:21:25 +00:00
committed by GitHub
co-authored by Copilot
parent a449271d57
commit 28c01ea1ce
3 changed files with 59 additions and 9 deletions
@@ -16,13 +16,13 @@ export function createTerminalSerializer(
// 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");
return Buffer.from(data + position + serializeAdditionalModes(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 {
// Addon 0.14 omits mouse encoding, DECSCUSR and cursor visibility. xterm 6
// retains these in internal services rather than the public modes/options.
function serializeAdditionalModes(terminal: TerminalType): string {
const core = (
terminal as unknown as {
_core: {
@@ -30,13 +30,17 @@ function serializeCursorPresentation(terminal: TerminalType): string {
isCursorHidden: boolean;
decPrivateModes: { cursorStyle?: "block" | "underline" | "bar"; cursorBlink?: boolean };
};
mouseStateService: { activeEncoding: string };
};
}
)._core.coreService;
const visibility = `\x1b[?25${core.isCursorHidden ? "l" : "h"}`;
const cursorStyle = core.decPrivateModes.cursorStyle;
)._core;
let mouseEncoding = "";
if (core.mouseStateService.activeEncoding === "SGR") mouseEncoding = "\x1b[?1006h";
else if (core.mouseStateService.activeEncoding === "SGR_PIXELS") mouseEncoding = "\x1b[?1016h";
const visibility = `${mouseEncoding}\x1b[?25${core.coreService.isCursorHidden ? "l" : "h"}`;
const cursorStyle = core.coreService.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`;
return `${visibility}\x1b[${style - (core.coreService.decPrivateModes.cursorBlink ? 1 : 0)} q`;
}
@@ -1702,6 +1702,52 @@ it.each([false, true])(
},
);
it.each([
{ name: "SGR", modes: "\x1b[?1006h", wheel: "\x1b[<64;10;5M" },
{ name: "SGR pixels", modes: "\x1b[?1016h", wheel: "\x1b[<64;80;90M" },
{ name: "disabled SGR", modes: "\x1b[?1006h\x1b[?1006l", wheel: "\x1b[M`*%" },
])("preserves $name wheel reports across repeated terminal replays", async (input) => {
const source = new xterm.Terminal({ rows: 10, cols: 80, allowProposedApi: true });
const receiver = new xterm.Terminal({ rows: 10, cols: 80, allowProposedApi: true });
const serialize = createTerminalSerializer(source);
const wheel = {
button: 4,
action: 0,
col: 10,
row: 5,
x: 80,
y: 90,
ctrl: false,
alt: false,
shift: false,
};
// Mouse encoding is not exposed by xterm's public modes API.
const mouse = (
receiver as unknown as {
_core: {
mouseStateService: {
encodeMouseEvent(event: typeof wheel): string;
};
};
}
)._core.mouseStateService;
try {
await new Promise<void>((resolve) =>
source.write("\x1b[?1049h\x1b[?1002h" + input.modes + "TRANSCRIPT", resolve),
);
for (let replay = 0; replay < 2; replay++) {
receiver.reset();
await new Promise<void>((resolve) => receiver.write(serialize(), resolve));
expect(receiver.buffer.active.type).toBe("alternate");
expect(receiver.modes.mouseTrackingMode).toBe("drag");
expect(mouse.encodeMouseEvent(wheel)).toBe(input.wheel);
}
} finally {
receiver.dispose();
source.dispose();
}
});
it.each([
{ name: "unset", presentation: "", style: undefined, visible: true },
{