fix(tui): stopped direct herdr pane flicker

Kept direct Herdr panes on the host-safe in-place resize path so streaming redraws no longer clear and replay pane scrollback.

Updated the resize regression, stress scenario, renderer docs, and changelog.

Fixes #8431
This commit is contained in:
roboomp
2026-08-13 14:34:03 +00:00
parent 326d24bd40
commit bb0314a5a1
6 changed files with 402 additions and 128 deletions
+17 -20
View File
@@ -66,7 +66,7 @@ needs to know whether the user has scrolled away from the tail.
- A component tree that reports **no seam** gets shell semantics: whatever
scrolls off is final. Shrinking such a frame into its committed prefix
re-anchors the window and leaves the stale copy in history (§3).
- Inside ED3-unsafe multiplexers, a width change terminates the physical-row
- Inside terminal multiplexers, a width change terminates the physical-row
coordinate epoch. The renderer captures an opaque
`NativeScrollbackWidthEpoch` marker from the last emitted source state before
`SIGWINCH`, then resolves that same logical boundary after the settled-width
@@ -77,11 +77,9 @@ needs to know whether the user has scrolled away from the tail.
and no old viewport row is recommitted. Components without the source
contract retain the conservative physical-row fallback. Visible overlays
freeze the seam and pinned live regions clip advancement at their final
boundary. Height-only resizes retain the existing ledger.
- Direct HerdR panes are not in that category. HerdR's Ghostty core implements
ED3, so OMP clears and replays its source-owned transcript after settlement,
matching direct terminals. Repainting host-reflowed rows in place would
harden soft wraps and leave residual rows after a later width reversal.
boundary. Height-only resizes retain the existing ledger. Direct HerdR panes
use this path because clearing and replaying scrollback flickers in its
host-owned pane.
---
@@ -188,20 +186,19 @@ contract, not a terminal-specific optimization.
deliberate exception: it clears and replays the complete current frame.
3. **Commits are exactly the chunk.** Any byte shape that scrolls the screen
must scroll only rows accounted for by the commit advance.
4. **An ED3-unsafe multiplexer width resize NEVER advances history.** The old
committed physical-row coordinate is opaque after reflow. The resize leaves
the host-reflowed viewport in place and establishes a complete-frame
baseline independent of the native commit count. Subsequent growth writes
the exact current-width rows newly crossing the seam—not blank scroll
commands—then repaints the bounded viewport; only that slice advances
commits. Visible overlays advance neither the baseline nor the seam ledger;
overlay exit backfills the exact hidden slice. Pinned live regions advance
only through their final boundary; finalization releases the deferred
mutable slice. During a height shrink, only occupied old-frame rows actually
moved into history by the host are excluded from the append-owned seam;
empty viewport rows do not consume content-driven movement. Height-only
resizes do not terminate the epoch. Direct HerdR uses ED3 source replay
instead.
4. **A multiplexer width resize NEVER advances history.** The old committed
physical-row coordinate is opaque after reflow. The resize leaves the
host-reflowed viewport in place and establishes a complete-frame baseline
independent of the native commit count. Subsequent growth writes the exact
current-width rows newly crossing the seam—not blank scroll commands—then
repaints the bounded viewport; only that slice advances commits. Visible
overlays advance neither the baseline nor the seam ledger; overlay exit
backfills the exact hidden slice. Pinned live regions advance only through
their final boundary; finalization releases the deferred mutable slice.
During a height shrink, only occupied old-frame rows actually moved into
history by the host are excluded from the append-owned seam; empty viewport
rows do not consume content-driven movement. Height-only resizes do not
terminate the epoch.
5. **NEVER probe the viewport position or fork on platform in the update
path.** win32 behaves like POSIX. The probe APIs are gone; do not
reintroduce them.
+4 -3
View File
@@ -162,9 +162,10 @@ Resize events are event-driven from `ProcessTerminal` to `TUI.requestRender()`.
Effects:
- A resize is an explicit user gesture: on direct terminals and direct HerdR panes the engine erases and replays (`ED3` + full paint) so history rewraps at the new geometry; the commit ledger restarts from the replayed frame. HerdR's Ghostty core supports ED3, and replay avoids converting its reflowed soft wraps into permanent row boundaries.
- Inside ED3-unsafe terminal multiplexers, height-only resize retains the append ledger and repaints the visible window in place after the settle debounce (issue #2088). A width change instead terminates the physical-row epoch: old committed coordinates become opaque, pane history remains immutable at its authored wrap, and the settled render leaves the host-reflowed viewport in place. The current frame length becomes a baseline independent of the native committed-row count. Subsequent growth writes the exact current-width rows newly crossing the scrollback seam, follows them with a bounded viewport repaint, and advances commits by exactly that emitted slice. Ordinary and forced renders arriving during the debounce are folded into the settled repaint without postponing source updates.
- Nested tmux, screen, Zellij, or cmux sessions inside HerdR remain on the ED3-unsafe path because the inner multiplexer owns its history.
- Direct HerdR panes follow the in-place multiplexer path: their host owns the
pane, and destructive `ED3` transcript replay produces visible flashes.
- Inside terminal multiplexers, height-only resize retains the append ledger and repaints the visible window in place after the settle debounce (issue #2088). A width change instead terminates the physical-row epoch: old committed coordinates become opaque, pane history remains immutable at its authored wrap, and the settled render establishes a complete-frame baseline. Subsequent growth writes only current-width rows newly crossing the scrollback seam before repainting the bounded viewport.
- Nested tmux, screen, Zellij, or cmux sessions inside HerdR use the same path.
- Terminals that re-report their size when the alternate screen buffer is toggled (Warp reports a height one row different for the alt buffer) take the in-place path too. The non-multiplexer fast path borrows the alternate screen for drag frames, so on these terminals each alt enter/leave emits a fresh resize event, which re-enters the fast path — a self-sustaining loop that floods ED3 full repaints with stable geometry. `resizeRepaintsInPlace()` (covering ED3-unsafe multiplexers and these terminals; overridable via `PI_TUI_RESIZE_IN_PLACE`) routes them through the in-place repaint, which never touches the alt buffer.
- Overlay visibility can depend on terminal dimensions (`OverlayOptions.visible`); focus is corrected when overlays become non-visible after resize.
+8 -4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed direct Herdr panes continuously flashing during transcript streaming by keeping resize redraws on the pane-safe in-place path instead of clearing and replaying host-owned scrollback ([#8431](https://github.com/can1357/oh-my-pi/issues/8431)).
## [17.3.0] - 2026-08-13
### Fixed
@@ -251,7 +255,7 @@
### Changed
- Improved native scrollback history management by introducing an optional erase-and-replay mechanism to rebuild scrollback when mutated rows (such as finalized tool blocks or collapsed transcripts) diverge. This is now gated behind the `tui.scrollbackRebuild` setting and defaults to off.
- Improved native scrollback history management by introducing an optional erase-and-replay mechanism to rebuild scrollback when mutated rows (such as finalized tool blocks or collapsed transcripts) diverge. This is now gated behind the `tui.scrollbackRebuild` setting and defaults to off.
### Fixed
@@ -600,7 +604,7 @@
- Added `ctrl+j` as a second default binding for the `tui.input.newLine` action alongside `shift+enter`, so terminals that cannot emit `shift+enter` still have a newline key. On terminals with Kitty-protocol / `modifyOtherKeys` disambiguation `ctrl+j` inserts a newline while `Enter` still submits; on legacy terminals where `ctrl+j` and `Enter` are both byte-identical `LF` it submits (documented limitation). User keybinding overrides still take precedence ([#2473](https://github.com/can1357/oh-my-pi/issues/2473))
- Added an `Editor.onLargePaste(text, lineCount)` hook, fired for a "marker-sized" paste (the point where the editor would otherwise collapse it into a `[Paste #N]` token). Returning `true` lets the host intercept the paste — e.g. to offer wrap-in-code-block / wrap-in-XML / attach-as-file choices — and suppresses the default marker (no undo state is recorded). Added `Editor.insertPaste(content)` so the host can re-insert a (possibly transformed) collapsed paste marker without re-triggering the hook.
- Added `Editor.deleteBeforeCursor(count)`, which removes up to `count` characters immediately before the cursor on the current line (capped at the cursor column, single line, records one undo state). Hosts use it to "track back" optimistically-inserted characters — e.g. the coding-agent hold-`Space` push-to-talk gesture deleting the space-bar auto-repeat burst.
- Added an optional `getNativeScrollbackSnapshotSafeEnd()` to the `NativeScrollbackLiveRegion` contract: a *durable* commit boundary (D ≥ the byte-stable `commitSafeEnd`) for live rows whose current snapshot is permanent content but may still drift bytes later (a streaming markdown table re-aligning its columns). The engine commits these rows when they scroll above the window — never dropping them — but **audit-exempt** (tracked via a new byte-stable `auditRows` prefix), so a later layout change of an already-committed row freezes a stale row in history (duplication never loss) instead of re-anchoring the committed-prefix audit and spraying duplicate snapshots. Components that omit it are unchanged: `durableBoundary === byteStableBoundary` and `auditRows === committedRows`, so the ledger math is byte-identical.
- Added an optional `getNativeScrollbackSnapshotSafeEnd()` to the `NativeScrollbackLiveRegion` contract: a _durable_ commit boundary (D ≥ the byte-stable `commitSafeEnd`) for live rows whose current snapshot is permanent content but may still drift bytes later (a streaming markdown table re-aligning its columns). The engine commits these rows when they scroll above the window — never dropping them — but **audit-exempt** (tracked via a new byte-stable `auditRows` prefix), so a later layout change of an already-committed row freezes a stale row in history (duplication never loss) instead of re-anchoring the committed-prefix audit and spraying duplicate snapshots. Components that omit it are unchanged: `durableBoundary === byteStableBoundary` and `auditRows === committedRows`, so the ledger math is byte-identical.
### Fixed
@@ -841,7 +845,7 @@
### Fixed
- Fixed Windows ConPTY session-resume painting the transcript with the last several rows truncated below the viewport until Alt+Tab forced a host repaint. After `sessionReplace`/`historyRebuild`/`overlayRebuild` paints that scroll-push content into native scrollback, the renderer now arms a 150 ms ConPTY settle window that coalesces spinner/blink-driven `requestRender(false)` calls into a single trailing render — Windows Terminal's viewport-follow logic no longer falls further behind the cursor on every tick of the post-paint storm. The arm also reclaims any render request queued *during* the in-flight composition (notably `ImageBudget.endPass()` calling `requestRender()` synchronously when a frame trips the live-graphics cap): without that, the queued request sat on the standard 30 Hz throttle and fired at ~33 ms — well inside the 150 ms quiet window — defeating the coalescing. Bumped the ConPTY per-`WriteFile` chunk cap from 8 KiB to 16 KiB so a multi-megabyte resume paint emits half as many writes (still well under the ~32 KiB threshold from #2034 that the original cap defends against), and made the cap measure encoded UTF-8 bytes instead of JS code units so a CJK-heavy transcript can't silently inflate a 16-KiB-of-code-units chunk into ~48 KiB of `WriteFile` traffic and reintroduce the #2034 viewport bug ([#2095](https://github.com/can1357/oh-my-pi/issues/2095)).
- Fixed Windows ConPTY session-resume painting the transcript with the last several rows truncated below the viewport until Alt+Tab forced a host repaint. After `sessionReplace`/`historyRebuild`/`overlayRebuild` paints that scroll-push content into native scrollback, the renderer now arms a 150 ms ConPTY settle window that coalesces spinner/blink-driven `requestRender(false)` calls into a single trailing render — Windows Terminal's viewport-follow logic no longer falls further behind the cursor on every tick of the post-paint storm. The arm also reclaims any render request queued _during_ the in-flight composition (notably `ImageBudget.endPass()` calling `requestRender()` synchronously when a frame trips the live-graphics cap): without that, the queued request sat on the standard 30 Hz throttle and fired at ~33 ms — well inside the 150 ms quiet window — defeating the coalescing. Bumped the ConPTY per-`WriteFile` chunk cap from 8 KiB to 16 KiB so a multi-megabyte resume paint emits half as many writes (still well under the ~32 KiB threshold from #2034 that the original cap defends against), and made the cap measure encoded UTF-8 bytes instead of JS code units so a CJK-heavy transcript can't silently inflate a 16-KiB-of-code-units chunk into ~48 KiB of `WriteFile` traffic and reintroduce the #2034 viewport bug ([#2095](https://github.com/can1357/oh-my-pi/issues/2095)).
## [15.10.3] - 2026-06-08
@@ -1084,7 +1088,7 @@
### Fixed
- Fixed native Windows + Windows Terminal freezing the editor on the wrap keystroke, on `/plan`/`/resume`/model-switch/status-line toggles, and on any other offscreen structural mutation until the next prompt submit. The `15.7.5` `#1635` fix routed every viewport-saturating pure-append and structural mutation through `deferredMutation` (a literal no-op) whenever `isNativeViewportAtBottom()` returned `undefined` — which it always does under `WT_SESSION` because the kernel32 probe can't see WT host scrollback. The deferral was only ever meant for the *confirmed-scrolled* case; an unknown viewport now falls back to a non-destructive `viewportRepaint` instead, so the live UI keeps updating without emitting `\x1b[3J` and without yanking a possibly-scrolled reader. Confirmed-scrolled frames (probe returns `false`) still defer.
- Fixed native Windows + Windows Terminal freezing the editor on the wrap keystroke, on `/plan`/`/resume`/model-switch/status-line toggles, and on any other offscreen structural mutation until the next prompt submit. The `15.7.5` `#1635` fix routed every viewport-saturating pure-append and structural mutation through `deferredMutation` (a literal no-op) whenever `isNativeViewportAtBottom()` returned `undefined` — which it always does under `WT_SESSION` because the kernel32 probe can't see WT host scrollback. The deferral was only ever meant for the _confirmed-scrolled_ case; an unknown viewport now falls back to a non-destructive `viewportRepaint` instead, so the live UI keeps updating without emitting `\x1b[3J` and without yanking a possibly-scrolled reader. Confirmed-scrolled frames (probe returns `false`) still defer.
- Removed the hard-coded 20-result cap on `@`-prefixed fuzzy file completion in `CombinedAutocompleteProvider.#getFuzzyFileSuggestions`. The dropdown now honors the existing `maxResults: 100` ceiling already configured for `fuzzyFind`, so projects with many files sharing a common stem (e.g. `@controller`, `@test`) surface all relevant matches instead of being silently truncated. ([#1652](https://github.com/can1357/oh-my-pi/issues/1652))
## [15.7.5] - 2026-06-01
+67 -33
View File
@@ -413,27 +413,9 @@ function parseSizeValue(value: SizeValue | undefined, referenceSize: number): nu
return undefined;
}
/**
* Detect sessions where ED3 cannot safely rebuild scrollback. A direct HerdR
* pane is deliberately excluded: HerdR's Ghostty core implements ED3, so the
* source-owned replay used by direct terminals is both supported and the only
* way to avoid hardening host-reflowed soft wraps. Nested multiplexers remain
* unsafe because the inner tmux/screen/Zellij layer still owns its history.
*/
/** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
function isMultiplexerSession(): boolean {
if (!isInsideTerminalMultiplexer()) return false;
if (Bun.env.HERDR_ENV !== "1") return true;
const term = Bun.env.TERM?.toLowerCase() ?? "";
return Boolean(
Bun.env.TMUX ||
Bun.env.STY ||
Bun.env.ZELLIJ ||
Bun.env.CMUX_WORKSPACE_ID ||
Bun.env.CMUX_SURFACE_ID ||
Bun.env.CMUX_REMOTE_TRANSPORT ||
term.startsWith("tmux") ||
term.startsWith("screen"),
);
return isInsideTerminalMultiplexer();
}
/**
@@ -561,7 +543,11 @@ export class Container
component: Component;
childBoundary: unknown;
sourceIndex: number;
leading: ReadonlyArray<{ component: Component; revision: number | undefined; rowCount: number }>;
leading: ReadonlyArray<{
component: Component;
revision: number | undefined;
rowCount: number;
}>;
trailing: ReadonlyArray<{
component: Component;
revision: number | undefined;
@@ -1427,8 +1413,16 @@ export class TUI extends Container {
component: Component;
childBoundary: unknown;
sourceIndex: number;
leading: ReadonlyArray<{ component: Component; revision: number | undefined; rowCount: number }>;
trailing: ReadonlyArray<{ component: Component; revision: number | undefined; rowCount: number }>;
leading: ReadonlyArray<{
component: Component;
revision: number | undefined;
rowCount: number;
}>;
trailing: ReadonlyArray<{
component: Component;
revision: number | undefined;
rowCount: number;
}>;
hasTrailingRows: boolean;
}
>();
@@ -1932,7 +1926,12 @@ export class TUI extends Container {
*/
showOverlay(component: Component, options?: OverlayOptions): OverlayHandle {
component.setIgnoreTight?.(true);
const entry = { component, options, preFocus: this.#focusedComponent, hidden: false };
const entry = {
component,
options,
preFocus: this.#focusedComponent,
hidden: false,
};
this.overlayStack.push(entry);
// Only focus if overlay is actually visible
if (this.#isOverlayVisible(entry)) {
@@ -2122,7 +2121,9 @@ export class TUI extends Container {
this.#recordHardwareCursorHidden();
this.#querySixelSupport();
this.#queryCellSize();
this.requestRender(true, { clearScrollback: options?.clearScrollback === true });
this.requestRender(true, {
clearScrollback: options?.clearScrollback === true,
});
}
addStartListener(listener: StartListener): () => void {
@@ -2387,7 +2388,10 @@ export class TUI extends Container {
// the same `#prepareForcedRender(!isMultiplexerSession())` path via
// `requestRender(true)`, so the clear-scrollback intent is preserved.
if (this.#multiplexerResizeTimer) {
this.#armMultiplexerResizeTimer({ clearScrollback: !isMultiplexerSession(), hasPendingRender: true });
this.#armMultiplexerResizeTimer({
clearScrollback: !isMultiplexerSession(),
hasPendingRender: true,
});
return;
}
this.#prepareForcedRender(!isMultiplexerSession());
@@ -2997,7 +3001,12 @@ export class TUI extends Container {
// Parse margin (clamp to non-negative)
const margin =
typeof opt.margin === "number"
? { top: opt.margin, right: opt.margin, bottom: opt.margin, left: opt.margin }
? {
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);
@@ -3827,7 +3836,10 @@ export class TUI extends Container {
window = this.#compositeOverlaysIntoWindow(window, width, height);
const overlayMarkers = this.#extractCursorMarkers(window);
if (overlayMarkers.length > 0) {
cursorPos = { row: windowTop + overlayMarkers[0]!.row, col: overlayMarkers[0]!.col };
cursorPos = {
row: windowTop + overlayMarkers[0]!.row,
col: overlayMarkers[0]!.col,
};
}
window = this.#prepareLinesArray(window, width);
}
@@ -4419,7 +4431,10 @@ export class TUI extends Container {
this.#hardwareCursorVisible = false;
this.#hardwareCursorVisibilityKnown = true;
if (!this.#hardwareCursorState) return;
this.#hardwareCursorState = { ...this.#hardwareCursorState, visible: false };
this.#hardwareCursorState = {
...this.#hardwareCursorState,
visible: false,
};
}
#forgetHardwareCursorState(): void {
@@ -4594,7 +4609,10 @@ export class TUI extends Container {
if (cursorPos.row < chunkTo) {
paintCursorPos = cursorPos;
} else if (cursorPos.row >= windowTop && cursorPos.row < windowTop + height) {
paintCursorPos = { row: chunkTo + cursorPos.row - windowTop, col: cursorPos.col };
paintCursorPos = {
row: chunkTo + cursorPos.row - windowTop,
col: cursorPos.col,
};
}
}
// ConPTY hosts bound bulk transcript-replacement replays (resume, handoff,
@@ -4856,7 +4874,11 @@ export class TUI extends Container {
const framed: string[] = new Array(extra + height);
for (let k = 0; k < extra; k++) framed[k] = tail[tail.length - 1 - k]!;
for (let screenRow = 0; screenRow < height; screenRow++) framed[extra + screenRow] = window[screenRow]!;
return { framed: this.#prepareLinesArray(framed, width), viewportTop: extra, contentRows };
return {
framed: this.#prepareLinesArray(framed, width),
viewportTop: extra,
contentRows,
};
}
/**
@@ -5287,7 +5309,13 @@ export class TUI extends Container {
// No IME target or no content — hide cursor regardless of preference.
const target = this.#targetHardwareCursorState(cursorPos, totalLines);
if (!target) {
return { seq: "\x1b[?25l", toRow: fromRow, toCol: 0, visible: false, state: null };
return {
seq: "\x1b[?25l",
toRow: fromRow,
toCol: 0,
visible: false,
state: null,
};
}
// Move cursor from current position to target.
@@ -5302,7 +5330,13 @@ export class TUI extends Container {
seq += `\x1b[${target.col + 1}G`;
seq += target.visible ? "\x1b[?25h" : "\x1b[?25l";
return { seq, toRow: target.row, toCol: target.col, visible: target.visible, state: target };
return {
seq,
toRow: target.row,
toCol: target.col,
visible: target.visible,
state: target,
};
}
#isHiddenCursorKnown(): boolean {
+27 -6
View File
@@ -414,7 +414,11 @@ class ManualRenderScheduler implements RenderScheduler {
}
scheduleRender(callback: () => void, delayMs: number): RenderTimer {
const timer = { at: this.#now + Math.max(0, delayMs), callback, canceled: false };
const timer = {
at: this.#now + Math.max(0, delayMs),
callback,
canceled: false,
};
this.#timers.push(timer);
return {
cancel: () => {
@@ -519,10 +523,27 @@ const MULTIPLEXER_ENV_KEYS = [
const NO_MULTIPLEXER_ENV: Record<string, string | undefined> = Object.fromEntries(
MULTIPLEXER_ENV_KEYS.map(key => [key, undefined]),
);
const TMUX_ENV: Record<string, string | undefined> = { ...NO_MULTIPLEXER_ENV, TMUX: "1" };
const TMUX_ENV: Record<string, string | undefined> = {
...NO_MULTIPLEXER_ENV,
TMUX: "1",
};
const MULTIPLEXER_ENV_CASES: Array<[string, Record<string, string | undefined>]> = [
["CMUX_WORKSPACE_ID", { ...NO_MULTIPLEXER_ENV, TERM: "dumb", CMUX_WORKSPACE_ID: "workspace:cmux-2088" }],
["CMUX_SURFACE_ID", { ...NO_MULTIPLEXER_ENV, TERM: "dumb", CMUX_SURFACE_ID: "surface:cmux-2088" }],
[
"CMUX_WORKSPACE_ID",
{
...NO_MULTIPLEXER_ENV,
TERM: "dumb",
CMUX_WORKSPACE_ID: "workspace:cmux-2088",
},
],
[
"CMUX_SURFACE_ID",
{
...NO_MULTIPLEXER_ENV,
TERM: "dumb",
CMUX_SURFACE_ID: "surface:cmux-2088",
},
],
];
const CMUX_SOCKET_ONLY_ENV: Record<string, string | undefined> = {
...NO_MULTIPLEXER_ENV,
@@ -2512,7 +2533,7 @@ describe("multiplexer detection gates ED3 on resize", () => {
});
}
it("rebuilds direct HerdR scrollback from source across repeated widths", async () => {
it("repaints direct HerdR resizes in place without ED3", async () => {
await withEnvPatch({ ...NO_MULTIPLEXER_ENV, TERM: "dumb", HERDR_ENV: "1" }, async () => {
const term = new VirtualTerminal(40, 10, 1000);
const tui = new TUI(term);
@@ -2536,7 +2557,7 @@ describe("multiplexer detection gates ED3 on resize", () => {
}
}
expect(writes.join("")).toContain(ED3);
expect(writes.join("")).not.toContain(ED3);
} finally {
tui.stop();
}
+279 -62
View File
@@ -369,7 +369,11 @@ function contentOperation(
checksRowAccounting: boolean,
overrides: AppliedOperationOverrides = {},
): AppliedOperation {
return appliedOperation(kind, detail, { mutatesContent: true, checksRowAccounting, ...overrides });
return appliedOperation(kind, detail, {
mutatesContent: true,
checksRowAccounting,
...overrides,
});
}
function viewOperation(
@@ -414,18 +418,78 @@ interface BurstStepMetadata {
}
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 },
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 {
@@ -790,7 +854,11 @@ class StressModel {
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 };
return {
index,
count: removed.length,
firstRemoved: removed[0]?.text ?? null,
};
}
replaceAll(): JsonObject {
@@ -839,11 +907,17 @@ class StressModel {
rotateUp(): JsonObject {
if (this.lines.length < 2) {
this.lines.push(this.#freshLine("t"));
return { dropped: null, appended: this.lines[this.lines.length - 1]?.text ?? "" };
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 ?? "" };
return {
dropped: dropped?.text ?? null,
appended: this.lines[this.lines.length - 1]?.text ?? "",
};
}
collapseToFew(): JsonObject {
@@ -881,7 +955,13 @@ class StressModel {
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 ?? "" };
return {
start,
count: removed.length,
editedIndex,
before,
after: this.lines[editedIndex]?.text ?? "",
};
}
swapOffscreenRows(height: number): JsonObject {
@@ -1046,7 +1126,12 @@ class StressOverlayModel {
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 ?? "" };
return {
action: "edit",
index,
before,
after: this.lines[index]?.text ?? "",
};
}
if (action === 2) {
const index = this.#rng.int(0, this.lines.length - 1);
@@ -1183,7 +1268,12 @@ class StressDriver {
scenario.uniqueContent,
`child${id}-`,
);
return { id, model, component: new StressComponent(model, scenario.reflow), active: false };
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
@@ -1404,9 +1494,18 @@ class StressDriver {
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: "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 },
@@ -1433,7 +1532,10 @@ class StressDriver {
{ 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: "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 },
@@ -1451,10 +1553,22 @@ class StressDriver {
{ 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 },
{
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);
}
@@ -1754,7 +1868,10 @@ class StressDriver {
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 });
return this.#viewOperation("hideOverlay", {
id: entry.id,
sentinel: entry.sentinel,
});
}
async #toggleOverlayHidden(): Promise<AppliedOperation> {
@@ -1874,7 +1991,10 @@ class StressDriver {
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 });
return viewOperation("resizeNoop", {
columns: this.#term.columns,
rows: this.#term.rows,
});
}
async #scrollUp(): Promise<AppliedOperation> {
@@ -1886,7 +2006,9 @@ class StressDriver {
async #scrollToBottom(): Promise<AppliedOperation> {
this.#term.scrollLines(LARGE_SCROLL);
this.#tui.requestRender(true, { clearScrollback: this.#traits.strictNativeScrollback });
this.#tui.requestRender(true, {
clearScrollback: this.#traits.strictNativeScrollback,
});
await this.#settle();
return forceRenderOperation(
"scrollToBottom",
@@ -1957,7 +2079,12 @@ class StressDriver {
this.#term.scrollLines(LARGE_SCROLL);
this.#tui.requestRender(true, { clearScrollback: true });
await this.#settle();
return { ...this.#forceOperation("forceRenderClearScrollback", { clearScrollback: true }), checkpoint: true };
return {
...this.#forceOperation("forceRenderClearScrollback", {
clearScrollback: true,
}),
checkpoint: true,
};
}
async #forceRenderAfterEmptyOverflow(): Promise<AppliedOperation> {
@@ -1980,7 +2107,11 @@ class StressDriver {
this.#tui.requestRender(true);
await this.#settle();
return {
...this.#forceOperation("forceRenderAfterEmptyOverflow", { detachedChildren, empty, overflow }),
...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.
@@ -2006,7 +2137,10 @@ class StressDriver {
}
this.#tui.requestRender();
await this.#settle();
return viewOperation("toggleFocusInput", { focused: this.#component.focused, cursor });
return viewOperation("toggleFocusInput", {
focused: this.#component.focused,
cursor,
});
}
// Container.addChild appends and Container.render walks children in array
@@ -2055,7 +2189,9 @@ class StressDriver {
await this.#settle();
return contentOperation(
"reorderChildren",
{ activeOrder: this.#children.filter(child => child.active).map(child => child.id) },
{
activeOrder: this.#children.filter(child => child.active).map(child => child.id),
},
false,
);
}
@@ -2412,7 +2548,9 @@ class StressDriver {
#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) });
this.#fail("cursor bounds", op, before, after, index, {
cursor: cursorObject(after),
});
}
const expectedCursor = after.expectedCursor;
if (expectedCursor === null || !after.atBottom) return;
@@ -2762,7 +2900,9 @@ class StressDriver {
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 });
this.#fail("stale overlay sentinel", op, before, after, index, {
sentinel,
});
}
}
}
@@ -3064,11 +3204,18 @@ export function expectedFrameFromLines(lines: readonly string[], width: number,
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)) };
cursor = {
row: row - viewportTop,
col: visibleWidth(line.slice(0, markerIndex)),
};
}
stripped[row] = cleanLine;
}
return { frame: stripped.map(line => expectedTerminalLine(line, width)), cursor, backgroundColumns };
return {
frame: stripped.map(line => expectedTerminalLine(line, width)),
cursor,
backgroundColumns,
};
}
function expectedBackgroundColumns(line: string, width: number): number[] {
@@ -3135,7 +3282,12 @@ function compositeExpectedOverlays(
): string[] {
if (overlays.length === 0) return [...lines];
const result = [...lines];
const rendered: { overlayLines: string[]; row: number; col: number; w: number }[] = [];
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;
@@ -3145,7 +3297,12 @@ function compositeExpectedOverlays(
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 });
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);
@@ -3180,7 +3337,12 @@ export function resolveExpectedOverlayLayout(
const opt = options ?? {};
const margin =
typeof opt.margin === "number"
? { top: opt.margin, right: opt.margin, bottom: opt.margin, left: opt.margin }
? {
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);
@@ -3410,12 +3572,18 @@ function snapshotDump(snapshot: Snapshot): JsonObject {
view: snapshot.view,
viewBackgroundColumns: snapshot.viewBackgroundColumns,
frameBackgroundColumns: snapshot.frameBackgroundColumns,
position: { baseY: snapshot.position.baseY, viewportY: snapshot.position.viewportY },
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 },
: {
row: snapshot.expectedCursor.row,
col: snapshot.expectedCursor.col,
},
redraws: snapshot.redraws,
width: snapshot.width,
height: snapshot.height,
@@ -3429,12 +3597,18 @@ function snapshotSummary(snapshot: Snapshot): JsonObject {
bufferLength: snapshot.buffer.length,
view: snapshot.view,
viewBackgroundColumns: snapshot.viewBackgroundColumns,
position: { baseY: snapshot.position.baseY, viewportY: snapshot.position.viewportY },
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 },
: {
row: snapshot.expectedCursor.row,
col: snapshot.expectedCursor.col,
},
redraws: snapshot.redraws,
width: snapshot.width,
height: snapshot.height,
@@ -3620,7 +3794,9 @@ function parseReplayOperations(): readonly OperationKind[] | null {
try {
parsed = JSON.parse(fs.readFileSync(path, "utf8"));
} catch (error) {
throw new Error(`Invalid TUI_STRESS_REPLAY_LOG JSON: ${path}`, { cause: error });
throw new Error(`Invalid TUI_STRESS_REPLAY_LOG JSON: ${path}`, {
cause: error,
});
}
const entries = Array.isArray(parsed)
? parsed
@@ -3756,9 +3932,9 @@ function coreTemplates(): ScenarioTemplate[] {
heightChoices: [3, 4, 6],
},
{
// Direct HerdR implements ED3, so a settled width change clears and
// replays the source-owned transcript at its new wrap. Streaming
// updates may race the resize but must survive that replay exactly once.
// Direct HerdR follows the in-place multiplexer resize policy.
// Streaming updates may race the resize but must survive the settled
// repaint exactly once.
name: "darwin-normal-herdr-reflow-stream-small",
platform: "darwin",
terminalMode: "normal",
@@ -3929,7 +4105,10 @@ function coreTemplates(): ScenarioTemplate[] {
function soakTemplates(): ScenarioTemplate[] {
const templates: ScenarioTemplate[] = [];
const platformEnvModes: readonly { platform: TestPlatform; envModes: readonly EnvMode[] }[] = [
const platformEnvModes: readonly {
platform: TestPlatform;
envModes: readonly EnvMode[];
}[] = [
{ platform: "darwin", envModes: ["plain", "tmux", "herdr"] },
{ platform: "linux", envModes: ["plain", "tmux", "termux", "vteNoSync"] },
{ platform: "win32", envModes: ["plain"] },
@@ -4094,7 +4273,10 @@ async function withPatchedPlatform<T>(platform: Scenario["platform"], run: () =>
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 });
Object.defineProperty(process, "platform", {
configurable: true,
value: platform,
});
try {
return await run();
} finally {
@@ -4143,7 +4325,11 @@ export async function runStressScenario(scenario: Scenario, options?: { patchEnv
export async function runWidthEpochOverlayReplayRegression(): Promise<void> {
const base = coreTemplates().find(candidate => candidate.name === "darwin-normal-herdr-reflow-stream-small");
if (base === undefined) throw new Error("Missing reflow-stream stress template");
const template: ScenarioTemplate = { ...base, name: "darwin-normal-tmux-reflow-stream-small", envMode: "tmux" };
const template: ScenarioTemplate = {
...base,
name: "darwin-normal-tmux-reflow-stream-small",
envMode: "tmux",
};
const operations: readonly OperationKind[] = ["resizeWidth", "showOverlay", "streamOne", "streamOne", "hideOverlay"];
const scenario = materializeScenario(
template,
@@ -4160,7 +4346,11 @@ export async function runWidthEpochOverlayReplayRegression(): Promise<void> {
export async function runWidthEpochHeightAppendReplayRegression(): Promise<void> {
const source = coreTemplates().find(candidate => candidate.name === "darwin-normal-herdr-reflow-stream-small");
if (source === undefined) throw new Error("Missing reflow-stream stress template");
const base: ScenarioTemplate = { ...source, name: "darwin-normal-tmux-reflow-stream-small", envMode: "tmux" };
const base: ScenarioTemplate = {
...source,
name: "darwin-normal-tmux-reflow-stream-small",
envMode: "tmux",
};
const template: ScenarioTemplate = {
...base,
columns: 40,
@@ -4204,14 +4394,38 @@ export async function runNoReflowResizeNotificationRegression(): Promise<void> {
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.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());
@@ -4219,7 +4433,10 @@ export async function runNoReflowResizeNotificationRegression(): Promise<void> {
},
configurable: true,
});
Object.defineProperty(process, "kill", { value: () => 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