fix(terminal): preserve 10,000 rows with direct ANSI replay (#8282)

Restore configured terminal history on reconnect, retaining 10,000 rows plus the screen. Serialize the worker-owned xterm buffer directly to ANSI and forward negotiated bounded replay chunks while preserving ANSI-era clients and per-viewer live ordering.

Preserve captured geometry through replay, bound pressure recovery to 500 rows, and keep the omitted visible-history default at 200. Remove the full cell-grid copy and obsolete JSON rendering.

All 21 CI checks pass. Real browser, native Debug emulator/simulator and Linux Electron coverage and platform limits are documented in the PR. The Android Debug invalid native delegate dispatch also reproduces on upstream main; its cause and any change in frequency remain unproven.

Credit Manav Dhindsa (DrakenKor), #6139 and #6148, and Lennart Schoch, #6743, for prior ANSI replay and retention work informing this change.

Closes #6137.
Closes #6147.
This commit is contained in:
Mohamed Boudra
2026-10-31 10:51:45 +07:00
committed by GitHub
parent 7b03f0ad21
commit 9c926ed126
92 changed files with 4380 additions and 2638 deletions
@@ -89,3 +89,18 @@ describe("host route binary framing", () => {
).toThrow("Invalid host route ID");
});
});
it.each([
TerminalStreamOpcode.ReplayBegin,
TerminalStreamOpcode.ReplayData,
TerminalStreamOpcode.ReplayEnd,
])("decodes replay opcode %s directly and inside a host route", (opcode) => {
const payload = new TextEncoder().encode("界 replay");
const bytes = encodeTerminalStreamFrame({ opcode, slot: 12, payload });
const expected = { kind: "terminal", frame: { opcode, slot: 12, payload } };
expect(decodeBinaryFrame(bytes)).toEqual(expected);
const routed = decodeBinaryFrame(encodeHostRouteFrame("host", bytes));
expect(routed?.kind).toBe("host_route");
if (routed?.kind !== "host_route") throw new Error("Missing host route");
expect(decodeBinaryFrame(routed.frame.payload)).toEqual(expected);
});
@@ -25,6 +25,9 @@ export function decodeBinaryFrame(bytes: Uint8Array): BinaryFrame | null {
case TerminalStreamOpcode.Input:
case TerminalStreamOpcode.Resize:
case TerminalStreamOpcode.Snapshot:
case TerminalStreamOpcode.ReplayBegin:
case TerminalStreamOpcode.ReplayData:
case TerminalStreamOpcode.ReplayEnd:
case TerminalStreamOpcode.Restore: {
const frame = decodeTerminalStreamFrame(bytes);
return frame ? { kind: "terminal", frame } : null;
@@ -1,6 +1,12 @@
import { z } from "zod";
import { TerminalStateSchema } from "../messages.js";
export const TerminalReplayMetadataSchema = z.object({
rows: z.number().int().positive(),
cols: z.number().int().positive(),
retainedHistoryLines: z.number().int().nonnegative(),
});
export const TerminalStreamResizeSchema = z.object({
rows: z.number().int().positive(),
cols: z.number().int().positive(),
@@ -13,6 +19,9 @@ export const TerminalStreamOpcode = {
Resize: 0x03,
Snapshot: 0x04,
Restore: 0x05,
ReplayBegin: 0x06,
ReplayData: 0x07,
ReplayEnd: 0x08,
} as const;
export type TerminalStreamOpcode = (typeof TerminalStreamOpcode)[keyof typeof TerminalStreamOpcode];
@@ -58,6 +67,9 @@ function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode {
value === TerminalStreamOpcode.Input ||
value === TerminalStreamOpcode.Resize ||
value === TerminalStreamOpcode.Snapshot ||
value === TerminalStreamOpcode.ReplayBegin ||
value === TerminalStreamOpcode.ReplayData ||
value === TerminalStreamOpcode.ReplayEnd ||
value === TerminalStreamOpcode.Restore
);
}