From bb0314a5a141ac3fa55649abba91bb63bd6e5235 Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 13 Aug 2026 14:34:03 +0000 Subject: [PATCH 1/2] fix(tui): stopped direct herdr pane flicker Kept direct Herdr panes on the host-safe in-place resize path so streaming redraws no longer clear and replay pane scrollback. Updated the resize regression, stress scenario, renderer docs, and changelog. Fixes #8431 --- docs/tui-core-renderer.md | 37 +-- docs/tui-runtime-internals.md | 7 +- packages/tui/CHANGELOG.md | 12 +- packages/tui/src/tui.ts | 100 ++++-- packages/tui/test/issue-2088-repro.test.ts | 33 +- packages/tui/test/render-stress-harness.ts | 341 +++++++++++++++++---- 6 files changed, 402 insertions(+), 128 deletions(-) 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 From 9b389d6006ff46a41be5437b98ccb3e0c156bbbc Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 13 Aug 2026 14:45:03 +0000 Subject: [PATCH 2/2] fix(tui): preserved direct herdr clears Scoped direct Herdr multiplexer behavior to resize repaint selection so explicit transcript replacements continue to clear and replay scrollback. Added coverage for resize stability and resetDisplay ED3 behavior. --- packages/tui/src/tui.ts | 28 +++++++++++++++++----- packages/tui/test/issue-2088-repro.test.ts | 20 ++++++++++++++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 3b2353912..55f91d547 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -413,9 +413,25 @@ function parseSizeValue(value: SizeValue | undefined, referenceSize: number): nu return undefined; } -/** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */ +/** + * 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 { - return isInsideTerminalMultiplexer(); + 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"), + ); } /** @@ -439,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(); } /** diff --git a/packages/tui/test/issue-2088-repro.test.ts b/packages/tui/test/issue-2088-repro.test.ts index 1126c7ef1..3604ccfd3 100644 --- a/packages/tui/test/issue-2088-repro.test.ts +++ b/packages/tui/test/issue-2088-repro.test.ts @@ -2564,6 +2564,26 @@ describe("multiplexer detection gates ED3 on resize", () => { }); }); + 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(); + } + }); + }); + it("keeps nested tmux inside HerdR on the ED3-unsafe path", async () => { await withEnvPatch({ ...TMUX_ENV, TERM: "tmux-256color", HERDR_ENV: "1" }, async () => { const term = new VirtualTerminal(40, 10, 1000);