Files
oh-my-pi/packages/tui/test/render-stress-harness.ts
T
2026-08-05 02:49:01 +09:00

4160 lines
149 KiB
TypeScript

import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { stripVTControlCharacters } from "node:util";
import { ProcessTerminal } from "@oh-my-pi/pi-tui/terminal";
import {
type Component,
CURSOR_MARKER,
type Focusable,
findCommittedPrefixResync,
type OverlayAnchor,
type OverlayHandle,
type OverlayOptions,
TUI,
} from "@oh-my-pi/pi-tui/tui";
import {
Ellipsis,
extractSegments,
sliceByColumn,
sliceWithWidth,
truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
} from "@oh-my-pi/pi-tui/utils";
import { setTerminalHeadless } from "@oh-my-pi/pi-utils";
import { StressRenderScheduler } from "./render-stress-scheduler";
import { VirtualTerminal } from "./virtual-terminal";
const BASE_SEEDS = [
0x00c0ffee, 0x1badb002, 0x5eed1234, 0xdecafbad, 0x8badf00d, 0x0ddc0ffe, 0xcafed00d, 0xb16b00b5,
] as const;
const LARGE_SCROLL = 1_000_000;
const CORE_ITERATIONS = 120;
const SOAK_ITERATIONS = 300;
const CORE_BULK_MAX = 1_000;
const SOAK_BULK_MAX = 1_000;
const CORE_TIMEOUT_MS = 20_000;
const SOAK_TIMEOUT_MS = 45_000;
const EXHAUSTIVE_SCROLLBACK = Bun.env.TUI_STRESS_EXHAUSTIVE_SCROLLBACK === "1";
const SEGMENT_RESET = "\x1b[0m";
const ESC = "\x1b";
const BEL = "\x07";
const ALT_SCREEN_ENTER = "\x1b[?1049h";
const ALT_SCREEN_EXIT = "\x1b[?1049l";
const SMILE = String.fromCodePoint(0x1f642);
type TestPlatform = "darwin" | "linux" | "win32";
type TerminalMode = "normal" | "unknown" | "intermittentUnknown" | "staleBottom";
type GeometryMode = "small" | "large";
type EnvMode = "plain" | "tmux" | "termux" | "appleTerminal" | "iterm2" | "wsl" | "vteNoSync" | "ghostty";
export type ScenarioTag =
| "small"
| "large"
| "tmux"
| "strictScrollback"
| "unknownViewport"
| "foregroundStream"
| "ed3Risk";
const ENV_KEYS = [
"TMUX",
"STY",
"ZELLIJ",
"TERMUX_VERSION",
"WEZTERM_PANE",
"KITTY_WINDOW_ID",
"GHOSTTY_RESOURCES_DIR",
"ALACRITTY_WINDOW_ID",
"VTE_VERSION",
"PI_NO_SYNC_OUTPUT",
"TERM_PROGRAM",
"ITERM_SESSION_ID",
"WT_SESSION",
"WSL_DISTRO_NAME",
"WSL_INTEROP",
] as const;
type EnvKey = (typeof ENV_KEYS)[number];
type JsonPrimitive = string | number | boolean | null;
type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
type JsonObject = { [key: string]: JsonValue };
export type OperationKind =
| "appendSmall"
| "appendExactWidth"
| "appendBulk"
| "streamOne"
| "editVisibleLine"
| "editOffscreenLine"
| "offscreenEditAppendRepeatedTail"
| "insertOffscreen"
| "insertMiddle"
| "deleteTrailing"
| "deleteMiddle"
| "replaceAll"
| "toggleCollapsible"
| "tickStatusHeader"
| "appendRepeatedTail"
| "injectBlankCluster"
| "appendDuplicateOfExisting"
| "highWaterPreviewCollapse"
| "eagerStreamingMutation"
| "scrollUp"
| "scrollToBottom"
| "scrollPartial"
| "resizeWidth"
| "resizeHeight"
| "resizeWithAppend"
| "forceRender"
| "toggleFocusInput"
| "moveCursorVisible"
| "moveCursorOffscreen"
| "showOverlay"
| "hideOverlay"
| "toggleOverlayHidden"
| "editOverlay"
| "moveOverlayCursor"
| "coalescedBurst"
| "rotateUp"
| "collapseToFew"
| "swapOffscreenRows"
| "resizeBoth"
| "resizeNoop"
| "forceRenderAllowUnknown"
| "forceRenderClearScrollback"
| "forceRenderAfterEmptyOverflow"
| "attachChild"
| "detachChild"
| "reorderChildren"
| "mutateChild";
export const OPERATION_KINDS = [
"appendSmall",
"appendExactWidth",
"appendBulk",
"streamOne",
"editVisibleLine",
"editOffscreenLine",
"offscreenEditAppendRepeatedTail",
"insertOffscreen",
"insertMiddle",
"deleteTrailing",
"deleteMiddle",
"replaceAll",
"toggleCollapsible",
"tickStatusHeader",
"appendRepeatedTail",
"injectBlankCluster",
"appendDuplicateOfExisting",
"highWaterPreviewCollapse",
"eagerStreamingMutation",
"scrollUp",
"scrollToBottom",
"scrollPartial",
"resizeWidth",
"resizeHeight",
"resizeWithAppend",
"forceRender",
"toggleFocusInput",
"moveCursorVisible",
"moveCursorOffscreen",
"showOverlay",
"hideOverlay",
"toggleOverlayHidden",
"editOverlay",
"moveOverlayCursor",
"coalescedBurst",
"rotateUp",
"collapseToFew",
"swapOffscreenRows",
"resizeBoth",
"resizeNoop",
"forceRenderAllowUnknown",
"forceRenderClearScrollback",
"forceRenderAfterEmptyOverflow",
"attachChild",
"detachChild",
"reorderChildren",
"mutateChild",
] as const satisfies readonly OperationKind[];
const OPERATION_KIND_SET = new Set<string>(OPERATION_KINDS);
export function isOperationKind(value: unknown): value is OperationKind {
return typeof value === "string" && OPERATION_KIND_SET.has(value);
}
const BURST_STEP_KINDS = [
"appendSmall",
"streamOne",
"appendRepeatedTail",
"injectBlankCluster",
"editVisibleLine",
"editOffscreenLine",
"tickStatusHeader",
"resizeWidth",
"resizeHeight",
"scrollPartial",
"scrollToBottom",
"forceRender",
] as const;
type BurstStepKind = (typeof BURST_STEP_KINDS)[number];
const OVERLAY_ANCHORS = [
"center",
"top-left",
"top-right",
"bottom-left",
"bottom-right",
"top-center",
"bottom-center",
"left-center",
"right-center",
] as const satisfies readonly OverlayAnchor[];
const CURSOR_MODES = ["start", "middle", "end", "wideBoundary"] as const;
export type CursorMode = (typeof CURSOR_MODES)[number];
export interface ExpectedCursor {
row: number;
col: number;
}
export interface ExpectedFrame {
frame: string[];
cursor: ExpectedCursor | null;
// Frame columns whose logical content carries background SGR. Only these
// cells may have non-default background in the terminal; background outside
// these column ranges is BCE bleed from stale SGR state painting erased cells.
backgroundColumns: number[][];
}
interface StressOverlayEntry {
id: number;
sentinel: string;
model: StressOverlayModel;
component: StressOverlayComponent;
handle: OverlayHandle;
options: OverlayOptions;
hidden: boolean;
detail: JsonObject;
}
interface StressChildEntry {
id: number;
model: StressModel;
component: StressComponent;
active: boolean;
}
interface LogicalLine {
id: number;
text: string;
}
export interface Scenario {
name: string;
seed: number;
platform: TestPlatform;
terminalMode: TerminalMode;
envMode: EnvMode;
geometryMode: GeometryMode;
columns: number;
rows: number;
widthChoices: readonly number[];
heightChoices: readonly number[];
iterations: number;
bulkMax: number;
scrollback: number;
strictScrollback: boolean;
timeoutMs: number;
uniqueContent: boolean;
// Models a foreground tool actively streaming output: content frames are
// re-rendered with a plain (non-forced) `requestRender()`, so offscreen-edit
// growth flows through `viewportRepaint` (which advances the rendered line
// count without committing the overflow to native history). The default
// content-frame path instead forces a render and never exercises that
// lagging-high-water state.
foregroundStream: boolean;
// Renders each logical line wrapped to the viewport width, so a width resize
// changes the physical line COUNT (reflow), not just per-row truncation —
// exercising the geometry-change + line-count-change interaction the
// fixed-line components never produced. Wrapped content must agree with the
// real Ghostty-backed terminal's cell widths.
reflow: boolean;
tags: readonly ScenarioTag[];
replayOperations?: readonly OperationKind[];
}
type ViewportProbeTrait = "known" | "unknown" | "intermittentUnknown" | "staleBottom";
interface TerminalStressTraits {
readonly preservesPaneHistory: boolean;
readonly strictNativeScrollback: boolean;
readonly syncOutputDisabled: boolean;
readonly viewportProbe: ViewportProbeTrait;
readonly ed3ScrollbackEraseRisk: boolean;
readonly conptyHostScrollbackUnobservable: boolean;
readonly foregroundStreaming: boolean;
}
interface Snapshot {
buffer: string[];
view: string[];
viewBackgroundColumns: number[][];
frameBackgroundColumns: number[][];
position: { baseY: number; viewportY: number };
cursor: { row: number; col: number };
expectedCursor: ExpectedCursor | null;
redraws: number;
width: number;
height: number;
frame: string[];
atBottom: boolean;
shadowTapeLength: number;
}
interface AppliedOperation {
kind: OperationKind;
detail: JsonObject;
mutatesContent: boolean;
checksRowAccounting: boolean;
geometryChanged: boolean;
forcedRender: boolean;
checkpoint: boolean;
mutatesViewport: boolean;
coalesced?: boolean;
// Maximum number of rows the op appended to the frame at any point while it
// ran, even if a later step inside the same op removed them again (e.g. a
// preview expanding and collapsing). Appended rows that overflow the
// viewport legitimately scroll into terminal history and can never be
// retracted from multiplexer pane history, so growth oracles must allow
// them. Defaults to the net frame growth when absent.
transientFrameGrowth?: number;
// The periodic prompt-submit checkpoint pins the viewport to the bottom and
// runs the prompt-submit reconciliation (a `/clear`-style forced rebuild for
// `normal`; other hosts get a plain forced render). Native scrollback
// must equal the transcript only when that reconciliation actually ran:
// ConPTY/Windows and other unobservable host-scrollback paths deliberately
// keep dirty history deferred until the renderer gets a positive at-tail probe.
// Plain `scrollToBottom` / forced-render ops also set `checkpoint`, but on
// Windows hosts a forced render cannot rebuild ConPTY-hidden history, so the
// clean-buffer oracle keys on this flag for non-`normal` scenarios.
reconcilesNativeScrollback?: boolean;
}
type AppliedOperationOverrides = Partial<Omit<AppliedOperation, "kind" | "detail">>;
function appliedOperation(
kind: OperationKind,
detail: JsonObject,
overrides: AppliedOperationOverrides,
): AppliedOperation {
return {
kind,
detail,
mutatesContent: false,
checksRowAccounting: false,
geometryChanged: false,
forcedRender: false,
checkpoint: false,
mutatesViewport: false,
...overrides,
};
}
function contentOperation(
kind: OperationKind,
detail: JsonObject,
checksRowAccounting: boolean,
overrides: AppliedOperationOverrides = {},
): AppliedOperation {
return appliedOperation(kind, detail, { mutatesContent: true, checksRowAccounting, ...overrides });
}
function viewOperation(
kind: OperationKind,
detail: JsonObject,
overrides: AppliedOperationOverrides = {},
): AppliedOperation {
return appliedOperation(kind, detail, overrides);
}
function forceRenderOperation(
kind: OperationKind,
detail: JsonObject,
overrides: AppliedOperationOverrides = {},
): AppliedOperation {
return appliedOperation(kind, detail, { forcedRender: true, ...overrides });
}
type OperationLogKind = OperationKind | "periodicCheckpoint";
interface OperationLogEntry {
index: number;
kind: OperationLogKind;
detail: JsonObject;
frameLengthBefore: number;
frameLengthAfter: number;
bufferLengthBefore: number;
bufferLengthAfter: number;
viewportYBefore: number;
viewportYAfter: number;
baseYBefore: number;
baseYAfter: number;
redrawsBefore: number;
redrawsAfter: number;
}
interface BurstStepMetadata {
readonly mutatesContent: boolean;
readonly geometryChanged: boolean;
readonly forcedRender: boolean;
readonly mutatesViewport: boolean;
}
const BURST_STEP_METADATA = {
appendSmall: { mutatesContent: true, geometryChanged: false, forcedRender: false, mutatesViewport: false },
streamOne: { mutatesContent: true, geometryChanged: false, forcedRender: false, mutatesViewport: false },
appendRepeatedTail: { mutatesContent: true, geometryChanged: false, forcedRender: false, mutatesViewport: false },
injectBlankCluster: { mutatesContent: true, geometryChanged: false, forcedRender: false, mutatesViewport: false },
editVisibleLine: { mutatesContent: true, geometryChanged: false, forcedRender: false, mutatesViewport: false },
editOffscreenLine: { mutatesContent: true, geometryChanged: false, forcedRender: false, mutatesViewport: false },
tickStatusHeader: { mutatesContent: true, geometryChanged: false, forcedRender: false, mutatesViewport: false },
resizeWidth: { mutatesContent: false, geometryChanged: true, forcedRender: false, mutatesViewport: true },
resizeHeight: { mutatesContent: false, geometryChanged: true, forcedRender: false, mutatesViewport: true },
scrollPartial: { mutatesContent: false, geometryChanged: false, forcedRender: false, mutatesViewport: true },
scrollToBottom: { mutatesContent: false, geometryChanged: false, forcedRender: false, mutatesViewport: true },
forceRender: { mutatesContent: false, geometryChanged: false, forcedRender: true, mutatesViewport: true },
} satisfies Record<BurstStepKind, BurstStepMetadata>;
class UnknownViewportTerminal extends VirtualTerminal {
override isNativeViewportAtBottom(): undefined {
return undefined;
}
}
class IntermittentUnknownViewportTerminal extends VirtualTerminal {
#probeCount = 0;
override isNativeViewportAtBottom(): boolean | undefined {
this.#probeCount += 1;
return this.#probeCount % 3 === 0 ? undefined : super.isNativeViewportAtBottom();
}
}
class StaleBottomTerminal extends VirtualTerminal {
#previous: boolean | undefined;
#returnStale = false;
override isNativeViewportAtBottom(): boolean | undefined {
const current = super.isNativeViewportAtBottom();
if (this.#returnStale) {
this.#returnStale = false;
const stale = this.#previous;
this.#previous = current;
return stale;
}
this.#returnStale = true;
this.#previous = current;
return current;
}
}
class MutableLinesComponent implements Component {
#lines: string[];
constructor(lines: readonly string[]) {
this.#lines = [...lines];
}
setLines(lines: readonly string[]): void {
this.#lines = [...lines];
}
invalidate(): void {}
render(_width: number): string[] {
return [...this.#lines];
}
}
class Rng {
#state: number;
constructor(seed: number) {
this.#state = seed >>> 0;
}
next(): number {
this.#state = (this.#state + 0x6d2b79f5) >>> 0;
let t = this.#state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296;
}
int(min: number, max: number): number {
if (max < min) return min;
return Math.floor(this.next() * (max - min + 1)) + min;
}
chance(probability: number): boolean {
return this.next() < probability;
}
pick<T>(items: readonly T[]): T {
if (items.length === 0) {
throw new Error("Cannot pick from an empty list");
}
return items[this.int(0, items.length - 1)]!;
}
}
interface StressRandomStreams {
readonly ops: Rng;
readonly content: Rng;
readonly overlay: Rng;
readonly geometry: Rng;
readonly cursor: Rng;
readonly children: Rng;
}
function createRandomStreams(seed: number): StressRandomStreams {
return {
ops: new Rng(mixSeed(seed, 0x01)),
content: new Rng(mixSeed(seed, 0x02)),
overlay: new Rng(mixSeed(seed, 0x03)),
geometry: new Rng(mixSeed(seed, 0x04)),
cursor: new Rng(mixSeed(seed, 0x05)),
children: new Rng(mixSeed(seed, 0x06)),
};
}
function mixSeed(seed: number, stream: number): number {
let mixed = (seed ^ Math.imul(stream, 0x9e3779b9)) >>> 0;
mixed = Math.imul(mixed ^ (mixed >>> 16), 0x7feb352d);
mixed = Math.imul(mixed ^ (mixed >>> 15), 0x846ca68b);
return (mixed ^ (mixed >>> 16)) >>> 0;
}
function isEd3RiskScenario(terminalMode: TerminalMode, envMode: EnvMode): boolean {
return (
terminalMode === "unknown" &&
(envMode === "appleTerminal" || envMode === "iterm2" || envMode === "wsl" || envMode === "ghostty")
);
}
interface WeightedCandidate<T> {
readonly item: T;
readonly weight: number;
}
function weightedPick<T>(rng: Rng, items: readonly WeightedCandidate<T>[]): T {
let total = 0;
for (const entry of items) {
total += Math.max(0, entry.weight);
}
if (total <= 0) throw new Error("No weighted candidates");
let roll = rng.next() * total;
for (const entry of items) {
const weight = Math.max(0, entry.weight);
roll -= weight;
if (roll < 0) return entry.item;
}
return items[items.length - 1]!.item;
}
function assertNever(value: never): never {
throw new Error(`Unexpected value: ${String(value)}`);
}
function terminalStressTraits(scenario: Scenario): TerminalStressTraits {
return {
preservesPaneHistory: scenario.envMode === "tmux",
strictNativeScrollback: scenario.strictScrollback,
syncOutputDisabled: scenario.envMode === "vteNoSync",
viewportProbe: scenario.terminalMode === "normal" ? "known" : scenario.terminalMode,
ed3ScrollbackEraseRisk: isEd3RiskScenario(scenario.terminalMode, scenario.envMode),
conptyHostScrollbackUnobservable: scenario.platform === "win32" && scenario.terminalMode === "unknown",
foregroundStreaming: scenario.foregroundStream,
};
}
function scenarioTags(
template: Pick<Scenario, "envMode" | "terminalMode" | "geometryMode">,
strictNativeScrollback: boolean,
foregroundStreaming: boolean,
): readonly ScenarioTag[] {
const tags: ScenarioTag[] = [template.geometryMode];
if (template.envMode === "tmux") tags.push("tmux");
if (strictNativeScrollback) tags.push("strictScrollback");
if (template.terminalMode !== "normal") tags.push("unknownViewport");
if (foregroundStreaming) tags.push("foregroundStream");
if (isEd3RiskScenario(template.terminalMode, template.envMode)) tags.push("ed3Risk");
return tags;
}
class StressModel {
readonly lines: LogicalLine[] = [];
readonly minLines: number;
#rng: Rng;
#nextId = 0;
#collapsibleIds: number[] = [];
#cursorLineIndex: number | null = null;
#cursorMode: CursorMode = "end";
#uniqueContent: boolean;
#usedText = new Set<string>();
#labelPrefix: string;
constructor(rng: Rng, minLines: number, uniqueContent = false, labelPrefix = "") {
this.#rng = rng;
this.minLines = minLines;
this.#uniqueContent = uniqueContent;
this.#labelPrefix = labelPrefix;
const initialLength = minLines + 20;
for (let i = 0; i < initialLength; i++) {
this.lines.push(this.#line(this.#initialText(i)));
}
}
renderedLines(width: number, focused = false): string[] {
const lines = this.lines.map(line => line.text);
if (focused && lines.length > 0) {
const index = this.#clampedCursorLineIndex();
lines[index] = insertCursorMarker(lines[index] ?? "", this.#cursorMode, width);
}
return lines;
}
debugLines(): string[] {
const cursor = this.#cursorLineIndex === null ? "none" : `${this.#cursorLineIndex}:${this.#cursorMode}`;
return [`cursor:${cursor}`, ...this.lines.map(line => `${line.id}:${JSON.stringify(line.text)}`)];
}
setCursorVisible(height: number, width: number): JsonObject {
this.#ensureLine();
const start = Math.max(0, this.lines.length - height);
const index = this.#rng.int(start, this.lines.length - 1);
return this.#setCursor(index, width, false);
}
setCursorOffscreen(height: number, width: number): JsonObject {
while (this.lines.length <= height) {
this.lines.push(this.#randomLine("u"));
}
const limit = Math.max(1, this.lines.length - height);
const index = this.#rng.int(0, limit - 1);
return this.#setCursor(index, width, true);
}
appendSmall(): JsonObject {
const count = this.#rng.int(1, 3);
for (let i = 0; i < count; i++) {
this.lines.push(this.#randomLine("a"));
}
return { count };
}
// Append a row whose visible width EXACTLY equals the terminal width. Half
// the time the final cell is a wide (2-cell) glyph so the exact-fit boundary
// lands a double-width char on the last column — the pending-wrap trigger the
// renderer's autowrap-off discipline must neutralize.
appendExactWidth(width: number): JsonObject {
const text = this.#exactWidthLine(width);
this.lines.push(this.#line(text));
return { width, text, visibleWidth: visibleWidth(text) };
}
#exactWidthLine(width: number): string {
if (width <= 0) return "";
const label = `${this.#labelPrefix}ew${this.#nextId.toString(36)}`;
// Pad with enough ASCII fill to cover any terminal width (the widest stress
// geometry is 120 cols). ASCII is one cell per code unit, so a code-unit
// slice is a cell-exact slice.
const fill = label.length >= width ? label : `${label}${".".repeat(width)}`;
// End on a wide CJK char (2 cells) on the wider rows so the exact-fit
// boundary lands a double-width glyph on the last column.
if (width >= 3 && this.#rng.chance(0.5)) {
return `${fill.slice(0, width - 2)}界`;
}
return fill.slice(0, width);
}
appendBulk(maxBulk: number): JsonObject {
const min = Math.min(20, maxBulk);
const count = this.#rng.int(min, maxBulk);
for (let i = 0; i < count; i++) {
this.lines.push(this.#randomLine("b"));
}
return { count };
}
streamOne(): JsonObject {
this.lines.push(this.#randomLine("s"));
return { count: 1 };
}
appendRepeatedTail(): JsonObject {
if (this.#uniqueContent) {
const line = this.#freshLine("repeatAlt");
this.lines.push(line);
return { convertedToUnique: true, text: line.text };
}
const text = this.lines[this.lines.length - 1]?.text ?? "";
this.lines.push(this.#line(text));
return { text };
}
appendDuplicateOfExisting(): JsonObject {
const sourceIndex = this.#rng.int(0, this.lines.length - 1);
if (this.#uniqueContent) {
const line = this.#freshLine("dupAlt");
this.lines.push(line);
return { sourceIndex, convertedToUnique: true, text: line.text };
}
const text = this.lines[sourceIndex]?.text ?? "";
this.lines.push(this.#line(text));
return { sourceIndex, text };
}
injectBlankCluster(): JsonObject {
const count = this.#rng.int(2, 8);
for (let i = 0; i < count; i++) {
this.lines.push(this.#line(""));
}
return { count };
}
editVisibleLine(height: number): JsonObject {
const start = Math.max(0, this.lines.length - height);
const index = this.#rng.int(start, this.lines.length - 1);
const before = this.lines[index]?.text ?? "";
this.lines[index] = this.#randomLine("v");
return { index, before, after: this.lines[index]?.text ?? "" };
}
editOffscreenLine(height: number): JsonObject {
const limit = Math.max(1, this.lines.length - height);
const index = this.#rng.int(0, limit - 1);
const before = this.lines[index]?.text ?? "";
this.lines[index] = this.#randomLine("o");
return { index, before, after: this.lines[index]?.text ?? "" };
}
offscreenEditAppendRepeatedTail(height: number): JsonObject {
while (this.lines.length < height + 3) {
this.lines.push(this.#randomLine("p"));
}
const previousLength = this.lines.length;
const offscreenLimit = Math.max(1, previousLength - height);
const offscreenIndex = this.#rng.int(0, offscreenLimit - 1);
const previousLast = this.lines[previousLength - 1]?.text ?? "";
this.lines[offscreenIndex] = this.#randomLine("x");
const repeatedIndex = Math.max(0, previousLength - 2);
this.lines[repeatedIndex] = this.#uniqueContent ? this.#freshLine("xAlt") : this.#line(previousLast);
this.lines[previousLength - 1] = this.#randomLine("e");
this.lines.push(this.#randomLine("f"));
return { offscreenIndex, repeatedIndex, previousLast, previousLength };
}
insertOffscreen(height: number): JsonObject {
const count = this.#rng.int(1, 4);
const limit = Math.max(1, this.lines.length - height);
const index = this.#rng.int(0, limit - 1);
this.lines.splice(index, 0, ...this.#newLines(count, "i"));
return { index, count };
}
insertMiddle(): JsonObject {
const count = this.#rng.int(1, 3);
const index = this.#rng.int(1, Math.max(1, this.lines.length - 2));
this.lines.splice(index, 0, ...this.#newLines(count, "m"));
return { index, count };
}
deleteTrailing(): JsonObject {
const removable = Math.max(0, this.lines.length - this.minLines);
if (removable === 0) return { count: 0 };
const count = Math.min(removable, this.#rng.int(1, 4));
const removed = this.lines.splice(this.lines.length - count, count);
return { count, firstRemoved: removed[0]?.text ?? null };
}
deleteMiddle(height: number): JsonObject {
const removable = Math.max(0, this.lines.length - this.minLines);
if (removable === 0) return { count: 0 };
const count = Math.min(removable, this.#rng.int(1, 3));
const offscreenLimit = Math.max(1, this.lines.length - height - count);
const index = this.#rng.int(1, Math.max(1, offscreenLimit));
const removed = this.lines.splice(index, count);
return { index, count: removed.length, firstRemoved: removed[0]?.text ?? null };
}
replaceAll(): JsonObject {
const nextLength = this.#rng.int(this.minLines, this.minLines + 40);
this.lines.splice(0, this.lines.length, ...this.#newLines(nextLength, "r"));
return { nextLength };
}
toggleCollapsible(): JsonObject {
if (this.#collapsibleIds.length > 0) {
const ids = new Set(this.#collapsibleIds);
const before = this.lines.length;
for (let i = this.lines.length - 1; i >= 0; i--) {
const line = this.lines[i];
if (line && ids.has(line.id)) {
this.lines.splice(i, 1);
}
}
const removed = before - this.lines.length;
this.#collapsibleIds = [];
if (removed > 0) {
return { expanded: false, removed };
}
}
const block = this.#uniqueContent
? [this.#freshLine("blk0"), this.#freshLine("blk1"), this.#freshLine("blk2"), this.#freshLine("blk3")]
: [
this.#line(styledText("blk0", 35)),
this.#line(wideText("blk1")),
this.#line(linkedText("blk2")),
this.#line(longText("blk3", 3)),
];
this.#collapsibleIds = block.map(line => line.id);
const index = Math.min(2, this.lines.length);
this.lines.splice(index, 0, ...block);
return { expanded: true, inserted: block.length, index };
}
tickStatusHeader(): JsonObject {
const before = this.lines[0]?.text ?? "";
this.lines[0] = this.#freshLine("h");
return { index: 0, before, after: this.lines[0]?.text ?? "" };
}
rotateUp(): JsonObject {
if (this.lines.length < 2) {
this.lines.push(this.#freshLine("t"));
return { dropped: null, appended: this.lines[this.lines.length - 1]?.text ?? "" };
}
const dropped = this.lines.shift();
this.lines.push(this.#randomLine("t"));
return { dropped: dropped?.text ?? null, appended: this.lines[this.lines.length - 1]?.text ?? "" };
}
collapseToFew(): JsonObject {
const nextLength = this.#rng.int(0, 2);
this.lines.splice(0, this.lines.length, ...this.#newLines(nextLength, "c"));
return { nextLength };
}
clear(): JsonObject {
const previousLength = this.lines.length;
this.lines.splice(0, this.lines.length);
return { previousLength };
}
appendCount(count: number, prefix: string): JsonObject {
this.lines.push(...this.#newLines(count, prefix));
return { count };
}
beginHighWaterPreview(height: number): JsonObject {
while (this.lines.length < height + 8) {
this.lines.push(this.#freshLine("seed"));
}
const start = this.lines.length;
const count = this.#rng.int(height + 4, height + 14);
for (let i = 0; i < count; i++) {
this.lines.push(this.#freshLine("preview"));
}
return { start, count };
}
collapseHighWaterPreview(start: number, count: number): JsonObject {
const removed = this.lines.splice(start, count);
this.#ensureLine();
const editedIndex = this.lines.length - 1;
const before = this.lines[editedIndex]?.text ?? "";
this.lines[editedIndex] = this.#freshLine("done");
return { start, count: removed.length, editedIndex, before, after: this.lines[editedIndex]?.text ?? "" };
}
swapOffscreenRows(height: number): JsonObject {
const offscreenLimit = this.lines.length - height;
if (offscreenLimit < 2) return { swapped: 0 };
const i = this.#rng.int(0, offscreenLimit - 1);
let j = this.#rng.int(0, offscreenLimit - 1);
if (j === i) j = (j + 1) % offscreenLimit;
const a = this.lines[i]!;
const b = this.lines[j]!;
this.lines[i] = b;
this.lines[j] = a;
return { swapped: 2, i, j };
}
#initialText(index: number): string {
if (this.#uniqueContent) return index % 13 === 0 ? "" : `${this.#labelPrefix}init${index.toString(36)}`;
if (index % 13 === 0) return "";
if (index % 37 === 0) return backgroundStyledText(`bg${index.toString(36)}`, 41 + (index % 6));
if (index % 31 === 0) return emojiPresentationText(`ep${index.toString(36)}`);
if (index % 29 === 0) return arabicCombiningText(`ar${index.toString(36)}`);
if (index % 23 === 0) return longText(`L${index.toString(36)}`, 4);
if (index % 19 === 0) return linkedText(`link${index.toString(36)}`);
if (index % 17 === 0) return styledText(`sg${index.toString(36)}界`, 31 + (index % 6));
if (index % 11 === 0) return wideText(`w${index.toString(36)}`);
if (index % 7 === 0) return `r${index % 3}`;
return `l${index.toString(36)}`;
}
#newLines(count: number, prefix: string): LogicalLine[] {
const lines: LogicalLine[] = [];
for (let i = 0; i < count; i++) {
lines.push(this.#randomLine(prefix));
}
return lines;
}
#randomLine(prefix: string): LogicalLine {
if (this.#uniqueContent) return this.#freshLine(prefix);
const roll = this.#rng.next();
if (roll < 0.1) return this.#line("");
if (roll < 0.2) return this.#line(`r${this.#rng.int(0, 3)}`);
if (roll < 0.34 && this.lines.length > 0) {
const source = this.lines[this.#rng.int(0, this.lines.length - 1)];
return this.#line(source?.text ?? "");
}
return this.#freshLine(prefix);
}
#freshLine(prefix: string): LogicalLine {
for (;;) {
const id = this.#nextId.toString(36);
const text = randomDecoratedText(this.#rng, `${this.#labelPrefix}${prefix}${id}`);
if (!this.#uniqueContent || text.length === 0 || !this.#usedText.has(text)) return this.#line(text);
this.#nextId += 1;
}
}
#ensureLine(): void {
if (this.lines.length === 0) {
this.lines.push(this.#freshLine("q"));
}
}
#setCursor(index: number, width: number, offscreen: boolean): JsonObject {
const clampedIndex = Math.max(0, Math.min(index, this.lines.length - 1));
const text = this.lines[clampedIndex]?.text ?? "";
const mode = pickCursorMode(this.#rng, text, width);
this.#cursorLineIndex = clampedIndex;
this.#cursorMode = mode;
return { index: clampedIndex, mode, offscreen, text };
}
#clampedCursorLineIndex(): number {
if (this.lines.length === 0) return 0;
if (this.#cursorLineIndex === null) return this.lines.length - 1;
return Math.max(0, Math.min(this.#cursorLineIndex, this.lines.length - 1));
}
#line(text: string): LogicalLine {
const line = { id: this.#nextId, text };
this.#nextId += 1;
if (text.length > 0) this.#usedText.add(text);
return line;
}
}
// Wrap a rendered line set to the viewport width, ANSI- and grapheme-aware, so
// a logical line can occupy a width-dependent NUMBER of physical rows — the
// reflow that real wrapped/markdown content performs and that fixed-line
// components never exercised. Use the renderer's native wrapper rather than
// Bun.wrapAnsi so combining marks stay with their base grapheme instead of
// starting a physical row the terminal will fold back into the previous cell.
function reflowToWidth(lines: readonly string[], width: number): string[] {
const target = Math.max(1, width);
const out: string[] = [];
for (const line of lines) {
if (line.length === 0) {
out.push("");
continue;
}
for (const physical of wrapTextWithAnsi(line, target)) out.push(physical);
}
return out;
}
class StressComponent implements Component, Focusable {
focused = false;
#model: StressModel;
#reflow: boolean;
constructor(model: StressModel, reflow = false) {
this.#model = model;
this.#reflow = reflow;
}
invalidate(): void {}
render(width: number): string[] {
const lines = this.#model.renderedLines(width, this.focused);
return this.#reflow ? reflowToWidth(lines, width) : lines;
}
}
class StressOverlayModel {
readonly lines: LogicalLine[] = [];
readonly sentinel: string;
#rng: Rng;
#nextId = 0;
#cursorLineIndex = 0;
#cursorMode: CursorMode = "middle";
constructor(rng: Rng, id: number) {
this.#rng = rng;
this.sentinel = `OV_SENTINEL_${id.toString(36)}_`;
const count = rng.int(1, 5);
this.lines.push(this.#line(`${this.sentinel}${randomDecoratedText(rng, `ov${id}-0`)}`));
for (let i = 1; i < count; i++) {
this.lines.push(this.#line(randomDecoratedText(rng, `ov${id}-${i}`)));
}
}
renderedLines(width: number, focused = false): string[] {
const lines = this.lines.map(line => line.text);
if (!lines.some(line => line.includes(this.sentinel))) lines.unshift(this.sentinel);
if (focused && lines.length > 0) {
const index = this.#clampedCursorLineIndex();
lines[index] = insertCursorMarker(lines[index] ?? "", this.#cursorMode, width);
}
return lines;
}
mutate(width: number): JsonObject {
this.#ensureLine();
const action = this.#rng.int(0, 3);
if (action === 0 || this.lines.length === 1) {
const line = this.#freshLine("oa");
this.lines.push(line);
return { action: "append", text: line.text };
}
if (action === 1) {
const index = this.#rng.int(0, this.lines.length - 1);
const before = this.lines[index]?.text ?? "";
this.lines[index] = this.#freshLine("oe");
return { action: "edit", index, before, after: this.lines[index]?.text ?? "" };
}
if (action === 2) {
const index = this.#rng.int(0, this.lines.length - 1);
const removed = this.lines.splice(index, 1);
return { action: "delete", index, removed: removed[0]?.text ?? "" };
}
return { action: "cursor", ...this.setCursor(width) };
}
setCursor(width: number): JsonObject {
this.#ensureLine();
const index = this.#rng.int(0, this.lines.length - 1);
const text = this.lines[index]?.text ?? "";
const mode = pickCursorMode(this.#rng, text, width);
this.#cursorLineIndex = index;
this.#cursorMode = mode;
return { index, mode, text };
}
debugLines(): string[] {
return this.lines.map(line => `${line.id}:${JSON.stringify(line.text)}`);
}
#freshLine(prefix: string): LogicalLine {
const id = this.#nextId.toString(36);
return this.#line(randomDecoratedText(this.#rng, `${prefix}${id}`));
}
#ensureLine(): void {
if (this.lines.length === 0) {
this.lines.push(this.#freshLine("oq"));
}
}
#clampedCursorLineIndex(): number {
return Math.max(0, Math.min(this.#cursorLineIndex, this.lines.length - 1));
}
#line(text: string): LogicalLine {
const line = { id: this.#nextId, text };
this.#nextId += 1;
return line;
}
}
class StressOverlayComponent implements Component, Focusable {
focused = false;
#model: StressOverlayModel;
constructor(model: StressOverlayModel) {
this.#model = model;
}
invalidate(): void {}
render(width: number): string[] {
return this.#model.renderedLines(width, this.focused);
}
}
class StressDriver {
#scenario: Scenario;
#streams: StressRandomStreams;
#traits: TerminalStressTraits;
#scheduler: StressRenderScheduler;
#term: VirtualTerminal;
#tui: TUI;
#model: StressModel;
#component: StressComponent;
#children: StressChildEntry[] = [];
#overlays: StressOverlayEntry[] = [];
#hiddenOverlaySentinels = new Set<string>();
#nextOverlayId = 0;
#opLog: OperationLogEntry[] = [];
#operationCoverage = new Map<OperationLogKind, number>();
// Lines that legitimately appeared 2+ times in any committed frame. Native
// scrollback retains rows from every past frame — content that leaves the
// frame (a detached child, collapsed preview, truncation-colliding rows
// after a width shrink) keeps its committed copies in history forever, so
// the duplicate oracle must allow them cumulatively, not just against the
// current frame.
#everDuplicatedFrameLines = new Set<string>();
// Shadow commit ledger mirroring the engine's append-only law, fed only by
// observed frames (render wrap) and observed bytes (write wrap) — never by
// engine internals. `#shadowTape` is what native scrollback must contain;
// `#shadowWindowTop` is the frame row mapped to grid row 0. Double-entry
// bookkeeping for committedRows/windowTop: the engine and this ledger must
// independently arrive at the same terminal state.
#shadowTape: string[] = [];
#shadowCommitted = 0;
// Raw-row mirror of the engine's committed prefix. The resync audit must
// run on the same inputs as the engine (raw rows, not normalized ones), or
// width-truncation collisions would let the two ledgers disagree about
// whether a divergence happened.
#shadowRawFrame: string[] = [];
#shadowRawPrefix: string[] = [];
#shadowWindowTop = 0;
#shadowFrame: string[] = [];
#shadowFrameHeight = 0;
#shadowFrameWidth = 0;
#shadowFrameOverlay = false;
#shadowFrameGeometryChanged = false;
#shadowResizePending = false;
#shadowAltActive = false;
// Every byte the renderer wrote to the terminal, in order. The sync-output
// discipline oracle audits bracket balance incrementally from #writeLogScanned
// and carries partial private CSI sequences across write chunks.
#writeLog: string[] = [];
#writeLogScanned = 0;
#ansiCarry = "";
// Running depth of synchronized-output (DEC 2026) and autowrap-disable (DECAWM)
// brackets across the whole session; both must return to 0 at every op
// boundary and never go out of {0,1}.
#syncDepth = 0;
#autowrapOffDepth = 0;
constructor(scenario: Scenario) {
this.#scenario = scenario;
this.#streams = createRandomStreams(scenario.seed);
this.#traits = terminalStressTraits(scenario);
this.#scheduler = new StressRenderScheduler();
const maxHeight = maxOf(scenario.heightChoices);
this.#model = new StressModel(this.#streams.content, maxHeight + 12, scenario.uniqueContent, "root-");
this.#component = new StressComponent(this.#model, scenario.reflow);
this.#children = [0, 1].map(id => {
const model = new StressModel(
this.#streams.children,
Math.max(1, Math.min(3, maxHeight)),
scenario.uniqueContent,
`child${id}-`,
);
return { id, model, component: new StressComponent(model, scenario.reflow), active: false };
});
this.#term = createTerminal(scenario);
// Capture every byte written to the terminal so per-op oracles can audit
// emission discipline (synchronized-output bracketing, autowrap restore).
const realWrite = this.#term.write.bind(this.#term);
(this.#term as { write: (data: string) => void }).write = (data: string) => {
this.#writeLog.push(data);
realWrite(data);
this.#applyShadowWrite(data);
};
// Mirror the engine's resize-event signal: a net-unchanged resize still
// reflows the terminal, and the engine classifies it as a geometry frame
// (audit skipped, commits frozen in multiplexers) — a dimension compare
// alone cannot see it.
const realResize = this.#term.resize.bind(this.#term);
(this.#term as { resize: (columns: number, rows: number) => void }).resize = (columns: number, rows: number) => {
this.#shadowResizePending = true;
realResize(columns, rows);
};
this.#tui = new TUI(this.#term, true, { renderScheduler: this.#scheduler });
this.#tui.addChild(this.#component);
const realRender = this.#tui.render.bind(this.#tui);
(this.#tui as { render: (width: number) => readonly string[] }).render = (width: number) => {
const lines = realRender(width);
this.#shadowFrameGeometryChanged =
this.#shadowResizePending ||
(this.#shadowFrameWidth > 0 &&
(width !== this.#shadowFrameWidth || this.#term.rows !== this.#shadowFrameHeight));
this.#shadowResizePending = false;
// Markers are engine-internal sentinels; the engine strips them from
// this same array immediately after render returns, and its commit
// ledger (prefix + audit) only ever sees stripped rows — mirror that
// exactly. (Also: stripVTControlCharacters would otherwise swallow
// everything after an APC introducer during normalization.)
const stripped = lines.map(line => (line.includes(CURSOR_MARKER) ? line.replaceAll(CURSOR_MARKER, "") : line));
this.#shadowRawFrame = stripped;
this.#shadowFrame = stripped.map(line => expectedTerminalLine(line, width));
this.#shadowFrameWidth = width;
this.#shadowFrameHeight = this.#term.rows;
this.#shadowFrameOverlay = this.#tui.hasOverlay();
// Mirror the engine's render-time ledger transitions here: the audit
// resync and the shrink-into-prefix re-anchor can both fire on frames
// that emit zero bytes, which the write hook would never observe.
if (!this.#shadowFrameGeometryChanged && this.#shadowRawPrefix.length > 0) {
const resyncTo = findCommittedPrefixResync(stripped, this.#shadowRawPrefix);
if (resyncTo >= 0) {
this.#shadowCommitted = resyncTo;
this.#shadowRawPrefix.length = resyncTo;
// Mirror the engine's cursor-tail re-anchor: a resync that
// leaves a focused cursor tail shorter than the viewport pulls
// the window back down to the frame tail and restarts commit
// bookkeeping there. The shrink-into-prefix block below owns
// the frame-shorter-than-prefix case with the same formula.
const rows = Math.max(1, this.#term.rows);
let cursorInTail = false;
for (let i = this.#shadowCommitted; i < lines.length; i++) {
if (lines[i]!.includes(CURSOR_MARKER)) {
cursorInTail = true;
break;
}
}
if (
cursorInTail &&
stripped.length > this.#shadowCommitted &&
stripped.length - this.#shadowCommitted < rows
) {
this.#shadowCommitted = Math.max(0, stripped.length - rows);
this.#shadowWindowTop = this.#shadowCommitted;
this.#shadowRawPrefix = stripped.slice(0, this.#shadowCommitted);
}
}
}
if (stripped.length <= this.#shadowCommitted) {
this.#shadowCommitted = Math.max(0, stripped.length - Math.max(1, this.#term.rows));
this.#shadowWindowTop = this.#shadowCommitted;
this.#shadowRawPrefix = stripped.slice(0, this.#shadowCommitted);
}
return lines;
};
}
async run(): Promise<void> {
try {
this.#tui.start();
await this.#settle();
this.#assertOracles(
{
kind: "forceRender",
detail: { initial: true },
mutatesContent: false,
checksRowAccounting: false,
geometryChanged: false,
forcedRender: true,
mutatesViewport: false,
checkpoint: false,
},
this.#snapshot(),
this.#snapshot(),
-1,
);
for (let index = 0; index < this.#scenario.iterations; index++) {
const before = this.#snapshot();
const kind = this.#scenario.replayOperations?.[index] ?? this.#chooseOperation(index, before);
const op = await this.#applyOperation(kind);
const after = this.#snapshot();
this.#recordOperation(index, op.kind, op.detail, before, after);
this.#assertOracles(op, before, after, index);
if ((index + 1) % 50 === 0) {
await this.#checkpoint(index, "periodicCheckpoint");
}
}
} finally {
this.#tui.stop();
await this.#term.flush();
}
}
#snapshot(): Snapshot {
const position = this.#term.getBufferPosition();
const expected = this.#expectedFrame();
const view = normalizeLines(this.#term.getViewport());
const viewBackgroundColumns: number[][] = [];
for (let row = 0; row < this.#term.rows; row++) {
viewBackgroundColumns.push(this.#term.getViewportRowBackgroundColumns(row));
}
// Tmux pane history is intentionally preserved, so overlay bytes can remain
// in historical scrollback after resize/reflow. The non-strict tmux stress
// oracle only checks live viewport behavior; avoid repeatedly materializing
// huge preserved pane history that no invariant consumes.
return {
buffer: this.#traits.preservesPaneHistory ? view : normalizeLines(this.#term.getScrollBuffer()),
view,
viewBackgroundColumns,
frameBackgroundColumns: expected.backgroundColumns,
position,
cursor: this.#term.getCursor(),
expectedCursor: expected.cursor,
redraws: this.#tui.fullRedraws,
width: this.#term.columns,
height: this.#term.rows,
frame: expected.frame,
atBottom: position.viewportY >= position.baseY,
shadowTapeLength: this.#shadowTape.length,
};
}
#expectedFrame(): ExpectedFrame {
const width = this.#term.columns;
const height = this.#term.rows;
const baseLines = this.#baseFrameLines(width);
const composed = compositeExpectedOverlays(baseLines, this.#overlays, width, height);
return expectedFrameFromLines(composed, width, height);
}
#baseFrameLines(width: number): string[] {
return [
...this.#component.render(width),
...this.#children.flatMap(child => (child.active ? child.component.render(width) : [])),
];
}
#hasVisibleOverlay(): boolean {
return this.#overlays.some(entry => isExpectedOverlayVisible(entry, this.#term.columns, this.#term.rows));
}
#settle(): Promise<void> {
return this.#scheduler.drain(this.#term);
}
#chooseOperation(index: number, before: Snapshot): OperationKind {
if (
(this.#traits.ed3ScrollbackEraseRisk || this.#traits.conptyHostScrollbackUnobservable) &&
before.position.baseY > 0
) {
if (before.atBottom && index % 47 === 0) return "scrollUp";
if (!before.atBottom && index % 47 === 1) {
return this.#traits.foregroundStreaming ? "streamOne" : "eagerStreamingMutation";
}
}
if (
this.#traits.strictNativeScrollback &&
before.atBottom &&
before.frame.length > before.height + 8 &&
index % 43 === 0
) {
return "collapseToFew";
}
if (
this.#traits.strictNativeScrollback &&
before.atBottom &&
before.frame.length > before.height + 8 &&
!this.#hasVisibleOverlay() &&
index % 37 === 0
) {
return "highWaterPreviewCollapse";
}
if (this.#traits.strictNativeScrollback && before.atBottom && index % 41 === 0) {
return "offscreenEditAppendRepeatedTail";
}
if (!before.atBottom && this.#streams.ops.chance(0.28)) {
return "scrollToBottom";
}
// Exact-width rows are the pending-wrap / DECAWM boundary case: a row whose
// visible width equals the terminal width writes its last cell, latching
// pending-wrap on autowrap terminals so a following cursor move can wrap and
// staircase. The renderer disables autowrap around paints (\x1b[?7l). Skipped
// for uniqueContent scenarios — at width 1-2 the finite cell alphabet cannot
// stay unique across hundreds of ops.
const weighted: readonly WeightedCandidate<OperationKind>[] = [
{ item: "appendSmall", weight: 14 },
{ item: "streamOne", weight: 12 },
{ item: "appendExactWidth", weight: this.#scenario.uniqueContent ? 0 : 5 },
{ item: "appendRepeatedTail", weight: this.#scenario.uniqueContent ? 2 : 8 },
{ item: "appendDuplicateOfExisting", weight: this.#scenario.uniqueContent ? 2 : 8 },
{ item: "injectBlankCluster", weight: 5 },
{ item: "appendBulk", weight: 3 },
{ item: "editVisibleLine", weight: 8 },
{ item: "editOffscreenLine", weight: 7 },
{ item: "offscreenEditAppendRepeatedTail", weight: 5 },
{ item: "insertOffscreen", weight: 3 },
{ item: "insertMiddle", weight: 2 },
{ item: "deleteTrailing", weight: 3 },
{ item: "deleteMiddle", weight: 2 },
{ item: "replaceAll", weight: 1 },
{ item: "toggleCollapsible", weight: 2 },
{ item: "tickStatusHeader", weight: 8 },
{ item: "scrollUp", weight: before.position.baseY > 0 ? 4 : 0 },
{ item: "scrollPartial", weight: before.position.baseY > 0 ? 3 : 0 },
{ item: "scrollToBottom", weight: before.atBottom ? 2 : 8 },
{ item: "resizeWidth", weight: 3 },
{ item: "resizeHeight", weight: 3 },
{ item: "forceRender", weight: 2 },
{ item: "forceRenderAllowUnknown", weight: 2 },
{ item: "forceRenderClearScrollback", weight: 1 },
{ item: "forceRenderAfterEmptyOverflow", weight: 1 },
{ item: "toggleFocusInput", weight: 2 },
{ item: "moveCursorVisible", weight: 3 },
{ item: "moveCursorOffscreen", weight: 2 },
{ item: "showOverlay", weight: this.#overlays.length < 2 ? 3 : 1 },
{ item: "hideOverlay", weight: this.#overlays.length > 0 ? 2 : 0 },
{ item: "toggleOverlayHidden", weight: this.#overlays.length > 0 ? 2 : 0 },
{ item: "editOverlay", weight: this.#overlays.length > 0 ? 4 : 0 },
{ item: "moveOverlayCursor", weight: this.#overlays.length > 0 ? 2 : 0 },
{ item: "coalescedBurst", weight: 6 },
{ item: "rotateUp", weight: 4 },
{ item: "swapOffscreenRows", weight: 3 },
{ item: "collapseToFew", weight: 1 },
{ item: "highWaterPreviewCollapse", weight: 2 },
// `eagerStreamingMutation` toggles the eager opt-in off in its `finally`,
// which would end the modeled foreground-tool turn early; a foregroundStream
// scenario keeps the opt-in on for its whole run, so skip it there.
{
item: "eagerStreamingMutation",
weight: this.#traits.preservesPaneHistory || this.#traits.foregroundStreaming ? 0 : 3,
},
{ item: "resizeBoth", weight: 2 },
{ item: "resizeNoop", weight: 1 },
{ item: "resizeWithAppend", weight: 2 },
{ item: "attachChild", weight: this.#children.some(child => !child.active) ? 2 : 0 },
{ item: "detachChild", weight: this.#children.some(child => child.active) ? 2 : 0 },
{ item: "reorderChildren", weight: this.#children.filter(child => child.active).length > 1 ? 1 : 0 },
{ item: "mutateChild", weight: this.#children.some(child => child.active) ? 3 : 0 },
];
return weightedPick(this.#streams.ops, weighted);
}
async #applyOperation(kind: OperationKind): Promise<AppliedOperation> {
switch (kind) {
case "appendSmall":
return await this.#applyContent(kind, this.#model.appendSmall(), true);
case "appendExactWidth":
return await this.#applyContent(kind, this.#model.appendExactWidth(this.#term.columns), true);
case "appendBulk":
return await this.#applyContent(kind, this.#model.appendBulk(this.#scenario.bulkMax), true);
case "streamOne":
return await this.#applyContent(kind, this.#model.streamOne(), true);
case "editVisibleLine":
return await this.#applyContent(kind, this.#model.editVisibleLine(this.#term.rows), true);
case "editOffscreenLine":
return await this.#applyContent(kind, this.#model.editOffscreenLine(this.#term.rows), true);
case "offscreenEditAppendRepeatedTail":
return await this.#applyContent(kind, this.#model.offscreenEditAppendRepeatedTail(this.#term.rows), true);
case "insertOffscreen":
return await this.#applyContent(kind, this.#model.insertOffscreen(this.#term.rows), true);
case "insertMiddle":
return await this.#applyContent(kind, this.#model.insertMiddle(), true);
case "deleteTrailing":
return await this.#applyContent(kind, this.#model.deleteTrailing(), false);
case "deleteMiddle":
return await this.#applyContent(kind, this.#model.deleteMiddle(this.#term.rows), true);
case "replaceAll":
return await this.#applyContent(kind, this.#model.replaceAll(), true);
case "toggleCollapsible":
return await this.#applyContent(kind, this.#model.toggleCollapsible(), true);
case "tickStatusHeader":
return await this.#applyContent(kind, this.#model.tickStatusHeader(), true);
case "appendRepeatedTail":
return await this.#applyContent(kind, this.#model.appendRepeatedTail(), true);
case "injectBlankCluster":
return await this.#applyContent(kind, this.#model.injectBlankCluster(), true);
case "appendDuplicateOfExisting":
return await this.#applyContent(kind, this.#model.appendDuplicateOfExisting(), true);
case "highWaterPreviewCollapse":
return await this.#highWaterPreviewCollapse();
case "eagerStreamingMutation":
return await this.#eagerStreamingMutation();
case "scrollUp":
return await this.#scrollUp();
case "scrollToBottom":
return await this.#scrollToBottom();
case "scrollPartial":
return await this.#scrollPartial();
case "resizeWidth":
return await this.#resizeWidth();
case "resizeHeight":
return await this.#resizeHeight();
case "resizeWithAppend":
return await this.#resizeWithAppend();
case "forceRender":
return await this.#forceRender();
case "forceRenderAllowUnknown":
return await this.#forceRenderAllowUnknown();
case "forceRenderClearScrollback":
return await this.#forceRenderClearScrollback();
case "forceRenderAfterEmptyOverflow":
return await this.#forceRenderAfterEmptyOverflow();
case "toggleFocusInput":
return await this.#toggleFocusInput();
case "moveCursorVisible":
return await this.#moveBaseCursor("moveCursorVisible", false);
case "moveCursorOffscreen":
return await this.#moveBaseCursor("moveCursorOffscreen", true);
case "showOverlay":
return await this.#showOverlay();
case "hideOverlay":
return await this.#hideOverlay();
case "toggleOverlayHidden":
return await this.#toggleOverlayHidden();
case "editOverlay":
return await this.#editOverlay();
case "moveOverlayCursor":
return await this.#moveOverlayCursor();
case "rotateUp":
return await this.#applyContent(kind, this.#model.rotateUp(), false);
case "collapseToFew":
return await this.#applyContent(kind, this.#model.collapseToFew(), false);
case "swapOffscreenRows":
return await this.#applyContent(kind, this.#model.swapOffscreenRows(this.#term.rows), false);
case "coalescedBurst":
return await this.#coalescedBurst();
case "resizeBoth":
return await this.#resizeBoth();
case "resizeNoop":
return await this.#resizeNoop();
case "attachChild":
return await this.#attachChild();
case "detachChild":
return await this.#detachChild();
case "reorderChildren":
return await this.#reorderChildren();
case "mutateChild":
return await this.#mutateChild();
default:
return assertNever(kind);
}
}
async #applyContent(
kind: OperationKind,
detail: JsonObject,
checksRowAccounting: boolean,
): Promise<AppliedOperation> {
this.#renderContentFrame();
await this.#settle();
return contentOperation(kind, detail, checksRowAccounting);
}
async #eagerStreamingMutation(): Promise<AppliedOperation> {
const detail: JsonObject = this.#streams.content.chance(0.5)
? this.#model.streamOne()
: this.#model.editOffscreenLine(this.#term.rows);
this.#renderContentFrame();
await this.#settle();
return contentOperation("eagerStreamingMutation", detail, false);
}
#renderContentFrame(): void {
if (this.#traits.foregroundStreaming) {
// A foreground tool's own re-render: a plain, non-forced request. The
// renderer keeps the live tail through `viewportRepaint`/`diff`;
// offscreen-edit growth advances the rendered line count without
// committing the overflow to native history, which is the lagging
// high-water state a later shrink must still re-anchor from.
this.#tui.requestRender();
return;
}
const position = this.#term.getBufferPosition();
const atBottom = position.viewportY >= position.baseY;
if (!this.#traits.strictNativeScrollback && atBottom) {
this.#tui.requestRender(true);
} else {
this.#tui.requestRender();
}
}
async #highWaterPreviewCollapse(): Promise<AppliedOperation> {
// `beginHighWaterPreview` first pads seed rows up to `height + 8`, THEN
// pushes the preview rows — so the op's true peak frame growth is the
// padding plus the preview count, not the preview count alone. In a
// multiplexer every one of those overflowing rows enters irretractable pane
// history, so the transient bound must measure the actual expansion.
const lengthBeforeBegin = this.#model.lines.length;
const begin = this.#model.beginHighWaterPreview(this.#term.rows);
const expandedFrameGrowth = this.#model.lines.length - lengthBeforeBegin;
this.#renderContentFrame();
await this.#settle();
const start = typeof begin.start === "number" ? begin.start : 0;
const count = typeof begin.count === "number" ? begin.count : 0;
const collapse = this.#model.collapseHighWaterPreview(start, count);
this.#renderContentFrame();
await this.#settle();
return {
kind: "highWaterPreviewCollapse",
detail: { begin, collapse },
mutatesContent: true,
checksRowAccounting: false,
geometryChanged: false,
forcedRender: false,
mutatesViewport: false,
checkpoint: false,
// The preview rows AND the seed-padding rows that begin appended scroll
// into history while expanded; the collapse cannot retract them, and a
// multiplexer pane keeps every one. Bound by the measured expansion.
transientFrameGrowth: Math.max(count, expandedFrameGrowth),
};
}
async #coalescedBurst(): Promise<AppliedOperation> {
const count = this.#streams.ops.int(2, 6);
const steps: JsonValue[] = [];
let mutatesContent = false;
let geometryChanged = false;
let forcedRender = false;
let mutatesViewport = false;
for (let i = 0; i < count; i++) {
const stepKind = this.#streams.ops.pick(BURST_STEP_KINDS);
const detail = this.#applyBurstStep(stepKind);
steps.push({ kind: stepKind, detail });
const metadata = BURST_STEP_METADATA[stepKind];
mutatesContent ||= metadata.mutatesContent;
geometryChanged ||= metadata.geometryChanged;
mutatesViewport ||= metadata.mutatesViewport;
forcedRender ||= metadata.forcedRender;
// Schedule without settling so the throttle coalesces every step into one paint.
if (stepKind !== "forceRender") this.#tui.requestRender();
}
this.#renderContentFrame();
await this.#settle();
return {
kind: "coalescedBurst",
detail: { count, steps },
mutatesContent,
checksRowAccounting: false,
geometryChanged,
forcedRender,
mutatesViewport,
checkpoint: false,
coalesced: true,
};
}
#applyBurstStep(kind: BurstStepKind): JsonObject {
switch (kind) {
case "appendSmall":
return this.#model.appendSmall();
case "streamOne":
return this.#model.streamOne();
case "appendRepeatedTail":
return this.#model.appendRepeatedTail();
case "injectBlankCluster":
return this.#model.injectBlankCluster();
case "editVisibleLine":
return this.#model.editVisibleLine(this.#term.rows);
case "editOffscreenLine":
return this.#model.editOffscreenLine(this.#term.rows);
case "tickStatusHeader":
return this.#model.tickStatusHeader();
case "resizeWidth": {
const columns = this.#pickDifferent(this.#scenario.widthChoices, this.#term.columns);
this.#term.resize(columns, this.#term.rows);
return { columns };
}
case "resizeHeight": {
const rows = this.#pickDifferent(this.#scenario.heightChoices, this.#term.rows);
this.#term.resize(this.#term.columns, rows);
return { rows };
}
case "scrollPartial": {
const amount = this.#streams.geometry.int(1, Math.max(1, this.#term.rows));
const direction = this.#streams.geometry.chance(0.5) ? -1 : 1;
this.#term.scrollLines(direction * amount);
return { amount: direction * amount };
}
case "scrollToBottom":
this.#term.scrollLines(LARGE_SCROLL);
return { amount: LARGE_SCROLL };
case "forceRender":
this.#tui.requestRender(true);
return {};
default:
return assertNever(kind);
}
}
async #moveBaseCursor(
kind: "moveCursorVisible" | "moveCursorOffscreen",
offscreen: boolean,
): Promise<AppliedOperation> {
const cursor = offscreen
? this.#model.setCursorOffscreen(this.#term.rows, this.#term.columns)
: this.#model.setCursorVisible(this.#term.rows, this.#term.columns);
this.#tui.setFocus(this.#component);
this.#tui.requestRender();
await this.#settle();
return this.#viewOperation(kind, { cursor });
}
async #showOverlay(): Promise<AppliedOperation> {
const id = this.#nextOverlayId;
this.#nextOverlayId += 1;
const model = new StressOverlayModel(this.#streams.overlay, id);
const component = new StressOverlayComponent(model);
const { options, detail } = this.#randomOverlayOptions();
const handle = this.#tui.showOverlay(component, options);
const entry: StressOverlayEntry = {
id,
sentinel: model.sentinel,
model,
component,
handle,
options,
hidden: false,
detail,
};
this.#overlays.push(entry);
await this.#settle();
return this.#viewOperation("showOverlay", {
id,
sentinel: model.sentinel,
options: detail,
lines: model.debugLines(),
});
}
async #hideOverlay(): Promise<AppliedOperation> {
const entry = this.#pickOverlay();
if (entry === undefined) return this.#viewOperation("hideOverlay", { skipped: true });
entry.handle.hide();
this.#overlays = this.#overlays.filter(overlay => overlay !== entry);
this.#hiddenOverlaySentinels.add(entry.sentinel);
await this.#settle();
return this.#viewOperation("hideOverlay", { id: entry.id, sentinel: entry.sentinel });
}
async #toggleOverlayHidden(): Promise<AppliedOperation> {
const entry = this.#pickOverlay();
if (entry === undefined) return this.#viewOperation("toggleOverlayHidden", { skipped: true });
entry.hidden = !entry.hidden;
entry.handle.setHidden(entry.hidden);
if (entry.hidden) this.#hiddenOverlaySentinels.add(entry.sentinel);
await this.#settle();
return this.#viewOperation("toggleOverlayHidden", {
id: entry.id,
sentinel: entry.sentinel,
hidden: entry.hidden,
});
}
async #editOverlay(): Promise<AppliedOperation> {
const entry = this.#pickOverlay();
if (entry === undefined) return this.#viewOperation("editOverlay", { skipped: true });
const detail = entry.model.mutate(this.#term.columns);
this.#tui.requestRender();
await this.#settle();
return this.#viewOperation("editOverlay", { id: entry.id, detail });
}
async #moveOverlayCursor(): Promise<AppliedOperation> {
const entry = this.#pickOverlay();
if (entry === undefined) return this.#viewOperation("moveOverlayCursor", { skipped: true });
const cursor = entry.model.setCursor(this.#term.columns);
this.#tui.setFocus(entry.component);
this.#tui.requestRender();
await this.#settle();
return this.#viewOperation("moveOverlayCursor", { id: entry.id, cursor });
}
#pickOverlay(): StressOverlayEntry | undefined {
if (this.#overlays.length === 0) return undefined;
return this.#overlays[this.#streams.overlay.int(0, this.#overlays.length - 1)];
}
#randomOverlayOptions(): { options: OverlayOptions; detail: JsonObject } {
const rng = this.#streams.overlay;
const options: OverlayOptions = {};
const detail: JsonObject = {};
if (rng.chance(0.75)) {
const width = rng.chance(0.35)
? (`${rng.pick([25, 40, 60, 80])}%` as `${number}%`)
: rng.int(1, Math.max(1, this.#term.columns + 8));
options.width = width;
detail.width = width;
}
if (rng.chance(0.35)) {
const maxHeight = rng.chance(0.35)
? (`${rng.pick([25, 50, 75])}%` as `${number}%`)
: rng.int(1, Math.max(1, this.#term.rows));
options.maxHeight = maxHeight;
detail.maxHeight = maxHeight;
}
if (rng.chance(0.25)) {
const minWidth = rng.int(1, Math.max(1, this.#term.columns + 4));
options.minWidth = minWidth;
detail.minWidth = minWidth;
}
if (rng.chance(0.5)) {
const anchor = rng.pick(OVERLAY_ANCHORS);
options.anchor = anchor;
options.offsetX = rng.int(-3, 3);
options.offsetY = rng.int(-2, 2);
detail.anchor = anchor;
detail.offsetX = options.offsetX;
detail.offsetY = options.offsetY;
} else {
const row = rng.chance(0.45)
? (`${rng.pick([0, 25, 50, 75, 100])}%` as `${number}%`)
: rng.int(-2, this.#term.rows + 2);
const col = rng.chance(0.45)
? (`${rng.pick([0, 25, 50, 75, 100])}%` as `${number}%`)
: rng.int(-4, this.#term.columns + 4);
options.row = row;
options.col = col;
detail.row = row;
detail.col = col;
}
if (rng.chance(0.6)) {
if (rng.chance(0.5)) {
const margin = rng.int(0, 2);
options.margin = margin;
detail.margin = margin;
} else {
const margin = {
top: rng.int(0, 2),
right: rng.int(0, 2),
bottom: rng.int(0, 2),
left: rng.int(0, 2),
};
options.margin = margin;
detail.margin = margin;
}
}
return { options, detail };
}
async #resizeBoth(): Promise<AppliedOperation> {
const columns = this.#pickDifferent(this.#scenario.widthChoices, this.#term.columns);
const rows = this.#pickDifferent(this.#scenario.heightChoices, this.#term.rows);
this.#term.resize(columns, rows);
// foregroundStream models a live tool turn: let the terminal's own resize
// callback drive the (non-forced, gated) repaint the real app relies on,
// rather than forcing a full rebuild the streaming path never uses.
if (!this.#traits.strictNativeScrollback && !this.#traits.foregroundStreaming) {
this.#tui.requestRender(true);
}
await this.#settle();
return viewOperation("resizeBoth", { columns, rows }, { geometryChanged: true, mutatesViewport: true });
}
async #resizeNoop(): Promise<AppliedOperation> {
this.#term.resize(this.#term.columns, this.#term.rows);
await this.#settle();
return viewOperation("resizeNoop", { columns: this.#term.columns, rows: this.#term.rows });
}
async #scrollUp(): Promise<AppliedOperation> {
const amount = this.#streams.geometry.int(1, Math.max(1, this.#term.rows * 2));
this.#term.scrollLines(-amount);
await this.#settle();
return this.#viewOperation("scrollUp", { amount });
}
async #scrollToBottom(): Promise<AppliedOperation> {
this.#term.scrollLines(LARGE_SCROLL);
this.#tui.requestRender(true, { clearScrollback: this.#traits.strictNativeScrollback });
await this.#settle();
return forceRenderOperation(
"scrollToBottom",
{ forcedCheckpoint: this.#traits.strictNativeScrollback },
{ checkpoint: true, mutatesViewport: true },
);
}
async #scrollPartial(): Promise<AppliedOperation> {
const amount = this.#streams.geometry.int(1, Math.max(1, this.#term.rows));
const direction = this.#streams.geometry.chance(0.5) ? -1 : 1;
this.#term.scrollLines(direction * amount);
await this.#settle();
return this.#viewOperation("scrollPartial", { amount: direction * amount });
}
async #resizeWidth(): Promise<AppliedOperation> {
const columns = this.#pickDifferent(this.#scenario.widthChoices, this.#term.columns);
this.#term.resize(columns, this.#term.rows);
if (!this.#traits.strictNativeScrollback && !this.#traits.foregroundStreaming) {
this.#tui.requestRender(true);
}
await this.#settle();
return viewOperation("resizeWidth", { columns }, { geometryChanged: true, mutatesViewport: true });
}
async #resizeHeight(): Promise<AppliedOperation> {
const rows = this.#pickDifferent(this.#scenario.heightChoices, this.#term.rows);
this.#term.resize(this.#term.columns, rows);
if (!this.#traits.strictNativeScrollback && !this.#traits.foregroundStreaming) {
this.#tui.requestRender(true);
}
await this.#settle();
return viewOperation("resizeHeight", { rows }, { geometryChanged: true, mutatesViewport: true });
}
// SIGWINCH racing a streamed token: the model grows and the terminal
// resizes inside the same frame budget. The TUI's own resize handler
// schedules the (non-forced) render; the embedder does not force — this is
// the default production path for "user resizes the window while the
// assistant is streaming".
async #resizeWithAppend(): Promise<AppliedOperation> {
const appended = this.#model.appendSmall();
const rows = this.#pickDifferent(this.#scenario.heightChoices, this.#term.rows);
const columns = this.#streams.geometry.chance(0.5)
? this.#pickDifferent(this.#scenario.widthChoices, this.#term.columns)
: this.#term.columns;
this.#term.resize(columns, rows);
await this.#settle();
return contentOperation("resizeWithAppend", { appended, columns, rows }, false, {
geometryChanged: true,
mutatesViewport: true,
});
}
async #forceRender(): Promise<AppliedOperation> {
this.#tui.requestRender(true);
await this.#settle();
return this.#forceOperation("forceRender", {});
}
async #forceRenderAllowUnknown(): Promise<AppliedOperation> {
this.#tui.requestRender(true);
await this.#settle();
return this.#forceOperation("forceRenderAllowUnknown", {});
}
async #forceRenderClearScrollback(): Promise<AppliedOperation> {
this.#term.scrollLines(LARGE_SCROLL);
this.#tui.requestRender(true, { clearScrollback: true });
await this.#settle();
return { ...this.#forceOperation("forceRenderClearScrollback", { clearScrollback: true }), checkpoint: true };
}
async #forceRenderAfterEmptyOverflow(): Promise<AppliedOperation> {
const detachedChildren: number[] = [];
for (const child of this.#children) {
if (!child.active) continue;
child.active = false;
detachedChildren.push(child.id);
this.#tui.removeChild(child.component);
}
const empty = this.#model.clear();
this.#tui.requestRender(true, { clearScrollback: true });
await this.#settle();
// The clear's own replay frame can itself overflow the viewport (status
// header + residual rows), so the transient bound must cover everything
// this op writes: the cleared frame plus the fresh overflow appends.
const clearedFrameLength = this.#expectedFrame().frame.length;
const overflowCount = this.#term.rows + this.#streams.geometry.int(1, 4);
const overflow = this.#model.appendCount(overflowCount, "overflow");
this.#tui.requestRender(true);
await this.#settle();
return {
...this.#forceOperation("forceRenderAfterEmptyOverflow", { detachedChildren, empty, overflow }),
mutatesContent: true,
// In multiplexers everything written during this op scrolls into pane
// history on top of whatever was already there.
transientFrameGrowth: clearedFrameLength + overflowCount,
};
}
#forceOperation(kind: OperationKind, detail: JsonObject): AppliedOperation {
return forceRenderOperation(kind, detail, {
mutatesViewport: kind === "forceRenderClearScrollback" || kind === "forceRenderAfterEmptyOverflow",
});
}
async #toggleFocusInput(): Promise<AppliedOperation> {
let cursor: JsonObject | null = null;
if (this.#component.focused) {
this.#tui.setFocus(null);
} else {
cursor = this.#streams.cursor.chance(0.25)
? this.#model.setCursorOffscreen(this.#term.rows, this.#term.columns)
: this.#model.setCursorVisible(this.#term.rows, this.#term.columns);
this.#tui.setFocus(this.#component);
}
this.#tui.requestRender();
await this.#settle();
return viewOperation("toggleFocusInput", { focused: this.#component.focused, cursor });
}
// Container.addChild appends and Container.render walks children in array
// order, so re-attaching a lower-id child after a higher-id one is already
// active would leave the TUI ordered [child1, child0] while #expectedFrame
// renders them in this.#children index order [child0, child1]. Rebuild the
// TUI child list from the canonical this.#children order so the model and the
// real frame always agree regardless of attach/detach sequencing.
#syncChildOrder(): void {
for (const child of this.#children) this.#tui.removeChild(child.component);
this.#tui.removeChild(this.#component);
this.#tui.addChild(this.#component);
for (const child of this.#children) {
if (child.active) this.#tui.addChild(child.component);
}
}
async #attachChild(): Promise<AppliedOperation> {
const child = this.#children.find(entry => !entry.active);
if (child === undefined) return this.#viewOperation("attachChild", { skipped: true });
child.active = true;
this.#syncChildOrder();
this.#renderContentFrame();
await this.#settle();
return contentOperation("attachChild", { id: child.id, lines: child.model.debugLines() }, false);
}
async #detachChild(): Promise<AppliedOperation> {
const active = this.#children.filter(entry => entry.active);
const child = active.length === 0 ? undefined : active[this.#streams.children.int(0, active.length - 1)];
if (child === undefined) return this.#viewOperation("detachChild", { skipped: true });
child.active = false;
this.#tui.removeChild(child.component);
this.#renderContentFrame();
await this.#settle();
return contentOperation("detachChild", { id: child.id }, false);
}
async #reorderChildren(): Promise<AppliedOperation> {
const active = this.#children.filter(entry => entry.active);
if (active.length < 2) return this.#viewOperation("reorderChildren", { skipped: true });
const first = this.#children.shift();
if (first !== undefined) this.#children.push(first);
this.#syncChildOrder();
this.#renderContentFrame();
await this.#settle();
return contentOperation(
"reorderChildren",
{ activeOrder: this.#children.filter(child => child.active).map(child => child.id) },
false,
);
}
async #mutateChild(): Promise<AppliedOperation> {
const active = this.#children.filter(entry => entry.active);
const child = active.length === 0 ? undefined : active[this.#streams.children.int(0, active.length - 1)];
if (child === undefined) return this.#viewOperation("mutateChild", { skipped: true });
const detail = this.#streams.children.chance(0.5)
? child.model.appendSmall()
: child.model.editVisibleLine(this.#term.rows);
this.#renderContentFrame();
await this.#settle();
return contentOperation("mutateChild", { id: child.id, detail }, false);
}
#viewOperation(kind: OperationKind, detail: JsonObject): AppliedOperation {
return viewOperation(kind, detail, {
mutatesViewport: kind === "scrollUp" || kind === "scrollPartial",
});
}
#pickDifferent(values: readonly number[], current: number): number {
const candidates = values.filter(value => value !== current);
return candidates.length === 0 ? current : this.#streams.geometry.pick(candidates);
}
async #checkpoint(index: number, kind: "periodicCheckpoint"): Promise<void> {
const before = this.#snapshot();
// Model a prompt submit: the editor keystroke pins the terminal to the
// bottom, then the app reconciles any deferred native-scrollback rewrite
// only if the renderer can prove the native host viewport is at the tail.
this.#term.scrollLines(LARGE_SCROLL);
let reconcilesNativeScrollback = false;
if (this.#traits.strictNativeScrollback || this.#traits.preservesPaneHistory) {
// Normal POSIX uses a /clear-style forced rebuild; tmux keeps its forced
// repaint (its pane history cannot be destructively reconciled).
this.#tui.requestRender(true, {
clearScrollback: this.#traits.strictNativeScrollback,
});
reconcilesNativeScrollback = this.#traits.strictNativeScrollback;
} else {
// Unknown-viewport / ED3-risk / Windows hosts: the deferred
// native-scrollback reconciliation no longer exists, so a prompt submit
// is a plain forced render that never destructively rewrites native
// scrollback.
this.#tui.requestRender(true);
}
await this.#settle();
const after = this.#snapshot();
this.#recordOperation(index, kind, { forcedCheckpoint: this.#traits.strictNativeScrollback }, before, after);
this.#assertOracles(
{
kind: "scrollToBottom",
detail: { periodic: true },
mutatesContent: false,
checksRowAccounting: false,
geometryChanged: false,
forcedRender: true,
mutatesViewport: true,
checkpoint: true,
reconcilesNativeScrollback,
},
before,
after,
index,
);
}
#recordOperation(
index: number,
kind: OperationLogKind,
detail: JsonObject,
before: Snapshot,
after: Snapshot,
): void {
this.#operationCoverage.set(kind, (this.#operationCoverage.get(kind) ?? 0) + 1);
this.#opLog.push({
index,
kind,
detail,
frameLengthBefore: before.frame.length,
frameLengthAfter: after.frame.length,
bufferLengthBefore: before.buffer.length,
bufferLengthAfter: after.buffer.length,
viewportYBefore: before.position.viewportY,
viewportYAfter: after.position.viewportY,
baseYBefore: before.position.baseY,
baseYAfter: after.position.baseY,
redrawsBefore: before.redraws,
redrawsAfter: after.redraws,
});
}
#assertOracles(op: AppliedOperation, before: Snapshot, after: Snapshot, index: number): void {
this.#assertSyncOutputDiscipline(op, before, after, index);
this.#assertTapeScrollParity(op, before, after, index);
this.#assertViewportFidelity(op, before, after, index);
this.#assertCleanBufferWhenAligned(op, before, after, index);
this.#assertNoFrameNeutralScrollbackGrowth(op, before, after, index);
this.#assertCursor(op, before, after, index);
this.#assertScrolledDeferral(op, before, after, index);
this.#assertMultiplexerPaneHistoryGrowth(op, before, after, index);
this.#assertHistoryPrefixStability(op, before, after, index);
this.#assertNativeScrollbackReplay(op, before, after, index);
this.#assertNoStaleOverlaySentinels(op, before, after, index);
this.#assertUniqueContentNoUnexpectedDuplicates(op, before, after, index);
this.#assertNoBackgroundBleed(op, before, after, index);
// Native scrollback must reconcile to an exact bottom-anchored copy of the
// transcript at checkpoints where the renderer actually performed a
// destructive/native-history rebuild. Unknown ConPTY host scrollback and
// ED3-risk terminals with no positive at-tail probe intentionally keep dirty
// history deferred; asserting a clean buffer there would contradict the
// anti-yank contract. tmux is excluded: its pane history is preserved, not
// rebuilt, so the buffer snapshot is the view, not history.
if (
op.checkpoint &&
!this.#traits.preservesPaneHistory &&
(this.#traits.strictNativeScrollback || op.reconcilesNativeScrollback === true)
) {
this.#assertCleanBuffer(op, before, after, index);
}
}
// The shadow tape and the physical buffer must scroll in lockstep: outside
// gesture replays (checkpoints, geometry) the only thing that ever pushes
// rows into native scrollback is a commit, and every commit appends to the
// tape in the same write. Any disagreement means the ledgers diverged —
// catch it at the op where it happens instead of N ops later when the
// content mismatch surfaces.
#assertTapeScrollParity(op: AppliedOperation, before: Snapshot, after: Snapshot, index: number): void {
if (!this.#traits.strictNativeScrollback) return;
if (op.checkpoint || op.geometryChanged) return;
if (this.#scrollbackCapReached(before) || this.#scrollbackCapReached(after)) return;
const physicalDelta = after.position.baseY - before.position.baseY;
const tapeDelta = after.shadowTapeLength - before.shadowTapeLength;
if (physicalDelta !== tapeDelta) {
this.#fail("tape/physical scroll parity", op, before, after, index, {
physicalDelta,
tapeDelta,
});
}
}
// Synchronized-output (DEC 2026) + autowrap (DECAWM) bracket discipline.
// Every paint write opens with PAINT_BEGIN (`\x1b[?2026h\x1b[?7l`) and closes
// with PAINT_END (`\x1b[?7h\x1b[?2026l`); the standalone cursor write brackets
// its move in `\x1b[?2026h…\x1b[?2026l`. The contract: across the entire byte
// stream the brackets must strictly alternate open/close (depth stays in
// {0,1}) and return to 0 at every op boundary. A renderer path that opens a
// sync block and returns before closing it freezes the terminal until the
// next keystroke — the "output froze until I pressed a key" bug class — and
// an unbalanced `\x1b[?7l` leaves autowrap off, producing staircase trails on
// the next non-TUI write. There is no terminal-side timeout for an unclosed
// 2026 block (Contour synchronized-output spec), so the renderer alone owns
// the invariant. Audits incrementally from #writeLogScanned to stay O(bytes).
#assertSyncOutputDiscipline(op: AppliedOperation, before: Snapshot, after: Snapshot, index: number): void {
for (; this.#writeLogScanned < this.#writeLog.length; this.#writeLogScanned++) {
this.#consumeAnsiChunk(this.#writeLog[this.#writeLogScanned]!, op, before, after, index);
}
if (this.#ansiCarry.length > 0) {
this.#fail("incomplete private CSI sequence at op boundary", op, before, after, index, {
carry: this.#ansiCarry,
});
}
// At an op boundary every paint/cursor write the op emitted has completed,
// so both brackets must be balanced. A nonzero depth means a paint path
// left the terminal inside a sync block or with autowrap disabled.
if (this.#syncDepth !== 0) {
this.#fail("synchronized-output left open at op boundary", op, before, after, index, {
syncDepth: this.#syncDepth,
});
}
if (this.#autowrapOffDepth !== 0) {
this.#fail("autowrap left disabled at op boundary", op, before, after, index, {
autowrapOffDepth: this.#autowrapOffDepth,
});
}
}
#consumeAnsiChunk(data: string, op: AppliedOperation, before: Snapshot, after: Snapshot, index: number): void {
const input = this.#ansiCarry + data;
this.#ansiCarry = "";
let cursor = 0;
while (cursor < input.length) {
const esc = input.indexOf("\x1b[?", cursor);
if (esc === -1) break;
const terminator = findPrivateCsiTerminator(input, esc + 3);
if (terminator === -1) {
this.#ansiCarry = input.slice(esc);
return;
}
const final = input[terminator] ?? "";
if (final === "h" || final === "l") {
const params = parseCsiParameters(input.slice(esc + 3, terminator));
this.#consumePrivateModeSequence(params, final, op, before, after, index);
}
cursor = terminator + 1;
}
const carryStart = trailingPrivateCsiPrefixStart(input);
if (carryStart >= 0) {
this.#ansiCarry = input.slice(carryStart);
}
}
#consumePrivateModeSequence(
params: readonly number[],
final: "h" | "l",
op: AppliedOperation,
before: Snapshot,
after: Snapshot,
index: number,
): void {
if (params.includes(2026)) {
if (this.#traits.syncOutputDisabled) {
this.#fail(
final === "h"
? "synchronized-output begin emitted while PI_NO_SYNC_OUTPUT is set"
: "synchronized-output end emitted while PI_NO_SYNC_OUTPUT is set",
op,
before,
after,
index,
{ sequence: final === "h" ? "BSU" : "ESU" },
);
}
this.#syncDepth += final === "h" ? 1 : -1;
if (this.#syncDepth > 1) {
this.#fail("nested synchronized-output begin (BSU within BSU)", op, before, after, index, {
syncDepth: this.#syncDepth,
});
}
if (this.#syncDepth < 0) {
this.#fail("synchronized-output end (ESU) without matching begin", op, before, after, index, {
syncDepth: this.#syncDepth,
});
}
}
if (params.includes(7)) {
this.#autowrapOffDepth += final === "l" ? 1 : -1;
if (this.#autowrapOffDepth < 0) {
this.#fail("autowrap enabled without matching disable", op, before, after, index, {
autowrapOffDepth: this.#autowrapOffDepth,
});
}
}
}
// SGR/BCE bleed: background attributes must appear only on viewport cells
// whose logical content carries background SGR. Stress content includes
// deliberately unreset background sequences (backgroundStyledText); the
// renderer's per-line terminators (#applyLineResets / LINE_TERMINATOR) must
// confine the color to its own text cells. A leak means BCE (back-color-erase,
// which xterm.js and most real terminals implement) paints \x1b[K / \x1b[2K
// erased cells with the stale background — the user-visible "random colored
// blank cells" bug class. Text-only oracles cannot see this; this oracle reads
// cell attributes.
#assertNoBackgroundBleed(op: AppliedOperation, before: Snapshot, after: Snapshot, index: number): void {
if (this.#hasVisibleOverlay()) return;
if (!after.atBottom) return;
const expectedView = expectedViewport(after.frame, after.height);
const viewportTop = Math.max(0, after.frame.length - after.height);
for (let row = 0; row < after.height; row++) {
const backgroundColumns = after.viewBackgroundColumns[row] ?? [];
if (backgroundColumns.length === 0) continue;
// Judge only rows whose text matches the frame row they map to: a
// deferred/stale row (text mismatch) has ambiguous provenance and is
// re-checked once a repaint re-aligns it. backgroundStyledText labels are
// never whitespace-only, so a stale background row cannot masquerade as a
// legitimately blank row.
if ((after.view[row] ?? "") !== (expectedView[row] ?? "")) continue;
const frameRow = viewportTop + row;
const expectedColumns = new Set(after.frameBackgroundColumns[frameRow] ?? []);
const unexpectedColumns = backgroundColumns.filter(column => !expectedColumns.has(column));
if (unexpectedColumns.length > 0) {
this.#fail("background SGR bleed", op, before, after, index, {
row,
frameRow,
backgroundColumns,
unexpectedColumns,
expectedColumns: [...expectedColumns],
rowText: after.view[row] ?? null,
expected: "background-colored cells only on columns whose content carries background SGR",
});
}
}
}
#assertViewportFidelity(op: AppliedOperation, before: Snapshot, after: Snapshot, index: number): void {
if (this.#hasVisibleOverlay()) return;
if (!after.atBottom) return;
// The grid must show the shadow window slice: the frame tail anchored at
// the ledger's window top (which floors at the committed boundary after
// a shrink, leaving blank rows below the content instead of re-showing
// committed rows). Multiplexer mode only checks geometry frames — tmux
// reflows the pane grid on resize and the renderer must repaint the
// whole visible window at the new geometry.
if (this.#traits.preservesPaneHistory && !op.geometryChanged) return;
const expected: string[] = [];
for (let r = 0; r < after.height; r++) {
expected.push(after.frame[this.#shadowWindowTop + r] ?? "");
}
if (!sameLinesAllowingMarkDrift(after.view, expected)) {
this.#fail(
this.#traits.foregroundStreaming ? "foreground-stream viewport fidelity" : "viewport fidelity",
op,
before,
after,
index,
{ expected, shadowWindowTop: this.#shadowWindowTop },
);
}
}
#assertCleanBufferWhenAligned(op: AppliedOperation, before: Snapshot, after: Snapshot, index: number): void {
if (!this.#traits.strictNativeScrollback || !after.atBottom || op.geometryChanged) return;
if (this.#hasVisibleOverlay()) return;
if (!this.#bufferReflectsFrame(before.buffer, before.frame, before.height)) return;
const expected = this.#expectedScrollbackBuffer(after);
if (after.buffer.length !== expected.length) return;
if (!sameLinesAllowingMarkDrift(after.buffer, expected)) {
const mismatch = firstMismatchIndex(after.buffer, expected);
this.#fail("aligned buffer fidelity", op, before, after, index, {
expectedLength: expected.length,
actualLength: after.buffer.length,
firstMismatch: mismatch,
expectedWindow: windowAround(expected, mismatch),
actualWindow: windowAround(after.buffer, mismatch),
});
}
}
#assertNoFrameNeutralScrollbackGrowth(op: AppliedOperation, before: Snapshot, after: Snapshot, index: number): void {
if (this.#hasVisibleOverlay()) return;
if (!this.#traits.strictNativeScrollback || op.checkpoint || op.geometryChanged) return;
if (!before.atBottom || !after.atBottom) return;
if (!sameLines(before.frame, after.frame)) return;
if (after.buffer.length > before.buffer.length) {
if (this.#isCleanBuffer(after.buffer, after.frame, after.height)) return;
this.#fail("frame-neutral scrollback growth", op, before, after, index, {
beforeLength: before.buffer.length,
afterLength: after.buffer.length,
});
}
}
#assertCursor(op: AppliedOperation, before: Snapshot, after: Snapshot, index: number): void {
if (this.#hasVisibleOverlay()) return;
if (after.cursor.row < 0 || after.cursor.row >= after.height || after.cursor.col < 0) {
this.#fail("cursor bounds", op, before, after, index, { cursor: cursorObject(after) });
}
const expectedCursor = after.expectedCursor;
if (expectedCursor === null || !after.atBottom) return;
// Exact cursor parking is only predictable when the buffer is bottom-anchored
// (no ghost/stale rows). After a trailing shrink the cursor sits on the
// de-anchored last content row, which is checked once a repaint re-anchors.
if (!this.#isCleanBuffer(after.buffer, after.frame, after.height)) return;
if (after.cursor.row !== expectedCursor.row) {
this.#fail("focused cursor row", op, before, after, index, {
expectedRow: expectedCursor.row,
actualRow: after.cursor.row,
actualCol: after.cursor.col,
});
}
// Cursor column is a terminal cell offset, not a UTF-16 length. When the
// marker is at or beyond the right margin, CHA clamping/pending-wrap details
// are terminal-dependent, so only assert exact columns that fit in-view.
if (expectedCursor.col < after.width && after.cursor.col !== expectedCursor.col) {
this.#fail("focused cursor column", op, before, after, index, {
expectedCol: expectedCursor.col,
actualCol: after.cursor.col,
actualRow: after.cursor.row,
});
}
}
#assertScrolledDeferral(op: AppliedOperation, before: Snapshot, after: Snapshot, index: number): void {
if (!op.mutatesContent || before.atBottom) return;
if (op.mutatesViewport || op.geometryChanged || op.checkpoint) return;
if (
this.#traits.viewportProbe !== "known" &&
!this.#traits.conptyHostScrollbackUnobservable &&
!this.#traits.ed3ScrollbackEraseRisk
)
return;
if (after.position.viewportY !== before.position.viewportY) {
this.#fail("scrolled viewport moved during content mutation", op, before, after, index, {
expectedViewportY: before.position.viewportY,
actualViewportY: after.position.viewportY,
});
}
// The anti-yank contract while scrolled into history: the viewport must not
// move (asserted above) and the visible rows that come from committed
// scrollback (history) must not be rewritten by a deferred content mutation.
// Rows below the history boundary belong to the live region and may legitimately
// repaint — e.g. a deferred shrink pads and repaints the live viewport, and a
// partial scroll (by < height) keeps the top live row on screen.
const historyVisible = Math.max(0, Math.min(before.position.baseY - before.position.viewportY, before.height));
for (let i = 0; i < historyVisible; i++) {
if (after.view[i] !== before.view[i]) {
this.#fail("scrolled history row rewritten during deferred content mutation", op, before, after, index, {
row: i,
historyVisible,
beforeRow: before.view[i] ?? null,
afterRow: after.view[i] ?? null,
});
}
}
}
// Multiplexer panes never receive a destructive scrollback clear (the
// renderer forces clearScrollback off inside tmux/screen/zellij because pane
// history is intentionally preserved), so any full-frame replay during live
// rendering appends a complete duplicate copy of the transcript to pane
// history. Users see every transcript row twice (or more) when scrolling
// back, and the per-frame write cost becomes O(frame). Pane history may grow
// exactly by the rows the shadow ledger committed during the op (appends,
// plus backfill of a chunk frozen during an overlay or geometry frame);
// anything beyond that is a replay leaking into preserved history. Geometry
// frames are exempt except pure height resizes, where xterm/tmux reflow is
// bounded: a height shrink moves at most (oldHeight - newHeight) rows into
// pane history — width changes rewrap pane history with unbounded row
// deltas and cannot be bounded from here.
#assertMultiplexerPaneHistoryGrowth(op: AppliedOperation, before: Snapshot, after: Snapshot, index: number): void {
if (!this.#traits.preservesPaneHistory) return;
if (op.checkpoint) return;
const heightOnlyResize = op.kind === "resizeHeight";
if (op.geometryChanged && !heightOnlyResize) return;
const reflowAllowance = heightOnlyResize ? Math.max(0, before.height - after.height) : 0;
const deltaBaseY = after.position.baseY - before.position.baseY;
if (deltaBaseY <= 0) return;
const committedDelta = Math.max(0, after.shadowTapeLength - before.shadowTapeLength);
const allowedGrowth = committedDelta + reflowAllowance;
if (deltaBaseY > allowedGrowth) {
this.#fail("multiplexer pane history grew faster than committed rows", op, before, after, index, {
deltaBaseY,
allowedGrowth,
committedDelta,
expected: "live frames must not replay the transcript into preserved pane history",
});
}
}
#assertHistoryPrefixStability(op: AppliedOperation, before: Snapshot, after: Snapshot, index: number): void {
if (!this.#traits.strictNativeScrollback) return;
if (this.#scrollbackCapReached(before) || this.#scrollbackCapReached(after)) return;
if (!op.mutatesContent || before.redraws !== after.redraws) return;
const prefixLength = Math.max(0, Math.min(before.position.viewportY, before.buffer.length));
const beforePrefix = before.buffer.slice(0, prefixLength);
const afterPrefix = after.buffer.slice(0, prefixLength);
if (!sameLines(beforePrefix, afterPrefix)) {
this.#fail("scrollback prefix changed without redraw", op, before, after, index, {
prefixLength,
beforePrefix,
afterPrefix,
});
}
}
#assertNativeScrollbackReplay(op: AppliedOperation, before: Snapshot, after: Snapshot, index: number): void {
if (!this.#traits.strictNativeScrollback) return;
if (this.#hasVisibleOverlay()) return;
if (!after.atBottom) return;
if (!op.mutatesContent && !op.forcedRender && !op.checkpoint && !op.geometryChanged) return;
const expected = this.#expectedScrollbackBuffer(after);
if (!sameLinesAllowingMarkDrift(after.buffer, expected)) {
const mismatch = firstMismatchIndex(after.buffer, expected);
this.#fail("native scrollback buffer fidelity", op, before, after, index, {
expectedLength: expected.length,
actualLength: after.buffer.length,
firstMismatch: mismatch,
expectedWindow: windowAround(expected, mismatch),
actualWindow: windowAround(after.buffer, mismatch),
});
}
const probes = scrollbackProbePositions(after.position.baseY, expected.length, after.height);
try {
for (const viewportY of probes) {
const current = this.#term.getBufferPosition().viewportY;
this.#term.scrollLines(viewportY - current);
const actual = normalizeLines(this.#term.getViewport());
const expectedView = fixedViewportSlice(expected, viewportY, after.height);
if (!sameLinesAllowingMarkDrift(actual, expectedView)) {
this.#fail("native scrollback viewport fidelity", op, before, after, index, {
viewportY,
expected: expectedView,
actual,
});
}
}
} finally {
this.#term.scrollLines(LARGE_SCROLL);
}
}
#assertCleanBuffer(op: AppliedOperation, before: Snapshot, after: Snapshot, index: number): void {
if (this.#hasVisibleOverlay()) return;
const expected = this.#expectedScrollbackBuffer(after);
if (!sameLinesAllowingMarkDrift(after.buffer, expected)) {
this.#fail("clean checkpoint reconstruction", op, before, after, index, {
expectedLength: expected.length,
actualLength: after.buffer.length,
});
}
}
#expectedScrollbackBuffer(snapshot: Snapshot): string[] {
const height = snapshot.height;
const expected = [...this.#shadowTape];
for (let r = 0; r < height; r++) {
expected.push(this.#shadowFrame[this.#shadowWindowTop + r] ?? "");
}
const cap = height + this.#scenario.scrollback;
return expected.length > cap ? expected.slice(expected.length - cap) : expected;
}
/**
* Advance the shadow commit ledger for one observed write. Classification
* is byte-driven: ED3 = destructive replay, ED2-without-ED3 =
* non-destructive replay (initial paint / multiplexer replace), anything
* else = ordinary update following the engine's append-only law.
*/
#applyShadowWrite(data: string): void {
data = this.#normalScreenShadowWrite(data);
if (data.length === 0) return;
const frame = this.#shadowFrame;
const raw = this.#shadowRawFrame;
const height = Math.max(1, this.#shadowFrameHeight);
const length = frame.length;
if (data.includes("\x1b[3J")) {
this.#shadowCommitted = Math.max(0, length - height);
this.#shadowWindowTop = this.#shadowCommitted;
this.#shadowTape = frame.slice(0, this.#shadowCommitted);
this.#shadowRawPrefix = raw.slice(0, this.#shadowCommitted);
return;
}
if (data.includes("\x1b[2J")) {
// Grid cleared in place, committed prefix scrolls above it; prior
// history rows stay (and are erased only by the ED3 branch above).
const chunkTo = Math.max(0, length - height);
for (let i = 0; i < chunkTo; i++) this.#shadowTape.push(frame[i] ?? "");
this.#shadowCommitted = chunkTo;
this.#shadowWindowTop = chunkTo;
this.#shadowRawPrefix = raw.slice(0, chunkTo);
return;
}
// Audit and shrink re-anchoring are mirrored at render time (they can
// fire on zero-byte frames); the write hook only applies commits.
const tail = Math.max(0, length - height);
// Overlays and multiplexer geometry frames freeze commits; a geometry
// frame also re-bases the raw prefix at the new width (accepted wrap
// drift, mirrored from the engine).
if (this.#shadowFrameGeometryChanged) {
if (tail < this.#shadowCommitted) {
// Pane growth pulls committed rows back out of multiplexer
// scrollback into the grid (tmux screen_resize_y takes lines
// from pane history when the screen grows; the emulated
// terminal does the same). The engine mirrors that by rebasing
// its commit seam to the exposed frame tail and force-rewriting
// the whole window (tui.ts geometry-growth branch) — un-commit
// the resurrected rows here so the ledger agrees.
const pulled = this.#shadowCommitted - tail;
this.#shadowTape.length = Math.max(0, this.#shadowTape.length - pulled);
this.#shadowCommitted = tail;
}
this.#shadowWindowTop = Math.max(this.#shadowCommitted, tail);
this.#shadowRawPrefix = raw.slice(0, this.#shadowCommitted);
return;
}
const windowTop = Math.max(this.#shadowCommitted, tail);
this.#shadowWindowTop = windowTop;
if (this.#shadowFrameOverlay) return;
const chunkTo = Math.max(this.#shadowCommitted, Math.min(length, windowTop));
for (let i = this.#shadowCommitted; i < chunkTo; i++) {
this.#shadowTape.push(frame[i] ?? "");
this.#shadowRawPrefix.push(raw[i] ?? "");
}
this.#shadowCommitted = chunkTo;
}
// Resize fast-path frames write to the alternate screen, then the settled
// replay often leaves alt and emits ED3 in the same write. Ignore bytes while
// alt is active, but keep the normal-screen suffix after `?1049l` so the
// shadow ledger observes the authoritative replay.
#normalScreenShadowWrite(data: string): string {
if (this.#shadowAltActive) {
const exitIndex = data.lastIndexOf(ALT_SCREEN_EXIT);
if (exitIndex === -1) return "";
this.#shadowAltActive = false;
return data.slice(exitIndex + ALT_SCREEN_EXIT.length);
}
const enterIndex = data.indexOf(ALT_SCREEN_ENTER);
if (enterIndex === -1) return data;
const exitIndex = data.indexOf(ALT_SCREEN_EXIT, enterIndex + ALT_SCREEN_ENTER.length);
if (exitIndex === -1) {
this.#shadowAltActive = true;
return data.slice(0, enterIndex);
}
return data.slice(0, enterIndex) + data.slice(exitIndex + ALT_SCREEN_EXIT.length);
}
#scrollbackCapReached(snapshot: Snapshot): boolean {
return Math.max(snapshot.height, snapshot.frame.length) > snapshot.height + this.#scenario.scrollback;
}
#bufferReflectsFrame(buffer: readonly string[], frame: readonly string[], height: number): boolean {
return sameLines(buffer, expectedScrollbackBuffer(frame, height, this.#scenario.scrollback));
}
#isCleanBuffer(buffer: readonly string[], frame: readonly string[], height: number): boolean {
return this.#bufferReflectsFrame(buffer, frame, height);
}
#assertNoStaleOverlaySentinels(op: AppliedOperation, before: Snapshot, after: Snapshot, index: number): void {
if (this.#hiddenOverlaySentinels.size === 0) return;
const visibleSentinels = new Set(
this.#overlays
.filter(entry => isExpectedOverlayVisible(entry, this.#term.columns, this.#term.rows))
.map(entry => entry.sentinel),
);
// Multiplexers preserve pane history and do not allow the renderer to scrub
// scrollback safely. A hidden overlay must disappear from the live viewport,
// but the viewport can itself be parked in pane history while scrolled.
if (this.#traits.preservesPaneHistory && !after.atBottom) return;
const nativeText = this.#traits.preservesPaneHistory
? after.view.join("\n")
: `${after.buffer.join("\n")}\n${after.view.join("\n")}`;
for (const sentinel of this.#hiddenOverlaySentinels) {
if (visibleSentinels.has(sentinel)) continue;
if (nativeText.includes(sentinel)) {
this.#fail("stale overlay sentinel", op, before, after, index, { sentinel });
}
}
}
#assertUniqueContentNoUnexpectedDuplicates(
op: AppliedOperation,
before: Snapshot,
after: Snapshot,
index: number,
): void {
if (!this.#scenario.uniqueContent) return;
// All comparisons run with non-spacing marks stripped: the virtual
// terminal drops them on input (ghostty-web 0.4 margin-cluster crash
// workaround), so buffer readback and frame/tape rows would otherwise
// never collide on marked rows.
const strip = (line: string): string => line.replace(NONSPACING_MARKS, "");
// Accumulate even when the check below is skipped (scrolled/overlay): the
// frame's legitimate duplicates commit to scrollback regardless of where
// the viewport is parked. The shadow tape contributes too: a no-seam
// offscreen insert re-indexes committed content, so the shifted rows
// legitimately commit a second time (the exact tape-equality oracle has
// already proven the buffer matches the ledger row for row).
for (const line of duplicateNonblankLines(after.frame)) {
this.#everDuplicatedFrameLines.add(strip(line));
}
const tapeSeen = new Set<string>();
for (const raw of this.#shadowTape) {
if (raw.length === 0) continue;
const line = strip(raw);
if (tapeSeen.has(line)) this.#everDuplicatedFrameLines.add(line);
tapeSeen.add(line);
}
// A committed row that still sits in the visible window (window floored
// at the commit boundary) legitimately appears in both regions of the
// whole-tape buffer snapshot.
for (let r = 0; r < after.height; r++) {
const raw = this.#shadowFrame[this.#shadowWindowTop + r] ?? "";
if (raw.length === 0) continue;
const line = strip(raw);
if (tapeSeen.has(line)) this.#everDuplicatedFrameLines.add(line);
}
if (this.#hasVisibleOverlay() || !after.atBottom) return;
const allowed = this.#everDuplicatedFrameLines;
const seen = new Set<string>();
for (const raw of after.buffer) {
if (raw.length === 0) continue;
const line = strip(raw);
if (seen.has(line) && !allowed.has(line)) {
this.#fail("unexpected duplicate native scrollback line", op, before, after, index, { line });
}
seen.add(line);
}
}
#fail(
message: string,
op: AppliedOperation,
before: Snapshot,
after: Snapshot,
index: number,
extra: JsonObject,
): never {
const replayLogPath = writeReplayLog(this.#scenario, this.#opLog);
const replay = `TUI_STRESS_REPLAY=${JSON.stringify({
scenario: this.#scenario.name,
seed: formatSeed(this.#scenario.seed),
iterations: index + 1,
})}`;
const replayLog = `TUI_STRESS_REPLAY_LOG=${replayLogPath}`;
const fullDump = Bun.env.TUI_STRESS_FULL_DUMP === "1";
const dump = {
message,
scenario: this.#scenario.name,
seed: formatSeed(this.#scenario.seed),
opIndex: index,
replay,
replayLog,
replayLogPath,
op: { kind: op.kind, detail: op.detail },
extra,
traits: this.#traits,
tags: this.#scenario.tags,
operationCoverage: Object.fromEntries(this.#operationCoverage.entries()),
lastOperations: this.#opLog.slice(-50),
shadow: {
committed: this.#shadowCommitted,
windowTop: this.#shadowWindowTop,
tapeLength: this.#shadowTape.length,
frameLength: this.#shadowFrame.length,
geometryChanged: this.#shadowFrameGeometryChanged,
overlayVisible: this.#shadowFrameOverlay,
},
lastWrites: this.#writeLog.slice(-4).map(write => JSON.stringify(write.slice(-400))),
children: this.#children.map(child => ({
id: child.id,
active: child.active,
focused: child.component.focused,
lines: child.model.debugLines(),
})),
overlays: this.#overlays.map(overlay => ({
id: overlay.id,
hidden: overlay.hidden,
focused: overlay.component.focused,
sentinel: overlay.sentinel,
options: overlay.detail,
lines: overlay.model.debugLines(),
})),
before: fullDump ? snapshotDump(before) : snapshotSummary(before),
after: fullDump ? snapshotDump(after) : snapshotSummary(after),
model: fullDump ? this.#model.debugLines() : undefined,
opLog: fullDump ? this.#opLog : undefined,
fullDump: fullDump ? true : "set TUI_STRESS_FULL_DUMP=1 for complete buffers and op log",
};
throw new Error(`TUI render stress invariant failed: ${message}\n${JSON.stringify(dump, null, 2)}`);
}
}
function createTerminal(scenario: Scenario): VirtualTerminal {
switch (scenario.terminalMode) {
case "unknown":
return new UnknownViewportTerminal(scenario.columns, scenario.rows, scenario.scrollback);
case "intermittentUnknown":
return new IntermittentUnknownViewportTerminal(scenario.columns, scenario.rows, scenario.scrollback);
case "staleBottom":
return new StaleBottomTerminal(scenario.columns, scenario.rows, scenario.scrollback);
case "normal":
return new VirtualTerminal(scenario.columns, scenario.rows, scenario.scrollback);
default:
return assertNever(scenario.terminalMode);
}
}
function normalizeLines(lines: readonly string[]): string[] {
return lines.map(line => line.trimEnd());
}
function expectedViewport(frame: readonly string[], height: number): string[] {
return fixedViewportSlice(frame, Math.max(0, frame.length - height), height);
}
function fixedViewportSlice(frame: readonly string[], start: number, height: number): string[] {
const view: string[] = [];
for (let i = 0; i < height; i++) {
view.push(frame[start + i] ?? "");
}
return view;
}
function sameLines(left: readonly string[], right: readonly string[]): boolean {
if (left.length !== right.length) return false;
for (let i = 0; i < left.length; i++) {
if (left[i] !== right[i]) return false;
}
return true;
}
// ghostty-web's cell-grid text extraction can migrate or merge Unicode
// non-spacing marks across neighboring cells for combining-heavy scripts
// (Arabic harakat), so a byte-exact round trip through the virtual terminal is
// not achievable for those rows (the engine paints them verbatim; see the
// WIDTH notes in docs/tui-core-renderer.md). Fall back to comparing with
// non-spacing marks stripped — row count, order, and all spacing content stay
// exact.
const NONSPACING_MARKS = /\p{Mn}/gu;
function sameLinesAllowingMarkDrift(left: readonly string[], right: readonly string[]): boolean {
if (sameLines(left, right)) return true;
if (left.length !== right.length) return false;
for (let i = 0; i < left.length; i++) {
if (left[i] === right[i]) continue;
if (left[i]!.replace(NONSPACING_MARKS, "") !== right[i]!.replace(NONSPACING_MARKS, "")) return false;
}
return true;
}
function firstMismatchIndex(left: readonly string[], right: readonly string[]): number {
const maxLength = Math.max(left.length, right.length);
for (let i = 0; i < maxLength; i++) {
if (left[i] !== right[i]) return i;
}
return -1;
}
function windowAround(lines: readonly string[], center: number): string[] {
const safeCenter = center < 0 ? 0 : center;
const start = Math.max(0, safeCenter - 3);
const end = Math.min(lines.length, safeCenter + 4);
return lines.slice(start, end);
}
export function expectedScrollbackBuffer(frame: readonly string[], height: number, scrollback: number): string[] {
const expected = [...frame];
while (expected.length < height) {
expected.push("");
}
const cap = height + scrollback;
return expected.length > cap ? expected.slice(expected.length - cap) : expected;
}
export function scrollbackProbePositions(maxViewportY: number, frameLength: number, height: number): number[] {
const maxY = Math.max(0, maxViewportY);
const positions = new Set<number>();
const add = (value: number): void => {
positions.add(Math.max(0, Math.min(maxY, value)));
};
add(0);
add(maxY);
add(Math.floor(maxY / 2));
add(Math.max(0, frameLength - height));
add(frameLength - 1);
add(frameLength);
if (EXHAUSTIVE_SCROLLBACK || maxY <= 32) {
for (let y = 0; y <= maxY; y++) add(y);
}
return [...positions].sort((left, right) => left - right);
}
export function duplicateNonblankLines(lines: readonly string[]): Set<string> {
const seen = new Set<string>();
const duplicates = new Set<string>();
for (const line of lines) {
if (line.length === 0) continue;
if (seen.has(line)) duplicates.add(line);
seen.add(line);
}
return duplicates;
}
function expectedTerminalLine(line: string, width: number): string {
const safeWidth = Math.max(1, width);
const fitted = visibleWidth(line) > safeWidth ? truncateToWidth(line, safeWidth, Ellipsis.Omit) : line;
return stripPlainTerminalText(fitted).trimEnd();
}
export function stripPlainTerminalText(text: string): string {
return stripVTControlCharacters(text)
.replace(/\]8;;[^\x07]*(?:\x07)?/g, "")
.replaceAll(BEL, "");
}
function findPrivateCsiTerminator(input: string, start: number): number {
return findCsiTerminator(input, start);
}
function findCsiTerminator(input: string, start: number): number {
for (let index = start; index < input.length; index++) {
const code = input.charCodeAt(index);
if (code >= 0x40 && code <= 0x7e) return index;
}
return -1;
}
function findOscTerminator(input: string, start: number): number {
for (let index = start; index < input.length; index++) {
const code = input.charCodeAt(index);
if (code === 0x07) return index + 1;
if (code === 0x1b && input[index + 1] === "\\") return index + 2;
}
return -1;
}
function parseCsiParameters(paramsText: string): number[] {
if (paramsText.length === 0) return [];
const params: number[] = [];
for (const part of paramsText.split(";")) {
if (part.length === 0) continue;
const parsed = Number.parseInt(part, 10);
if (Number.isFinite(parsed)) params.push(parsed);
}
return params;
}
function trailingPrivateCsiPrefixStart(input: string): number {
const esc = input.lastIndexOf(ESC);
if (esc === -1) return -1;
const tail = input.slice(esc);
return /^\x1b(?:\[?|\[\?[0-9;]*)$/.test(tail) ? esc : -1;
}
export function expectedFrameFromLines(lines: readonly string[], width: number, height: number): ExpectedFrame {
const stripped = [...lines];
const viewportTop = Math.max(0, stripped.length - height);
let cursor: ExpectedCursor | null = null;
const backgroundColumns: number[][] = Array.from({ length: stripped.length }, () => []);
for (let row = stripped.length - 1; row >= 0; row--) {
const line = stripped[row] ?? "";
const markerIndex = line.indexOf(CURSOR_MARKER);
const cleanLine = markerIndex === -1 ? line : removeCursorMarkers(line);
backgroundColumns[row] = expectedBackgroundColumns(cleanLine, width);
if (markerIndex !== -1 && cursor === null && row >= viewportTop) {
cursor = { row: row - viewportTop, col: visibleWidth(line.slice(0, markerIndex)) };
}
stripped[row] = cleanLine;
}
return { frame: stripped.map(line => expectedTerminalLine(line, width)), cursor, backgroundColumns };
}
function expectedBackgroundColumns(line: string, width: number): number[] {
const safeWidth = Math.max(1, width);
const fitted = visibleWidth(line) > safeWidth ? truncateToWidth(line, safeWidth, Ellipsis.Omit) : line;
const columns: number[] = [];
let backgroundActive = false;
let skipUntil = 0;
let col = 0;
for (const segment of SEGMENTER.segment(fitted)) {
if (segment.index < skipUntil) continue;
if (fitted.charCodeAt(segment.index) === 0x1b) {
const next = segment.index + 1;
if (fitted[next] === "[") {
const terminator = findCsiTerminator(fitted, next + 1);
if (terminator === -1) break;
if (fitted[terminator] === "m") {
backgroundActive = applySgrBackground(backgroundActive, fitted.slice(next + 1, terminator));
}
skipUntil = terminator + 1;
continue;
}
if (fitted[next] === "]") {
const terminator = findOscTerminator(fitted, next + 1);
if (terminator === -1) break;
skipUntil = terminator;
continue;
}
}
const segmentWidth = visibleWidth(segment.segment);
if (segmentWidth <= 0) continue;
if (backgroundActive) {
const end = Math.min(safeWidth, col + segmentWidth);
for (let column = col; column < end; column++) columns.push(column);
}
col += segmentWidth;
if (col >= safeWidth) break;
}
return columns;
}
function applySgrBackground(current: boolean, paramsText: string): boolean {
const params = parseCsiParameters(paramsText);
let active = current;
for (const param of params.length === 0 ? [0] : params) {
if (param === 0 || param === 49) {
active = false;
} else if ((param >= 40 && param <= 48) || (param >= 100 && param <= 107)) {
active = true;
}
}
return active;
}
function removeCursorMarkers(line: string): string {
return line.includes(CURSOR_MARKER) ? line.split(CURSOR_MARKER).join("") : line;
}
function compositeExpectedOverlays(
lines: readonly string[],
overlays: readonly StressOverlayEntry[],
termWidth: number,
termHeight: number,
): string[] {
if (overlays.length === 0) return [...lines];
const result = [...lines];
const rendered: { overlayLines: string[]; row: number; col: number; w: number }[] = [];
let minLinesNeeded = result.length;
for (const entry of overlays) {
if (!isExpectedOverlayVisible(entry, termWidth, termHeight)) continue;
const firstLayout = resolveExpectedOverlayLayout(entry.options, 0, termWidth, termHeight);
let overlayLines = entry.component.render(firstLayout.width);
if (overlayLines.length > firstLayout.maxHeight) {
overlayLines = overlayLines.slice(0, firstLayout.maxHeight);
}
const layout = resolveExpectedOverlayLayout(entry.options, overlayLines.length, termWidth, termHeight);
rendered.push({ overlayLines, row: layout.row, col: layout.col, w: layout.width });
minLinesNeeded = Math.max(minLinesNeeded, layout.row + overlayLines.length);
}
const workingHeight = Math.max(result.length, minLinesNeeded);
while (result.length < workingHeight) {
result.push("");
}
const viewportStart = Math.max(0, workingHeight - termHeight);
for (const { overlayLines, row, col, w } of rendered) {
for (let i = 0; i < overlayLines.length; i++) {
const index = viewportStart + row + i;
if (index < 0 || index >= result.length) continue;
const overlayLine = overlayLines[i] ?? "";
const truncatedOverlayLine =
visibleWidth(overlayLine) > w ? sliceByColumn(overlayLine, 0, w, true) : overlayLine;
result[index] = compositeExpectedLineAt(result[index] ?? "", truncatedOverlayLine, col, w, termWidth);
}
}
return result;
}
function isExpectedOverlayVisible(entry: StressOverlayEntry, termWidth: number, termHeight: number): boolean {
if (entry.hidden) return false;
return entry.options.visible?.(termWidth, termHeight) ?? true;
}
export function resolveExpectedOverlayLayout(
options: OverlayOptions | undefined,
overlayHeight: number,
termWidth: number,
termHeight: number,
): { width: number; row: number; col: number; maxHeight: number } {
const opt = options ?? {};
const margin =
typeof opt.margin === "number"
? { top: opt.margin, right: opt.margin, bottom: opt.margin, left: opt.margin }
: (opt.margin ?? {});
const marginTop = Math.max(0, margin.top ?? 0);
const marginRight = Math.max(0, margin.right ?? 0);
const marginBottom = Math.max(0, margin.bottom ?? 0);
const marginLeft = Math.max(0, margin.left ?? 0);
const availWidth = Math.max(1, termWidth - marginLeft - marginRight);
const availHeight = Math.max(1, termHeight - marginTop - marginBottom);
let width = parseOverlaySizeValue(opt.width, termWidth) ?? Math.min(80, availWidth);
if (opt.minWidth !== undefined) {
width = Math.max(width, opt.minWidth);
}
width = Math.max(1, Math.min(width, availWidth));
let maxHeight = parseOverlaySizeValue(opt.maxHeight, termHeight) ?? availHeight;
maxHeight = Math.max(1, Math.min(maxHeight, availHeight));
const effectiveHeight = Math.min(overlayHeight, maxHeight);
let row: number;
let col: number;
if (opt.row !== undefined) {
row =
typeof opt.row === "string"
? resolveOverlayPercentPosition(opt.row, Math.max(0, availHeight - effectiveHeight), marginTop)
: opt.row;
} else {
row = resolveExpectedAnchorRow(opt.anchor ?? "center", effectiveHeight, availHeight, marginTop);
}
if (opt.col !== undefined) {
col =
typeof opt.col === "string"
? resolveOverlayPercentPosition(opt.col, Math.max(0, availWidth - width), marginLeft)
: opt.col;
} else {
col = resolveExpectedAnchorCol(opt.anchor ?? "center", width, availWidth, marginLeft);
}
if (opt.offsetY !== undefined) row += opt.offsetY;
if (opt.offsetX !== undefined) col += opt.offsetX;
row = Math.max(marginTop, Math.min(row, termHeight - marginBottom - effectiveHeight));
col = Math.max(marginLeft, Math.min(col, termWidth - marginRight - width));
return { width, row, col, maxHeight };
}
function parseOverlaySizeValue(value: OverlayOptions["width"] | undefined, referenceSize: number): number | undefined {
if (value === undefined) return undefined;
if (typeof value === "number") return value;
const match = value.match(/^(\d+(?:\.\d+)?)%$/);
return match ? Math.floor((referenceSize * Number.parseFloat(match[1] ?? "0")) / 100) : undefined;
}
function resolveOverlayPercentPosition(value: string, maxPosition: number, margin: number): number {
const match = value.match(/^(\d+(?:\.\d+)?)%$/);
if (!match) return margin + Math.floor(maxPosition / 2);
return margin + Math.floor(maxPosition * (Number.parseFloat(match[1] ?? "0") / 100));
}
function resolveExpectedAnchorRow(
anchor: OverlayAnchor,
height: number,
availHeight: number,
marginTop: number,
): number {
switch (anchor) {
case "top-left":
case "top-center":
case "top-right":
return marginTop;
case "bottom-left":
case "bottom-center":
case "bottom-right":
return marginTop + availHeight - height;
case "left-center":
case "center":
case "right-center":
return marginTop + Math.floor((availHeight - height) / 2);
default:
return assertNever(anchor);
}
}
function resolveExpectedAnchorCol(
anchor: OverlayAnchor,
width: number,
availWidth: number,
marginLeft: number,
): number {
switch (anchor) {
case "top-left":
case "left-center":
case "bottom-left":
return marginLeft;
case "top-right":
case "right-center":
case "bottom-right":
return marginLeft + availWidth - width;
case "top-center":
case "center":
case "bottom-center":
return marginLeft + Math.floor((availWidth - width) / 2);
default:
return assertNever(anchor);
}
}
export function compositeExpectedLineAt(
baseLine: string,
overlayLine: string,
startCol: number,
overlayWidth: number,
totalWidth: number,
): string {
const afterStart = startCol + overlayWidth;
const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);
const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true);
const beforePad = Math.max(0, startCol - base.beforeWidth);
const overlayPad = Math.max(0, overlayWidth - overlay.width);
const actualBeforeWidth = Math.max(startCol, base.beforeWidth);
const actualOverlayWidth = Math.max(overlayWidth, overlay.width);
const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);
const afterPad = Math.max(0, afterTarget - base.afterWidth);
const result =
base.before +
" ".repeat(beforePad) +
SEGMENT_RESET +
overlay.text +
" ".repeat(overlayPad) +
SEGMENT_RESET +
base.after +
" ".repeat(afterPad);
return visibleWidth(result) <= totalWidth ? result : sliceByColumn(result, 0, totalWidth, true);
}
function wideText(label: string): string {
return `${label}界${SMILE}한`;
}
function arabicCombiningText(label: string): string {
// Arabic tashkeel are nonspacing marks stored in the base cell. Stress them
// alongside LTR labels because mis-measuring the marks used to overrun TUI
// rows and crash on width verification (issue #643).
return `${label}-بَسِمَ-قُرْآن`;
}
function emojiPresentationText(label: string): string {
// Text-default symbols promoted to emoji presentation by VS16 (U+FE0F) plus a
// keycap sequence. With the Ghostty-backed terminal, these are now cell-exact:
// Ghostty is the real modern terminal oracle, and both the renderer and the
// terminal measure each sequence here as 2 cells.
//
// Keep randomized stress to VS16/keycap emoji for this migration baseline.
// ZWJ and regional-indicator content should be enabled separately as renderer
// bug triage: Ghostty will expose real under-measure and overrun failures
// instead of hiding them behind a legacy model mismatch.
return `${label} \u26A0\uFE0F\u2139\uFE0F 1\uFE0F\u20E3`;
}
function styledText(label: string, color: number): string {
return `${ESC}[${color}m${label}${ESC}[0m`;
}
function backgroundStyledText(label: string, color: number): string {
// Background SGR with NO trailing reset. Real components do leak unreset SGR
// (markdown renderers, raw tool output), and BCE terminals (xterm.js included)
// fill cells erased by \x1b[K / \x1b[2K with the *current* background — so a
// leaked background paints whole phantom-colored rows. The renderer must
// contain the leak to this row via its per-line terminators; the
// no-background-bleed oracle asserts neighboring and blank rows never
// inherit the color.
return `${ESC}[${color}m${label}`;
}
function linkedText(label: string): string {
return `${ESC}]8;;https://example.test/${label}${BEL}${label}-link${ESC}]8;;${BEL}`;
}
function longText(label: string, repeats: number): string {
let text = `${label}-`;
for (let i = 0; i < repeats; i++) {
text += `${i}界`;
}
return `${text}-${label}`;
}
function randomDecoratedText(rng: Rng, label: string): string {
const roll = rng.next();
if (roll < 0.18) return wideText(label);
if (roll < 0.34) return styledText(`${label}界`, 31 + rng.int(0, 6));
if (roll < 0.5) return linkedText(label);
if (roll < 0.66) return longText(label, rng.int(2, 6));
if (roll < 0.76) return arabicCombiningText(label);
if (roll < 0.85) return emojiPresentationText(label);
if (roll < 0.93) return backgroundStyledText(label, 41 + rng.int(0, 6));
return label;
}
function pickCursorMode(rng: Rng, text: string, width: number): CursorMode {
if (text.includes("\x1b") || visibleWidth(text) === 0 || width <= 1) {
return rng.chance(0.5) ? "start" : "end";
}
return rng.pick(CURSOR_MODES);
}
function insertCursorMarker(text: string, mode: CursorMode, width: number): string {
const index = cursorInsertionIndex(text, mode, width);
return `${text.slice(0, index)}${CURSOR_MARKER}${text.slice(index)}`;
}
const SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
export function cursorInsertionIndex(text: string, mode: CursorMode, width: number): number {
if (mode === "start") return 0;
if (mode === "end" || text.includes("\x1b")) return text.length;
const textWidth = visibleWidth(text);
const target = mode === "wideBoundary" ? Math.max(0, Math.min(width - 1, textWidth)) : Math.floor(textWidth / 2);
let offset = 0;
let col = 0;
for (const segment of SEGMENTER.segment(text)) {
const nextCol = col + visibleWidth(segment.segment);
if (nextCol > target) break;
offset = segment.index + segment.segment.length;
col = nextCol;
if (col >= target) break;
}
return offset;
}
function snapshotDump(snapshot: Snapshot): JsonObject {
return {
buffer: snapshot.buffer,
view: snapshot.view,
viewBackgroundColumns: snapshot.viewBackgroundColumns,
frameBackgroundColumns: snapshot.frameBackgroundColumns,
position: { baseY: snapshot.position.baseY, viewportY: snapshot.position.viewportY },
cursor: cursorObject(snapshot),
expectedCursor:
snapshot.expectedCursor === null
? null
: { row: snapshot.expectedCursor.row, col: snapshot.expectedCursor.col },
redraws: snapshot.redraws,
width: snapshot.width,
height: snapshot.height,
frame: snapshot.frame,
atBottom: snapshot.atBottom,
};
}
function snapshotSummary(snapshot: Snapshot): JsonObject {
return {
bufferLength: snapshot.buffer.length,
view: snapshot.view,
viewBackgroundColumns: snapshot.viewBackgroundColumns,
position: { baseY: snapshot.position.baseY, viewportY: snapshot.position.viewportY },
cursor: cursorObject(snapshot),
expectedCursor:
snapshot.expectedCursor === null
? null
: { row: snapshot.expectedCursor.row, col: snapshot.expectedCursor.col },
redraws: snapshot.redraws,
width: snapshot.width,
height: snapshot.height,
frameLength: snapshot.frame.length,
frameTail: snapshot.frame.slice(-Math.min(snapshot.height + 3, snapshot.frame.length)),
atBottom: snapshot.atBottom,
};
}
function cursorObject(snapshot: Snapshot): JsonObject {
return { row: snapshot.cursor.row, col: snapshot.cursor.col };
}
function maxOf(values: readonly number[]): number {
let max = values[0] ?? 0;
for (const value of values) {
if (value > max) max = value;
}
return max;
}
function parsePositiveInt(name: string, fallback: number): number {
const raw = Bun.env[name];
if (raw === undefined || raw.length === 0) return fallback;
if (!/^[1-9]\d*$/.test(raw)) {
throw new Error(`${name} must be a positive integer; received ${JSON.stringify(raw)}`);
}
return Number.parseInt(raw, 10);
}
export function formatSeed(seed: number): string {
return `0x${(seed >>> 0).toString(16).padStart(8, "0")}`;
}
function scenarioEnv(envMode: EnvMode): Record<EnvKey, string | undefined> {
return {
TMUX: envMode === "tmux" ? "1" : undefined,
STY: undefined,
ZELLIJ: undefined,
TERMUX_VERSION: envMode === "termux" ? "0.118.0" : undefined,
WEZTERM_PANE: undefined,
KITTY_WINDOW_ID: undefined,
GHOSTTY_RESOURCES_DIR: envMode === "ghostty" ? "/Applications/Ghostty.app/Contents/Resources" : undefined,
ALACRITTY_WINDOW_ID: undefined,
VTE_VERSION: envMode === "vteNoSync" ? "6800" : undefined,
PI_NO_SYNC_OUTPUT: envMode === "vteNoSync" ? "1" : undefined,
TERM_PROGRAM: envMode === "appleTerminal" ? "Apple_Terminal" : envMode === "iterm2" ? "iTerm.app" : undefined,
ITERM_SESSION_ID: envMode === "iterm2" ? "w0t0p0" : undefined,
// WSL fronted by Windows Terminal: WT propagates WT_SESSION into the
// Linux environment, and WSL sets its own distro markers. See #1610.
WT_SESSION: envMode === "wsl" ? "5ca7376f-cd1b-4524-a45a-7e87b06b8f9e" : undefined,
WSL_DISTRO_NAME: envMode === "wsl" ? "Ubuntu" : undefined,
WSL_INTEROP: envMode === "wsl" ? "/run/WSL/8_interop" : undefined,
};
}
export function buildScenarios(): Scenario[] {
const soak = Bun.env.TUI_STRESS_SOAK === "1";
const templates = soak ? soakTemplates() : coreTemplates();
const replay = parseReplay(templates);
const replayOperations = parseReplayOperations();
if (replayOperations !== null && replay === null) {
throw new Error("TUI_STRESS_REPLAY_LOG requires TUI_STRESS_REPLAY to select the scenario and seed");
}
if (replay !== null) {
const maxHeight = maxOf(replay.template.heightChoices);
return [
materializeScenario(
replay.template,
replay.seed,
replayOperations?.length ?? replay.iterations,
SOAK_BULK_MAX,
SOAK_TIMEOUT_MS,
maxHeight,
replayOperations ?? undefined,
),
];
}
const defaultSeedCount = Math.max(BASE_SEEDS.length, templates.length);
const seedCount = parsePositiveInt("TUI_STRESS_SEEDS", defaultSeedCount);
const iterations = parsePositiveInt("TUI_STRESS_ITER", soak ? SOAK_ITERATIONS : CORE_ITERATIONS);
const bulkMax = soak ? SOAK_BULK_MAX : CORE_BULK_MAX;
const baseIterations = soak ? SOAK_ITERATIONS : CORE_ITERATIONS;
const baseTimeoutMs = soak ? SOAK_TIMEOUT_MS : CORE_TIMEOUT_MS;
// Higher-iteration hunts scale worse than linearly because exhaustive
// scrollback probes and resize/overlay rebuilds revisit larger buffers.
const timeoutMs = Math.max(baseTimeoutMs, Math.ceil((baseTimeoutMs * iterations * 3) / baseIterations));
const seeds = buildSeeds(seedCount);
const scenarios: Scenario[] = [];
for (let i = 0; i < seeds.length; i++) {
const template = templates[i % templates.length]!;
const maxHeight = maxOf(template.heightChoices);
scenarios.push(materializeScenario(template, seeds[i]!, iterations, bulkMax, timeoutMs, maxHeight));
}
return scenarios;
}
function materializeScenario(
template: ScenarioTemplate,
seed: number,
iterations: number,
bulkMax: number,
timeoutMs: number,
maxHeight: number,
replayOperations?: readonly OperationKind[],
): Scenario {
const strictScrollback =
template.envMode !== "tmux" && template.terminalMode === "normal" && template.platform !== "win32";
const foregroundStream = template.foregroundStream ?? false;
const reflow = template.reflow ?? false;
return {
...template,
seed,
iterations,
bulkMax,
scrollback: template.scrollbackRows ?? Math.max(10_000, maxHeight + 64 + iterations * (bulkMax + 8)),
strictScrollback,
timeoutMs,
uniqueContent: template.uniqueContent ?? false,
foregroundStream,
reflow,
tags: scenarioTags(template, strictScrollback, foregroundStream),
replayOperations,
};
}
function parseReplay(
templates: readonly ScenarioTemplate[],
): { template: ScenarioTemplate; seed: number; iterations: number } | null {
const raw = Bun.env.TUI_STRESS_REPLAY;
if (raw === undefined || raw.length === 0) return null;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error) {
throw new Error(`Invalid TUI_STRESS_REPLAY JSON: ${raw}`, { cause: error });
}
if (!isJsonRecord(parsed)) {
throw new Error("Invalid TUI_STRESS_REPLAY: expected an object with scenario, seed, and optional iterations");
}
const scenario = parsed.scenario;
if (typeof scenario !== "string" || scenario.length === 0) {
throw new Error("Invalid TUI_STRESS_REPLAY.scenario: expected a non-empty scenario name");
}
const template = templates.find(candidate => candidate.name === scenario);
if (template === undefined) throw new Error(`Unknown TUI_STRESS_REPLAY scenario: ${scenario}`);
const iterationsValue = parsed.iterations;
const iterations = iterationsValue === undefined ? CORE_ITERATIONS : parseReplayIterations(iterationsValue);
const seed = parseReplaySeed(parsed.seed);
return { template, seed, iterations };
}
function isJsonRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parseReplayIterations(value: unknown): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < 1) {
throw new Error("Invalid TUI_STRESS_REPLAY.iterations: expected a positive number");
}
return Math.floor(value);
}
function parseReplaySeed(seed: unknown): number {
if (typeof seed === "number" && Number.isFinite(seed)) return seed >>> 0;
if (typeof seed === "string") {
const radix = seed.startsWith("0x") || seed.startsWith("0X") ? 16 : 10;
const valid = radix === 16 ? /^0x[0-9a-f]+$/i.test(seed) : /^\d+$/.test(seed);
if (!valid) throw new Error(`Invalid TUI_STRESS_REPLAY.seed: ${JSON.stringify(seed)}`);
return Number.parseInt(seed, radix) >>> 0;
}
throw new Error("Invalid TUI_STRESS_REPLAY.seed: expected a number or integer string");
}
function parseReplayOperations(): readonly OperationKind[] | null {
const path = Bun.env.TUI_STRESS_REPLAY_LOG;
if (path === undefined || path.length === 0) return null;
let parsed: unknown;
try {
parsed = JSON.parse(fs.readFileSync(path, "utf8"));
} catch (error) {
throw new Error(`Invalid TUI_STRESS_REPLAY_LOG JSON: ${path}`, { cause: error });
}
const entries = Array.isArray(parsed)
? parsed
: isJsonRecord(parsed) && Array.isArray(parsed.operations)
? parsed.operations
: null;
if (entries === null) {
throw new Error("Invalid TUI_STRESS_REPLAY_LOG: expected an operation array or { operations } object");
}
const operations: OperationKind[] = [];
for (let index = 0; index < entries.length; index++) {
const entry = entries[index];
const kind = isJsonRecord(entry) ? entry.kind : entry;
if (kind === "periodicCheckpoint") continue;
if (!isOperationKind(kind)) {
throw new Error(`Invalid TUI_STRESS_REPLAY_LOG operation at index ${index}`);
}
operations.push(kind);
}
return operations;
}
function buildSeeds(count: number): number[] {
const seeds: number[] = [];
for (let i = 0; i < count; i++) {
const fixed = BASE_SEEDS[i];
seeds.push(fixed === undefined ? (0x9e3779b9 + Math.imul(i + 1, 0x85ebca6b)) >>> 0 : fixed);
}
return seeds;
}
type ScenarioTemplate = Omit<
Scenario,
| "seed"
| "iterations"
| "bulkMax"
| "scrollback"
| "strictScrollback"
| "timeoutMs"
| "uniqueContent"
| "foregroundStream"
| "reflow"
| "tags"
| "replayOperations"
> & {
scrollbackRows?: number;
uniqueContent?: boolean;
foregroundStream?: boolean;
reflow?: boolean;
};
function writeReplayLog(scenario: Scenario, operations: readonly OperationLogEntry[]): string {
const filePath = path.join(
os.tmpdir(),
`omp-tui-stress-${scenario.name}-${(scenario.seed >>> 0).toString(16)}-${Date.now().toString(36)}.json`,
);
fs.writeFileSync(filePath, JSON.stringify(operations, null, 2));
return filePath;
}
function coreTemplates(): ScenarioTemplate[] {
return [
{
name: "darwin-normal-small",
platform: "darwin",
terminalMode: "normal",
envMode: "plain",
geometryMode: "small",
columns: 32,
rows: 4,
widthChoices: [10, 16, 24, 32, 40],
heightChoices: [3, 4, 6],
scrollbackRows: 5,
},
{
name: "linux-normal-small",
platform: "linux",
terminalMode: "normal",
envMode: "plain",
geometryMode: "small",
columns: 40,
rows: 6,
widthChoices: [10, 18, 32, 40],
heightChoices: [3, 4, 6],
},
{
// VTE 0.68 reports DEC 2026 synchronized output as permanently reset
// and users can opt out when a terminal's implementation is buggy or
// visually worse. The renderer must remove only the 2026 wrapper; it
// still keeps autowrap disabled around paints to avoid pending-wrap
// staircase corruption.
name: "linux-normal-vteNoSync-small",
platform: "linux",
terminalMode: "normal",
envMode: "vteNoSync",
geometryMode: "small",
columns: 40,
rows: 6,
widthChoices: [10, 18, 32, 40],
heightChoices: [3, 4, 6],
},
{
name: "darwin-normal-large",
platform: "darwin",
terminalMode: "normal",
envMode: "plain",
geometryMode: "large",
columns: 80,
rows: 12,
widthChoices: [40, 80, 120],
heightChoices: [12, 24],
},
{
name: "win32-intermittentUnknown-small",
platform: "win32",
terminalMode: "intermittentUnknown",
envMode: "plain",
geometryMode: "small",
columns: 32,
rows: 4,
widthChoices: [10, 16, 32],
heightChoices: [3, 4, 6],
},
{
name: "darwin-normal-tmux-small",
platform: "darwin",
terminalMode: "normal",
envMode: "tmux",
geometryMode: "small",
columns: 32,
rows: 4,
widthChoices: [10, 16, 32],
heightChoices: [3, 4, 6],
},
{
name: "linux-staleBottom-large",
platform: "linux",
terminalMode: "staleBottom",
envMode: "plain",
geometryMode: "large",
columns: 120,
rows: 24,
widthChoices: [80, 120],
heightChoices: [12, 24],
},
{
name: "darwin-normal-tiny",
platform: "darwin",
terminalMode: "normal",
envMode: "plain",
geometryMode: "small",
columns: 6,
rows: 1,
widthChoices: [1, 2, 6, 12],
heightChoices: [1, 2, 3],
uniqueContent: true,
},
{
name: "linux-normal-termux-small",
platform: "linux",
terminalMode: "normal",
envMode: "termux",
geometryMode: "small",
columns: 32,
rows: 4,
widthChoices: [10, 16, 32],
heightChoices: [1, 2, 3, 4, 6],
},
{
name: "darwin-unknown-appleTerminal-small",
platform: "darwin",
terminalMode: "unknown",
envMode: "appleTerminal",
geometryMode: "small",
columns: 32,
rows: 4,
widthChoices: [10, 16, 32],
heightChoices: [3, 4, 6],
scrollbackRows: 10_000,
},
{
// WSL fronted by Windows Terminal (#1610): the viewport probe is
// permanently unobservable (kernel32 is unreachable from a Linux
// process) and the outer WT host erases scrollback on ED3, snapping a
// scrolled-up reader to the remaining buffer. The renderer must treat
// this environment as ED3-risk and defer eager live rebuilds.
name: "linux-unknown-wsl-small",
platform: "linux",
terminalMode: "unknown",
envMode: "wsl",
geometryMode: "small",
columns: 32,
rows: 4,
widthChoices: [10, 16, 32],
heightChoices: [3, 4, 6],
scrollbackRows: 10_000,
},
{
// Native-Windows ConPTY host (Windows Terminal, Tabby, Hyper, VS Code,
// conhost behind ConPTY — #1635/#1746). kernel32 cannot see the host
// UI's scrollback (the pseudo-console buffer is pinned to the visible
// grid), and no env var distinguishes the hosts (Tabby sets none), so
// the probe is permanently `undefined`. A reader scrolled in the host
// UI must not be yanked by streaming-time rebuilds; reconciliation
// waits for explicit checkpoints.
name: "win32-unknown-small",
platform: "win32",
terminalMode: "unknown",
envMode: "plain",
geometryMode: "small",
columns: 32,
rows: 4,
widthChoices: [10, 16, 32],
heightChoices: [3, 4, 6],
scrollbackRows: 10_000,
},
{
// Foreground tool actively streaming on an ED3-risk terminal whose
// viewport position is unobservable (ghostty/kitty/alacritty/VTE/iTerm2).
// Content frames flow through `viewportRepaint`/`diff` instead of a
// forced history rebuild. An offscreen-edit growth then repaints in
// place — advancing the rendered line count without committing the
// overflow to native history — and the next shrink must still
// re-anchor the bottom of the viewport from that lagging high-water mark.
// The default content-frame path forces a render and never reaches this
// state (a notification chip rendering over the active tool render: the
// original report).
name: "darwin-unknown-ghostty-stream-small",
platform: "darwin",
terminalMode: "unknown",
envMode: "ghostty",
geometryMode: "small",
columns: 32,
rows: 4,
widthChoices: [10, 16, 32],
heightChoices: [3, 4, 6],
scrollbackRows: 10_000,
foregroundStream: true,
},
{
name: "linux-unknown-ghostty-stream-large",
platform: "linux",
terminalMode: "unknown",
envMode: "ghostty",
geometryMode: "large",
columns: 80,
rows: 12,
widthChoices: [40, 80, 120],
heightChoices: [8, 12, 24],
scrollbackRows: 10_000,
foregroundStream: true,
},
{
// Width-reflowing content (wrapped/markdown-style) uses the same grapheme
// width semantics as the real Ghostty-backed terminal, so the wrap agrees
// with the terminal's cell widths. A width resize changes the physical
// line count, so the renderer must
// re-anchor the viewport and rebuild native history across a line-count
// change — not just retruncate rows. Combined with the full random op
// space (scroll, overlay, append, shrink) it covers reflow interactions
// the deterministic width tests exercise only in isolation.
name: "darwin-normal-reflow-small",
platform: "darwin",
terminalMode: "normal",
envMode: "plain",
geometryMode: "small",
columns: 32,
rows: 4,
widthChoices: [8, 12, 16, 24, 32, 40],
heightChoices: [3, 4, 6],
reflow: true,
},
{
name: "darwin-unknown-reflow-stream-large",
platform: "darwin",
terminalMode: "unknown",
envMode: "ghostty",
geometryMode: "large",
columns: 80,
rows: 12,
widthChoices: [24, 40, 80, 120],
heightChoices: [8, 12, 24],
scrollbackRows: 10_000,
reflow: true,
foregroundStream: true,
},
];
}
function soakTemplates(): ScenarioTemplate[] {
const templates: ScenarioTemplate[] = [];
const platformEnvModes: readonly { platform: TestPlatform; envModes: readonly EnvMode[] }[] = [
{ platform: "darwin", envModes: ["plain", "tmux"] },
{ platform: "linux", envModes: ["plain", "tmux", "termux", "vteNoSync"] },
{ platform: "win32", envModes: ["plain"] },
];
const terminalModes: readonly TerminalMode[] = ["normal", "unknown", "intermittentUnknown", "staleBottom"];
const geometries: readonly GeometryMode[] = ["small", "large"];
for (const { platform, envModes } of platformEnvModes) {
for (const terminalMode of terminalModes) {
for (const envMode of envModes) {
for (const geometryMode of geometries) {
const large = geometryMode === "large";
templates.push({
name: `${platform}-${terminalMode}-${envMode}-${geometryMode}`,
platform,
terminalMode,
envMode,
geometryMode,
columns: large ? 80 : 32,
rows: large ? 12 : 4,
widthChoices: large ? [80, 120] : [2, 10, 16, 24, 32, 40],
heightChoices: large ? [12, 24] : [3, 4, 6],
...(!large && terminalMode === "normal" && envMode === "plain"
? { scrollbackRows: 5, uniqueContent: true }
: {}),
});
}
}
}
}
// WSL fronted by Windows Terminal (#1610): only the unknown terminal mode is
// realistic — the kernel32 viewport probe never answers from a Linux process.
for (const geometryMode of geometries) {
const large = geometryMode === "large";
templates.push({
name: `linux-unknown-wsl-${geometryMode}`,
platform: "linux",
terminalMode: "unknown",
envMode: "wsl",
geometryMode,
columns: large ? 80 : 32,
rows: large ? 12 : 4,
widthChoices: large ? [80, 120] : [2, 10, 16, 24, 32, 40],
heightChoices: large ? [12, 24] : [3, 4, 6],
});
}
// Foreground tool streaming on an ED3-risk terminal with an unobservable
// viewport (ghostty/kitty/…): the eager native-scrollback rebuild opt-in is
// gated off, so content frames repaint in place and offscreen-edit growth
// lags the high-water mark — a later shrink must still re-anchor the viewport
// bottom rather than drifting rows up over one another.
for (const geometryMode of geometries) {
const large = geometryMode === "large";
templates.push({
name: `darwin-unknown-ghostty-stream-${geometryMode}`,
platform: "darwin",
terminalMode: "unknown",
envMode: "ghostty",
geometryMode,
columns: large ? 80 : 32,
rows: large ? 12 : 4,
widthChoices: large ? [80, 120] : [2, 10, 16, 24, 32, 40],
heightChoices: large ? [8, 12, 24] : [3, 4, 6],
foregroundStream: true,
});
}
return templates;
}
export interface StressEnvSnapshot {
bun: Record<EnvKey, string | undefined>;
process: Record<EnvKey, string | undefined>;
}
export function applyStressEnv(envMode: Scenario["envMode"]): StressEnvSnapshot {
const envPatch = scenarioEnv(envMode);
const snapshot: StressEnvSnapshot = {
bun: {
TMUX: undefined,
STY: undefined,
ZELLIJ: undefined,
TERMUX_VERSION: undefined,
WEZTERM_PANE: undefined,
KITTY_WINDOW_ID: undefined,
GHOSTTY_RESOURCES_DIR: undefined,
ALACRITTY_WINDOW_ID: undefined,
VTE_VERSION: undefined,
PI_NO_SYNC_OUTPUT: undefined,
TERM_PROGRAM: undefined,
ITERM_SESSION_ID: undefined,
WT_SESSION: undefined,
WSL_DISTRO_NAME: undefined,
WSL_INTEROP: undefined,
},
process: {
TMUX: undefined,
STY: undefined,
ZELLIJ: undefined,
TERMUX_VERSION: undefined,
WEZTERM_PANE: undefined,
KITTY_WINDOW_ID: undefined,
GHOSTTY_RESOURCES_DIR: undefined,
ALACRITTY_WINDOW_ID: undefined,
VTE_VERSION: undefined,
PI_NO_SYNC_OUTPUT: undefined,
TERM_PROGRAM: undefined,
ITERM_SESSION_ID: undefined,
WT_SESSION: undefined,
WSL_DISTRO_NAME: undefined,
WSL_INTEROP: undefined,
},
};
for (const key of ENV_KEYS) {
snapshot.bun[key] = Bun.env[key];
snapshot.process[key] = process.env[key];
const value = envPatch[key];
if (value === undefined) {
delete Bun.env[key];
delete process.env[key];
} else {
Bun.env[key] = value;
process.env[key] = value;
}
}
return snapshot;
}
export function restoreStressEnv(snapshot: StressEnvSnapshot): void {
for (const key of ENV_KEYS) {
const bunValue = snapshot.bun[key];
if (bunValue === undefined) {
delete Bun.env[key];
} else {
Bun.env[key] = bunValue;
}
const processValue = snapshot.process[key];
if (processValue === undefined) {
delete process.env[key];
} else {
process.env[key] = processValue;
}
}
}
let stressEnvPatchDepth = 0;
let platformPatchDepth = 0;
async function withPatchedEnv<T>(envMode: Scenario["envMode"], run: () => Promise<T>): Promise<T> {
if (stressEnvPatchDepth > 0) throw new Error("Nested stress environment patching is not supported");
stressEnvPatchDepth += 1;
const snapshot = applyStressEnv(envMode);
try {
return await run();
} finally {
restoreStressEnv(snapshot);
stressEnvPatchDepth -= 1;
}
}
async function withPatchedPlatform<T>(platform: Scenario["platform"], run: () => Promise<T>): Promise<T> {
if (platformPatchDepth > 0) throw new Error("Nested stress platform patching is not supported");
platformPatchDepth += 1;
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", { configurable: true, value: platform });
try {
return await run();
} finally {
if (platformDescriptor !== undefined) {
Object.defineProperty(process, "platform", platformDescriptor);
} else {
Reflect.deleteProperty(process, "platform");
}
platformPatchDepth -= 1;
}
}
export interface StressScenarioSuccess {
ok: true;
}
export interface StressScenarioFailure {
ok: false;
scenario: string;
seed: string;
error: string;
stack?: string;
}
/**
* Result a {@link runStressScenario} subprocess emits as a single JSON line on
* stdout. Each scenario runs in its own `bun` subprocess (one scenario per
* process), so there is no request multiplexing or `id` to correlate.
*/
export type StressScenarioResult = StressScenarioSuccess | StressScenarioFailure;
export async function runStressScenario(scenario: Scenario, options?: { patchEnv?: boolean }): Promise<void> {
const run = async (): Promise<void> => {
await withPatchedPlatform(scenario.platform, async () => {
const driver = new StressDriver(scenario);
await driver.run();
});
};
if (options?.patchEnv === false) {
await run();
} else {
await withPatchedEnv(scenario.envMode, run);
}
}
function restoreOwnProperty(target: object, key: string, descriptor: PropertyDescriptor | undefined): void {
if (descriptor === undefined) {
delete (target as Record<string, unknown>)[key];
return;
}
Object.defineProperty(target, key, descriptor);
}
export async function runNoReflowResizeNotificationRegression(): Promise<void> {
await withPatchedEnv("ghostty", async () => {
await withPatchedPlatform("darwin", async () => {
const stdinIsTty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
const stdoutIsTty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
const stdoutColumns = Object.getOwnPropertyDescriptor(process.stdout, "columns");
const stdoutRows = Object.getOwnPropertyDescriptor(process.stdout, "rows");
const stdinSetRawMode = Object.getOwnPropertyDescriptor(process.stdin, "setRawMode");
const stdinSetEncoding = Object.getOwnPropertyDescriptor(process.stdin, "setEncoding");
const stdinResume = Object.getOwnPropertyDescriptor(process.stdin, "resume");
const stdinPause = Object.getOwnPropertyDescriptor(process.stdin, "pause");
const stdoutWrite = Object.getOwnPropertyDescriptor(process.stdout, "write");
const processKill = Object.getOwnPropertyDescriptor(process, "kill");
const writes: string[] = [];
Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true });
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
Object.defineProperty(process.stdout, "columns", { value: 100, configurable: true });
Object.defineProperty(process.stdout, "rows", { value: 30, configurable: true });
Object.defineProperty(process.stdin, "setRawMode", { value: () => process.stdin, configurable: true });
Object.defineProperty(process.stdin, "setEncoding", { value: () => process.stdin, configurable: true });
Object.defineProperty(process.stdin, "resume", { value: () => process.stdin, configurable: true });
Object.defineProperty(process.stdin, "pause", { value: () => process.stdin, configurable: true });
Object.defineProperty(process.stdout, "write", {
value: (chunk: string | Uint8Array) => {
writes.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString());
return true;
},
configurable: true,
});
Object.defineProperty(process, "kill", { value: () => true, configurable: true });
// Exercises the real ProcessTerminal stdin/stdout pipeline; opt out of
// the test-default headless suppression inside the try so the finally
// below always restores the prior value, even on a start()/render throw.
let previousHeadless = false;
const term = new ProcessTerminal();
const scheduler = new StressRenderScheduler();
const tui = new TUI(term, true, { renderScheduler: scheduler });
const initialLines = Array.from({ length: 35 }, (_value, index) => `stream-row-${index}`);
const component = new MutableLinesComponent(initialLines);
const drainTarget = { flush: async () => {} } as VirtualTerminal;
tui.addChild(component);
try {
previousHeadless = setTerminalHeadless(false);
tui.start();
await scheduler.drain(drainTarget);
const reportOnlyWriteStart = writes.length;
process.stdin.emit("data", "\x1b[48;30;100;600;1000t");
await scheduler.drain(drainTarget);
if (writes.length !== reportOnlyWriteStart) {
throw new Error("Unchanged DEC 2048 resize report scheduled a render without a geometry change");
}
const streamingWriteStart = writes.length;
component.setLines([...initialLines, "stream-row-35"]);
process.stdin.emit("data", "\x1b[48;30;100;600;1000t");
tui.requestRender();
await scheduler.drain(drainTarget);
const emitted = writes.slice(streamingWriteStart).join("");
if (emitted.includes("\x1b[3J")) {
throw new Error(
"Unchanged DEC 2048 report coalesced with streaming content emitted destructive scrollback clear",
);
}
} finally {
tui.stop();
restoreOwnProperty(process.stdin, "isTTY", stdinIsTty);
restoreOwnProperty(process.stdout, "isTTY", stdoutIsTty);
restoreOwnProperty(process.stdout, "columns", stdoutColumns);
restoreOwnProperty(process.stdout, "rows", stdoutRows);
restoreOwnProperty(process.stdin, "setRawMode", stdinSetRawMode);
restoreOwnProperty(process.stdin, "setEncoding", stdinSetEncoding);
restoreOwnProperty(process.stdin, "resume", stdinResume);
restoreOwnProperty(process.stdin, "pause", stdinPause);
restoreOwnProperty(process.stdout, "write", stdoutWrite);
restoreOwnProperty(process, "kill", processKill);
setTerminalHeadless(previousHeadless);
}
});
});
}
export async function runPreexistingScrollbackRegression(): Promise<void> {
const term = new VirtualTerminal(40, 5, 100);
const scheduler = new StressRenderScheduler();
term.write(`${Array.from({ length: 12 }, (_value, index) => `shell-${index}`).join("\r\n")}\r\n`);
await term.flush();
const tui = new TUI(term, true, { renderScheduler: scheduler });
const component = new MutableLinesComponent(["ui-0", "ui-1", "ui-2"]);
tui.addChild(component);
try {
tui.start();
await scheduler.drain(term);
const externalRows = normalizeLines(term.getScrollBuffer()).filter(line => line.startsWith("shell-"));
if (externalRows.length === 0) {
throw new Error("Test setup failed: preexisting shell scrollback did not survive initial TUI paint");
}
const frames = [
["ui-0", "inserted-0", "ui-1", "ui-2"],
["ui-0", "inserted-1", "ui-1", "ui-2"],
["ui-0", "ui-1", "ui-2"],
["prefix", "ui-0", "ui-1", "ui-2"],
] as const;
for (let index = 0; index < frames.length; index++) {
component.setLines(frames[index]!);
tui.requestRender();
await scheduler.drain(term);
const buffer = normalizeLines(term.getScrollBuffer());
for (const row of externalRows) {
if (!buffer.includes(row)) {
throw new Error(
`Preexisting shell scrollback was cleared by visible structural mutation\n${JSON.stringify(
{ mutationIndex: index, missing: row, externalRows, buffer },
null,
2,
)}`,
);
}
}
}
} finally {
tui.stop();
await term.flush();
}
}