Merge PR #8433: fix(tui): stop direct HerdR pane flicker (@roboomp)
This commit is contained in:
+17
-20
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+72
-22
@@ -414,11 +414,9 @@ function parseSizeValue(value: SizeValue | undefined, referenceSize: number): nu
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 sessions where ED3 cannot safely rebuild scrollback. Direct HerdR
|
||||
* panes support explicit clears; nested multiplexers remain unsafe because the
|
||||
* inner tmux/screen/Zellij layer owns their history.
|
||||
*/
|
||||
function isMultiplexerSession(): boolean {
|
||||
if (!isInsideTerminalMultiplexer()) return false;
|
||||
@@ -457,12 +455,12 @@ function reportsSizeOnAltScreenToggle(): boolean {
|
||||
|
||||
/**
|
||||
* Resize should repaint the visible window in place — no alternate-screen
|
||||
* borrow, no ED3 scrollback rewrap — for multiplexer panes and for terminals
|
||||
* that loop on alt-screen toggles. The tradeoff is identical to a multiplexer:
|
||||
* scrollback above the window keeps its old wrap instead of being re-flowed.
|
||||
* borrow, no ED3 scrollback rewrap — for multiplexer and direct HerdR panes,
|
||||
* plus terminals that loop on alt-screen toggles. Direct HerdR remains a
|
||||
* direct terminal for explicit transcript replacement and display reset.
|
||||
*/
|
||||
function resizeRepaintsInPlace(): boolean {
|
||||
return isMultiplexerSession() || reportsSizeOnAltScreenToggle();
|
||||
return isMultiplexerSession() || Bun.env.HERDR_ENV === "1" || reportsSizeOnAltScreenToggle();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -561,7 +559,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 +1429,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 +1942,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 +2137,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 +2404,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 +3017,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 +3852,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 +4447,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 +4625,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 +4890,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 +5325,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 +5346,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 {
|
||||
|
||||
@@ -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,6 +2557,26 @@ describe("multiplexer detection gates ED3 on resize", () => {
|
||||
}
|
||||
}
|
||||
|
||||
expect(writes.join("")).not.toContain(ED3);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit scrollback clears in direct HerdR", async () => {
|
||||
await withEnvPatch({ ...NO_MULTIPLEXER_ENV, TERM: "dumb", HERDR_ENV: "1" }, async () => {
|
||||
const term = new VirtualTerminal(40, 10, 1000);
|
||||
const tui = new TUI(term);
|
||||
tui.addChild(new MutableLinesComponent(Array.from({ length: 20 }, (_value, index) => `line-${index}`)));
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
await settle(term);
|
||||
const writes = captureWrites(term);
|
||||
tui.resetDisplay();
|
||||
await settle(term);
|
||||
|
||||
expect(writes.join("")).toContain(ED3);
|
||||
} finally {
|
||||
tui.stop();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user