diff --git a/docs/tui-core-renderer.md b/docs/tui-core-renderer.md index 3390137b6..446be9598 100644 --- a/docs/tui-core-renderer.md +++ b/docs/tui-core-renderer.md @@ -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. diff --git a/docs/tui-runtime-internals.md b/docs/tui-runtime-internals.md index f26c13c91..b5c1ea16a 100644 --- a/docs/tui-runtime-internals.md +++ b/docs/tui-runtime-internals.md @@ -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. diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index a234ed5b0..24f2d6628 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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 diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 73c0fd98b..3b2353912 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -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 { diff --git a/packages/tui/test/issue-2088-repro.test.ts b/packages/tui/test/issue-2088-repro.test.ts index e93eb63f0..1126c7ef1 100644 --- a/packages/tui/test/issue-2088-repro.test.ts +++ b/packages/tui/test/issue-2088-repro.test.ts @@ -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 = Object.fromEntries( MULTIPLEXER_ENV_KEYS.map(key => [key, undefined]), ); -const TMUX_ENV: Record = { ...NO_MULTIPLEXER_ENV, TMUX: "1" }; +const TMUX_ENV: Record = { + ...NO_MULTIPLEXER_ENV, + TMUX: "1", +}; const MULTIPLEXER_ENV_CASES: Array<[string, Record]> = [ - ["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 = { ...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(); } diff --git a/packages/tui/test/render-stress-harness.ts b/packages/tui/test/render-stress-harness.ts index 508357d3b..07280d2fc 100644 --- a/packages/tui/test/render-stress-harness.ts +++ b/packages/tui/test/render-stress-harness.ts @@ -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; 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[] = [ { 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 { @@ -1874,7 +1991,10 @@ class StressDriver { async #resizeNoop(): Promise { 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 { @@ -1886,7 +2006,9 @@ class StressDriver { async #scrollToBottom(): Promise { 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 { @@ -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(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 { 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 { export async function runWidthEpochHeightAppendReplayRegression(): Promise { 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 { 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 { }, 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