From ef803e3880e8c0601de5cd991b38ffffee13bd32 Mon Sep 17 00:00:00 2001 From: Shawn Koh Date: Tue, 11 Aug 2026 17:34:22 +0800 Subject: [PATCH] fix(tui): rebuild direct HerdR scrollback from source --- docs/tui-core-renderer.md | 65 ++-- docs/tui-runtime-internals.md | 7 +- packages/coding-agent/CHANGELOG.md | 4 + .../modes/components/transcript-container.ts | 56 +++- .../components/transcript-container.test.ts | 47 +++ packages/tui/CHANGELOG.md | 2 +- packages/tui/src/components/markdown.ts | 48 ++- packages/tui/src/tui.ts | 279 ++++++++++++++---- packages/tui/test/issue-2088-repro.test.ts | 180 ++++++++++- packages/tui/test/render-stress-harness.ts | 19 +- .../tui/test/render-stress-oracles.test.ts | 4 +- 11 files changed, 594 insertions(+), 117 deletions(-) diff --git a/docs/tui-core-renderer.md b/docs/tui-core-renderer.md index 35cc3d7a4..3390137b6 100644 --- a/docs/tui-core-renderer.md +++ b/docs/tui-core-renderer.md @@ -66,16 +66,22 @@ 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 multiplexers, a width change terminates the physical-row coordinate - epoch. Pane history remains immutable at its authored wrap; the renderer - leaves the host-reflowed viewport in place and establishes a separate - complete-frame baseline without publishing those viewport rows as native - commits. Later frame growth writes the exact current-width rows newly crossing - the scrollback seam, followed by a bounded viewport repaint; only that real - content advances the native commit count. Visible overlays freeze the seam - ledger as well as emission. Pinned live regions clip epoch advancement at - their final boundary, keeping mutable growth viewport-only until finalization. - Height-only resizes retain the existing ledger. +- Inside ED3-unsafe 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 + render. Host-reflowed history stays immutable. Output queued during + settlement is emitted only from the resolved old boundary to the current + source boundary at the terminal-owned viewport bottom; the settled viewport + then repaints in place. No old-width and new-width row counts are compared, + 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. --- @@ -90,8 +96,8 @@ needs to know whether the user has scrolled away from the tail. rows in the last 24, SGR-stripped). A single in-place mismatch is accepted as stale history; a structural shift re-anchors at the first changed row, favoring duplication over content loss. An in-place width change does not - audit or re-slice the prior epoch's physical coordinate; it replaces that - opaque coordinate with a current-width final-prefix baseline. + audit or re-slice the prior epoch's physical coordinates; it resolves the + captured logical source marker in the settled-width frame. 3. Classify the frame as a gesture-driven full paint, an opt-in divergence rebuild, or an ordinary update and calculate the window/commit chunk. Overlays freeze commits. A pinned live region clips its offscreen mutable @@ -145,6 +151,14 @@ commits are prefix-only. `NativeScrollbackCommittedRows` lets containers pass the committed count down to children, and `NativeScrollbackReplay` lets components release layout locks before a destructive replay. +`NativeScrollbackWidthEpoch` is the cross-width source contract. Capture reads +only state that produced the last emitted frame. Resolve projects that source +boundary into the newly rendered width, while the current-boundary method +identifies the logical suffix queued during settlement. Containers propagate +the marker through nested sources; Markdown snapshots its last rendered source +text, so a streaming update received before `SIGWINCH` cannot masquerade as +already-emitted output. + `TranscriptContainer` implements the application seam. It scans for the first unfinalized transcript block. Finalized blocks before it are exact; that live block may extend the exact boundary through @@ -174,19 +188,20 @@ 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. **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. +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. 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 ac97e51af..f26c13c91 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: outside multiplexers the engine erases and replays (`ED3` + full paint) so history rewraps at the new geometry; the commit ledger restarts from the replayed frame. -- 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 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 its existing deadline. Visible overlays freeze both append emission and the seam ledger, allowing overlay exit to backfill the exact rows appended behind them. Pinned live regions clip epoch advancement at their final boundary, so mutable task/status growth remains viewport-only until finalization. If height shrink and content growth share a frame, only occupied old-frame rows moved by the host are excluded before calculating the append-owned seam; discarded empty viewport rows are not. -- 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 multiplexers and these terminals; overridable via `PI_TUI_RESIZE_IN_PLACE`) routes them through the in-place repaint, which never touches the alt buffer. +- 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. +- 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. ## Streaming and incremental UI updates diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b387e76fd..73196a4a0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -88,6 +88,10 @@ - Removed the `resolveAgentModelSource` model-resolver export, whose only use was being fed to `resolveExplicitModelRole`. Replaced by `resolveAgentModelSelection`, which returns the expanded `patterns` and the pre-expansion `role` together so a spawn path cannot derive one without the other ([#7910](https://github.com/can1357/oh-my-pi/pull/7910) by [@enieuwy](https://github.com/enieuwy)). - A run is now attributed to the model that actually produced its output, not whichever model the session was last pointed at. A retry fallback that errored on its first request — an exhausted quota, a hard provider error — was credited with the whole run in the Agent Hub row and the settled task result, even when the previous model did every turn. Sessions expose the serving model directly, holding the last model that produced output while a candidate is armed but unproven, and transcript-derived history stops at the newest turn that produced output. +### Fixed + +- Fixed terminal Mermaid borders and junctions using low-contrast UI chrome colors instead of the active theme's readable content color. +- Fixed streaming and finalized transcript blocks exposing width-independent source boundaries so multiplexer pane resizes retain output queued during settlement without duplicating prior transcript history. ## [17.2.12] - 2026-08-08 diff --git a/packages/coding-agent/src/modes/components/transcript-container.ts b/packages/coding-agent/src/modes/components/transcript-container.ts index 8e4902c07..38c195dd2 100644 --- a/packages/coding-agent/src/modes/components/transcript-container.ts +++ b/packages/coding-agent/src/modes/components/transcript-container.ts @@ -3,6 +3,7 @@ import { Container, type NativeScrollbackCommittedRows, type NativeScrollbackLiveRegion, + type NativeScrollbackWidthEpoch, type RenderStablePrefix, type ViewportTailProvider, } from "@oh-my-pi/pi-tui"; @@ -159,7 +160,12 @@ const EMPTY_TAIL: readonly string[] = []; */ export class TranscriptContainer extends Container - implements NativeScrollbackLiveRegion, NativeScrollbackCommittedRows, RenderStablePrefix, ViewportTailProvider + implements + NativeScrollbackLiveRegion, + NativeScrollbackCommittedRows, + NativeScrollbackWidthEpoch, + RenderStablePrefix, + ViewportTailProvider { #toolActivityVisible = true; // Bumped to retire every block segment at once (theme change / clear); a @@ -179,6 +185,11 @@ export class TranscriptContainer // Finalized blocks wholly before this boundary are immutable on-screen history; // their previous contribution can be replayed without calling render(). #committedRows = 0; + #widthEpochBoundaries = new WeakMap< + object, + { segment: BlockSegment; childBoundary: unknown; childHasBoundary: boolean } + >(); + // Stable-prefix floor accumulated across renders since the last // getRenderStablePrefixRows() read (see RenderStablePrefix: reading // consumes the report and re-bases the baseline). Out-of-band renders @@ -236,6 +247,49 @@ export class TranscriptContainer } } + override captureNativeScrollbackWidthEpoch(): unknown { + const segment = this.#segments.at(-1); + if (!segment) return undefined; + const child = segment.component as Component & Partial; + const childHasBoundary = + typeof child.captureNativeScrollbackWidthEpoch === "function" && + typeof child.resolveNativeScrollbackWidthEpoch === "function" && + typeof child.getNativeScrollbackWidthEpochRows === "function"; + const marker = {}; + this.#widthEpochBoundaries.set(marker, { + segment, + childBoundary: childHasBoundary ? child.captureNativeScrollbackWidthEpoch?.() : undefined, + childHasBoundary, + }); + return marker; + } + + override resolveNativeScrollbackWidthEpoch(boundary: unknown): number | undefined { + if (typeof boundary !== "object" || boundary === null) return undefined; + const marker = this.#widthEpochBoundaries.get(boundary); + if (!marker) return undefined; + const current = this.#segments.find(segment => segment.component === marker.segment.component); + if (!current) return undefined; + if (!marker.childHasBoundary) { + if (marker.segment.rowCount === 0 || !marker.segment.finalized) return current.startRow; + if (marker.segment.version !== current.version) return undefined; + return current.startRow + current.rowCount; + } + const child = current.component as Component & NativeScrollbackWidthEpoch; + const rawRows = child.resolveNativeScrollbackWidthEpoch(marker.childBoundary); + if (rawRows === undefined) return undefined; + let leadingTrimmedRows = 0; + while (leadingTrimmedRows < current.rawRef.length && isPlainBlank(current.rawRef[leadingTrimmedRows]!)) { + leadingTrimmedRows++; + } + const contributionRows = Math.max(0, Math.min(current.contribution.length, rawRows - leadingTrimmedRows)); + return current.startRow + current.sep + contributionRows; + } + + override getNativeScrollbackWidthEpochRows(): number | undefined { + return this.#segments === EMPTY_SEGMENTS ? undefined : this.#lines.length; + } + getRenderStablePrefixRows(): number { const value = Math.min(this.#stableRowsFloor, this.#lines.length); this.#stableRowsFloor = this.#lines.length; diff --git a/packages/coding-agent/test/modes/components/transcript-container.test.ts b/packages/coding-agent/test/modes/components/transcript-container.test.ts index 71b077979..72249a4c6 100644 --- a/packages/coding-agent/test/modes/components/transcript-container.test.ts +++ b/packages/coding-agent/test/modes/components/transcript-container.test.ts @@ -265,6 +265,53 @@ describe("TranscriptContainer", () => { expect(container.getNativeScrollbackLiveRegionStart()).toBeUndefined(); }); + it("resolves a finalized transcript tail at the settled width before appended blocks", () => { + const container = new TranscriptContainer(); + container.addChild(new Text("first block with enough words to wrap after the pane narrows", 0, 0)); + container.render(80); + const boundary = container.captureNativeScrollbackWidthEpoch(); + + container.addChild(new Text("new block queued during resize", 0, 0)); + container.render(24); + const previousRows = container.resolveNativeScrollbackWidthEpoch(boundary); + const currentRows = container.getNativeScrollbackWidthEpochRows(); + + expect(previousRows).toBeGreaterThan(0); + expect(currentRows).toBeGreaterThan(previousRows!); + }); + + it("maps a streaming Markdown source prefix without rendering the assistant twice", () => { + const container = new TranscriptContainer(); + const assistant = new AssistantMessageComponent(); + assistant.updateContent( + makeAssistantMessage({ + content: [{ type: "text", text: "A streaming answer with a stable source prefix." }], + }), + { transient: true }, + ); + container.addChild(assistant); + container.render(40); + const boundary = container.captureNativeScrollbackWidthEpoch(); + + assistant.updateContent( + makeAssistantMessage({ + content: [ + { + type: "text", + text: "A streaming answer with a stable source prefix. More output arrived while the pane resized.", + }, + ], + }), + { transient: true }, + ); + container.render(17); + const previousRows = container.resolveNativeScrollbackWidthEpoch(boundary); + const currentRows = container.getNativeScrollbackWidthEpochRows(); + + expect(previousRows).toBeGreaterThan(0); + expect(currentRows).toBeGreaterThan(previousRows!); + }); + it("starts the live region at the earliest of several unfinalized blocks", () => { const container = new TranscriptContainer(); const sealed = new StreamingBlock(["done"], true); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index d5375fa27..e4869d063 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -9,7 +9,7 @@ - Fixed inline images rendering permanently cropped on Kitty direct-placement terminals (WezTerm, Warp) when an image block straddled the viewport top during streaming: placements are now clipped to the visible slice at write time, and a placement id whose cells reached native scrollback is never re-used ([#8070](https://github.com/can1357/oh-my-pi/pull/8070) by [@voonfoo](https://github.com/voonfoo)) ### Fixed -- Fixed repeated multiplexer width changes corrupting native scrollback by treating old-width committed row coordinates as opaque, preserving the host-reflowed viewport, keeping continuous ordinary renders from starving resize settlement, freezing the append seam behind visible overlays, keeping pinned live-region growth viewport-only until finalization, separating host-owned height-shrink movement from appended output, parking no-cursor frames at the real content bottom, and advancing commits only for exact post-resize physical-row output ([#6147](https://github.com/can1357/oh-my-pi/issues/6147), [#7026](https://github.com/can1357/oh-my-pi/issues/7026)). +- Fixed repeated pane-width changes corrupting native scrollback: direct HerdR panes now use the same ED3 source replay as direct terminals, preventing settled viewport paints from hardening host-reflowed soft wraps, while ED3-unsafe multiplexers retain opaque logical width epochs that append only source output queued during resize ([#6147](https://github.com/can1357/oh-my-pi/issues/6147), [#7026](https://github.com/can1357/oh-my-pi/issues/7026)). ## [17.2.12] - 2026-08-08 diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 4bad33500..a0aa40baf 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -11,7 +11,12 @@ import { latexToBlock } from "../latex-block"; import { inlineMathSpanEnd, isBareMathEnvironment, latexToUnicode } from "../latex-to-unicode"; import type { SymbolTheme } from "../symbols"; import { TERMINAL } from "../terminal-capabilities"; -import type { Component, NativeScrollbackCommittedRows, NativeScrollbackReplay } from "../tui"; +import type { + Component, + NativeScrollbackCommittedRows, + NativeScrollbackReplay, + NativeScrollbackWidthEpoch, +} from "../tui"; import { applyBackgroundToLine, Ellipsis, @@ -1412,7 +1417,9 @@ interface RenderedTableLayout extends TableLayoutLock { endRow: number; } -export class Markdown implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay { +export class Markdown + implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay, NativeScrollbackWidthEpoch +{ #text: string; #paddingX: number; // Left/right padding #paddingY: number; // Top/bottom padding @@ -1450,6 +1457,11 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ // exposure to 0 and re-earns it — the exposure is hard-monotone within a // text lineage. #settledExposedText?: string; + // Semantic source state that produced the most recent render. Unlike #text, + // it does not advance when streaming updates arrive before the next paint. + #lastRenderedText?: string; + #widthEpochBoundaries = new WeakMap(); + // True while #renderStreamingContentLines renders the frozen token range: // frozen code blocks highlight even in transient mode so their bytes match // the finalized render (they render once into the prefix line cache, so @@ -1545,6 +1557,34 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ return this.#lastRenderSettledRows; } + captureNativeScrollbackWidthEpoch(): unknown { + if (this.#lastRenderedText === undefined) return undefined; + const marker = {}; + this.#widthEpochBoundaries.set(marker, this.#lastRenderedText); + return marker; + } + + resolveNativeScrollbackWidthEpoch(boundary: unknown): number | undefined { + if (typeof boundary !== "object" || boundary === null || this.#cachedWidth === undefined) return undefined; + const text = this.#widthEpochBoundaries.get(boundary); + if (text === undefined) return undefined; + const snapshot = new Markdown( + text, + this.#paddingX, + this.#paddingY, + this.#theme, + this.#defaultTextStyle, + this.#codeBlockIndent, + ); + snapshot.#ignoreTight = this.#ignoreTight; + snapshot.#transientRenderCache = this.#transientRenderCache; + return snapshot.render(this.#cachedWidth).length; + } + + getNativeScrollbackWidthEpochRows(): number | undefined { + return this.#cachedLines?.length; + } + /** * Freeze every table whose first physical row is already part of the native * scrollback prefix. The recorded widths came from the exact frame that was @@ -1644,6 +1684,7 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ // Returning the cached reference is load-bearing: parents memoize their // concatenation on reference equality. if (this.#cachedLines && this.#cachedText === this.#text && this.#cachedWidth === width) { + this.#lastRenderedText = this.#text; return this.#cachedLines; } @@ -1660,6 +1701,7 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ this.#cachedText = this.#text; this.#cachedWidth = width; this.#cachedLines = EMPTY_RENDER_LINES; + this.#lastRenderedText = this.#text; return EMPTY_RENDER_LINES; } @@ -1695,6 +1737,7 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ this.#cachedText = this.#text; this.#cachedWidth = width; this.#cachedLines = cached.lines; + this.#lastRenderedText = this.#text; return cached.lines; } } @@ -1738,6 +1781,7 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ })), }); } + this.#lastRenderedText = this.#text; return result; } diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 609cbd130..9f3ef1368 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -219,6 +219,18 @@ export interface NativeScrollbackCommittedRows { setNativeScrollbackCommittedRows(rows: number): void; } +/** + * Width-independent source boundary for multiplexer resize epochs. Capture + * reads the last rendered source state; resolve maps that same logical boundary + * into the most recent render's physical rows at its new width. The current + * boundary identifies the source tail after updates queued during the resize. + */ +export interface NativeScrollbackWidthEpoch { + captureNativeScrollbackWidthEpoch(): unknown; + resolveNativeScrollbackWidthEpoch(boundary: unknown): number | undefined; + getNativeScrollbackWidthEpochRows(): number | undefined; +} + /** * A component that discards rows after they enter native scrollback implements * this hook so a destructive full replay can rehydrate its complete frame. @@ -235,6 +247,15 @@ function setNativeScrollbackCommittedRows(component: Component, rows: number): v (component as Component & Partial).setNativeScrollbackCommittedRows?.(rows); } +function getNativeScrollbackWidthEpoch(component: Component): NativeScrollbackWidthEpoch | undefined { + const candidate = component as Component & Partial; + return candidate.captureNativeScrollbackWidthEpoch && + candidate.resolveNativeScrollbackWidthEpoch && + candidate.getNativeScrollbackWidthEpochRows + ? (candidate as NativeScrollbackWidthEpoch) + : undefined; +} + function isOverlayFocusTarget(owner: Component, component: Component | null): boolean { if (component === owner) return true; if (!component) return false; @@ -381,9 +402,27 @@ 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. 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. + */ 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"), + ); } /** @@ -486,7 +525,9 @@ export interface OverlayHandle { /** * Container - a component that contains other components */ -export class Container implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay { +export class Container + implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay, NativeScrollbackWidthEpoch +{ children: Component[] = []; // Memoized concatenation of the children's latest renders. Children are @@ -499,6 +540,10 @@ export class Container implements Component, NativeScrollbackCommittedRows, Nati #memoLines: string[] | undefined; #memoChildLines: (readonly string[])[] = []; #memoWidth = -1; + // Child identities matching #memoChildLines. Kept separately because callers + // may append children after the last emitted render but before SIGWINCH. + #memoChildren: Component[] = []; + #widthEpochBoundaries = new WeakMap(); #ignoreTight = false; @@ -584,6 +629,50 @@ export class Container implements Component, NativeScrollbackCommittedRows, Nati for (const child of this.children) prepareNativeScrollbackReplay(child); } + captureNativeScrollbackWidthEpoch(): unknown { + const refs = this.#memoChildLines; + const children = this.#memoChildren; + if (this.#memoLines === undefined || refs.length !== children.length) return undefined; + for (let index = children.length - 1; index >= 0; index--) { + const component = children[index]!; + const source = getNativeScrollbackWidthEpoch(component); + const childBoundary = source?.captureNativeScrollbackWidthEpoch(); + if (childBoundary === undefined) continue; + const marker = {}; + this.#widthEpochBoundaries.set(marker, { component, childBoundary }); + return marker; + } + return undefined; + } + + resolveNativeScrollbackWidthEpoch(boundary: unknown): number | undefined { + if (typeof boundary !== "object" || boundary === null) return undefined; + const marker = this.#widthEpochBoundaries.get(boundary); + if (!marker) return undefined; + const index = this.#memoChildren.indexOf(marker.component); + if (index < 0 || this.#memoLines === undefined || this.#memoChildLines.length !== this.#memoChildren.length) { + return undefined; + } + const childRows = getNativeScrollbackWidthEpoch(marker.component)?.resolveNativeScrollbackWidthEpoch( + marker.childBoundary, + ); + if (childRows === undefined) return undefined; + let rows = childRows; + for (let i = 0; i < index; i++) rows += this.#memoChildLines[i]!.length; + return rows; + } + + getNativeScrollbackWidthEpochRows(): number | undefined { + if (this.#memoLines === undefined || this.#memoChildLines.length !== this.#memoChildren.length) return undefined; + let offset = this.#memoLines.length; + for (let index = this.#memoChildren.length - 1; index >= 0; index--) { + offset -= this.#memoChildLines[index]!.length; + const rows = getNativeScrollbackWidthEpoch(this.#memoChildren[index]!)?.getNativeScrollbackWidthEpochRows(); + if (rows !== undefined) return offset + rows; + } + return undefined; + } + render(width: number): readonly string[] { width = Math.max(1, width); const children = this.children; @@ -601,6 +690,7 @@ export class Container implements Component, NativeScrollbackCommittedRows, Nati refs[i] = childLines; } } + this.#memoChildren = children.slice(); this.#memoWidth = width; if (unchanged) return this.#memoLines!; const lines: string[] = []; @@ -1045,6 +1135,11 @@ export class TUI extends Container { // index in an in-place resize session. This is the current-width frame // baseline; only later physical-row growth may advance the append ledger. #widthEpochBaselineRows: number | undefined; + // Logical source boundary captured from the last emitted frame at the first + // SIGWINCH in a multiplexer resize burst. Unlike physical row counts, the + // opaque marker survives width reflow and resolves after the settled render. + #multiplexerWidthEpochBoundary: unknown; + // Frame row currently mapped to screen row 0. Monotonic between full // paints: a shrink never re-exposes scrolled-off rows (they cannot be // un-scrolled without rewriting history); live rows repaint at fixed @@ -1141,6 +1236,8 @@ export class TUI extends Container { // Per-root-child segment ledger backing the stable-prefix computation. #frameSegments: FrameSegment[] = []; #composeWidth = -1; + #rootWidthEpochBoundaries = new WeakMap(); + // Cursor markers stripped at ingestion, ascending by frame row. #frameCursorMarkers: { row: number; col: number }[] = []; // Leading rows of #composedFrame byte-identical to the previous compose. @@ -1190,6 +1287,40 @@ export class TUI extends Container { this.#watchdog = new LoopWatchdog(); } + override captureNativeScrollbackWidthEpoch(): unknown { + for (let index = this.#frameSegments.length - 1; index >= 0; index--) { + const segment = this.#frameSegments[index]!; + const source = getNativeScrollbackWidthEpoch(segment.component); + const childBoundary = source?.captureNativeScrollbackWidthEpoch(); + if (childBoundary === undefined) continue; + const marker = {}; + this.#rootWidthEpochBoundaries.set(marker, { component: segment.component, childBoundary }); + return marker; + } + return undefined; + } + + override resolveNativeScrollbackWidthEpoch(boundary: unknown): number | undefined { + if (typeof boundary !== "object" || boundary === null) return undefined; + const marker = this.#rootWidthEpochBoundaries.get(boundary); + if (!marker) return undefined; + const segment = this.#frameSegments.find(candidate => candidate.component === marker.component); + if (!segment) return undefined; + const childRows = getNativeScrollbackWidthEpoch(marker.component)?.resolveNativeScrollbackWidthEpoch( + marker.childBoundary, + ); + return childRows === undefined ? undefined : segment.start + childRows; + } + + override getNativeScrollbackWidthEpochRows(): number | undefined { + for (let index = this.#frameSegments.length - 1; index >= 0; index--) { + const segment = this.#frameSegments[index]!; + const rows = getNativeScrollbackWidthEpoch(segment.component)?.getNativeScrollbackWidthEpochRows(); + if (rows !== undefined) return segment.start + rows; + } + return undefined; + } + override render(width: number): readonly string[] { width = Math.max(1, width); this.#nativeScrollbackLiveRegionStart = undefined; @@ -1651,6 +1782,9 @@ export class TUI extends Container { this.#requestResizeViewportPaint(); return; } + if (this.#multiplexerWidthEpochBoundary === undefined) { + this.#multiplexerWidthEpochBoundary = this.captureNativeScrollbackWidthEpoch(); + } this.#armMultiplexerResizeTimer({ clearScrollback: false, hasPendingRender: @@ -3055,6 +3189,11 @@ export class TUI extends Container { this.#multiplexerResizeHasPendingRender = false; if (resizeEventOccurred) this.#forgetHardwareCursorState(); const widthChanged = this.#previousWidth > 0 && this.#previousWidth !== width; + const widthEpochSourceBoundary = widthChanged + ? this.resolveNativeScrollbackWidthEpoch(this.#multiplexerWidthEpochBoundary) + : undefined; + const widthEpochCurrentRows = widthChanged ? this.getNativeScrollbackWidthEpochRows() : undefined; + if (resizeEventOccurred) this.#multiplexerWidthEpochBoundary = undefined; // A resize event with net-unchanged dimensions still reflowed the // terminal buffer; classify it as a height change so geometry handling // repaints instead of diffing against a screen that no longer exists. @@ -3176,20 +3315,23 @@ export class TUI extends Container { chunkTo = liveRegionPinned ? Math.min(windowTop, finalBoundary) : windowTop; } else if (widthEpochReset) { // A terminal width change ends the physical-row coordinate epoch. - // Multiplexer history keeps its old wrap, so old committed rows are - // opaque. Preserve the native commit ledger and establish a separate - // current-width baseline. Content queued during the settle window did - // not reach the terminal: retain its growth relative to the last - // emitted frame so the baseline emitter moves every displaced row into - // history before repainting the current viewport. - this.#widthEpochBaselineRows = resizeHadPendingRender - ? Math.min(frameLength, this.#previousFrameLength) - : frameLength; + // Resolve the last emitted logical source boundary at the new width; + // updates queued during debounce are the current-boundary suffix. + // Components without the source contract retain the conservative + // legacy fallback, but never compare cross-width counts when a marker + // resolved successfully. + this.#widthEpochBaselineRows = + widthEpochSourceBoundary ?? + (resizeHadPendingRender ? Math.min(frameLength, this.#previousFrameLength) : frameLength); windowTop = Math.max(0, frameLength - height); chunkTo = this.#committedRows; widthEpochAppendFrom = this.#widthEpochBaselineRows; - const appendBoundary = liveRegionPinned ? finalBoundary : frameLength; - widthEpochAppendTo = hasVisibleOverlay ? widthEpochAppendFrom : Math.max(widthEpochAppendFrom, appendBoundary); + widthEpochAppendTo = + hasVisibleOverlay || widthEpochCurrentRows === undefined + ? hasVisibleOverlay + ? widthEpochAppendFrom + : Math.max(widthEpochAppendFrom, liveRegionPinned ? finalBoundary : frameLength) + : Math.max(widthEpochAppendFrom, widthEpochCurrentRows); } else if (this.#widthEpochBaselineRows !== undefined) { // Only rows physically appended after the width epoch may drive the // terminal forward. Keep the native commit count independent of this @@ -3344,23 +3486,32 @@ export class TUI extends Container { return; } if (this.#widthEpochBaselineRows !== undefined) { - const windowMovement = Math.max(0, windowTop - prevWindowTop); - // A height shrink moves only occupied rows past the old seam. Empty - // viewport rows disappear without entering pane history, so the raw - // height delta would steal append-owned movement from a sparse frame. - const previousViewportRows = Math.min( - this.#previousHeight, - Math.max(0, this.#previousFrameLength - prevWindowTop), - ); - const hostHeightShrinkRows = Math.min(windowMovement, Math.max(0, previousViewportRows - height)); - const appendWindowMovement = windowMovement - hostHeightShrinkRows; - const epochGrowthRows = Math.max(0, widthEpochAppendTo - widthEpochAppendFrom); - const scrollRows = Math.min(appendWindowMovement, epochGrowthRows); - const commitFrom = prevWindowTop + hostHeightShrinkRows; + const logicalAppend = widthEpochSourceBoundary !== undefined && widthEpochCurrentRows !== undefined; + let scrollRows: number; + let commitFrom: number; + let commitTo: number; + if (logicalAppend) { + commitFrom = widthEpochSourceBoundary; + commitTo = Math.max(commitFrom, widthEpochCurrentRows); + scrollRows = commitTo - commitFrom; + } else { + const windowMovement = Math.max(0, windowTop - prevWindowTop); + const previousViewportRows = Math.min( + this.#previousHeight, + Math.max(0, this.#previousFrameLength - prevWindowTop), + ); + const hostHeightShrinkRows = Math.min(windowMovement, Math.max(0, previousViewportRows - height)); + const appendWindowMovement = windowMovement - hostHeightShrinkRows; + const epochGrowthRows = Math.max(0, widthEpochAppendTo - widthEpochAppendFrom); + scrollRows = Math.min(appendWindowMovement, epochGrowthRows); + commitFrom = prevWindowTop + hostHeightShrinkRows; + commitTo = commitFrom + scrollRows; + } this.#emitWidthEpochBaseline(frame, window, width, height, cursorPos, purgeSequence, imageTransmitBuffer, { repaintFromScreenRow: 0, commitFrom, - commitTo: commitFrom + scrollRows, + commitTo, + appendOnly: logicalAppend, windowTop, cursorTrackingLineCount, leadingSequence: deferredAltExit, @@ -3369,7 +3520,7 @@ export class TUI extends Container { if (!hasVisibleOverlay) { if (liveRegionPinned) { this.#widthEpochBaselineRows = widthEpochAppendTo; - this.#windowTopRow = prevWindowTop + scrollRows; + this.#windowTopRow = logicalAppend ? windowTop : prevWindowTop + scrollRows; } else { this.#widthEpochBaselineRows = frameLength; this.#windowTopRow = windowTop; @@ -3379,21 +3530,20 @@ export class TUI extends Container { this.#clearScrollbackOnNextRender = false; this.#hasEverRendered = true; return; - } else { - if (imageTransmitBuffer.length > 0) { - this.terminal.write(imageTransmitBuffer); - } - this.#emitUpdate(frame, window, width, height, cursorPos, purgeSequence, { - chunkTo, - windowTop, - prevWindowTop, - prevHardwareCursorRow, - forceWindowRewrite: - this.#forceViewportRepaintOnNextRender || (geometryChanged && this.#resizeRepaintsInPlace()), - repaintVirtualScrollInPlace: hasVisibleOverlay, - cursorTrackingLineCount, - }); } + if (imageTransmitBuffer.length > 0) { + this.terminal.write(imageTransmitBuffer); + } + this.#emitUpdate(frame, window, width, height, cursorPos, purgeSequence, { + chunkTo, + windowTop, + prevWindowTop, + prevHardwareCursorRow, + forceWindowRewrite: + this.#forceViewportRepaintOnNextRender || (geometryChanged && this.#resizeRepaintsInPlace()), + repaintVirtualScrollInPlace: hasVisibleOverlay, + cursorTrackingLineCount, + }); for (let i = this.#committedPrefix.length; i < chunkTo; i++) { this.#committedPrefix.push(rawFrame[i] ?? ""); } @@ -3765,6 +3915,7 @@ export class TUI extends Container { repaintFromScreenRow: number; commitFrom: number; commitTo: number; + appendOnly: boolean; windowTop: number; cursorTrackingLineCount: number; leadingSequence: string; @@ -3773,20 +3924,29 @@ export class TUI extends Container { this.#fullRedrawCount += 1; let buffer = this.#paintBeginSequence + purgeSequence + options.leadingSequence + imageTransmitBuffer; if (options.commitTo > options.commitFrom) { - // Rebuild the current-width seam with real rows. Writing the exact - // newly offscreen slice followed by the viewport makes only that - // slice enter native history; mutable editor/live rows stay on-grid. - buffer += "\x1b[1;1H"; - let wroteLine = false; - for (let row = options.commitFrom; row < options.commitTo; row++) { - if (wroteLine) buffer += "\r\n"; - buffer += this.#lineRewriteSequence(frame[row] ?? "", width); - wroteLine = true; - } - for (let screenRow = 0; screenRow < height; screenRow++) { - if (wroteLine) buffer += "\r\n"; - buffer += this.#lineRewriteSequence(window[screenRow] ?? "", width); - wroteLine = true; + if (options.appendOnly) { + buffer += `\x1b[${height};1H`; + for (let row = options.commitFrom; row < options.commitTo; row++) { + buffer += "\r\n"; + buffer += this.#lineRewriteSequence(frame[row] ?? "", width); + } + for (let screenRow = 0; screenRow < height; screenRow++) { + buffer += `\x1b[${screenRow + 1};1H`; + buffer += this.#lineRewriteSequence(window[screenRow] ?? "", width); + } + } else { + buffer += "\x1b[1;1H"; + let wroteLine = false; + for (let row = options.commitFrom; row < options.commitTo; row++) { + if (wroteLine) buffer += "\r\n"; + buffer += this.#lineRewriteSequence(frame[row] ?? "", width); + wroteLine = true; + } + for (let screenRow = 0; screenRow < height; screenRow++) { + if (wroteLine) buffer += "\r\n"; + buffer += this.#lineRewriteSequence(window[screenRow] ?? "", width); + wroteLine = true; + } } } else { for (let screenRow = options.repaintFromScreenRow; screenRow < height; screenRow++) { @@ -3794,9 +3954,6 @@ export class TUI extends Container { buffer += this.#lineRewriteSequence(window[screenRow] ?? "", width); } } - // An exact-width bottom row leaves DEC autowrap pending. Clear it before - // the cursor CUP so a resize repaint can never turn that pending wrap - // into an unaccounted native-history scroll. buffer += "\r"; const contentRows = Math.max(1, Math.min(height, frame.length - options.windowTop)); const contentBottomRow = options.windowTop + contentRows - 1; @@ -3806,8 +3963,6 @@ export class TUI extends Container { buffer += `\x1b[${screenRow + 1};${target.col + 1}H`; buffer += target.visible ? "\x1b[?25h" : "\x1b[?25l"; } else { - // Keep direct-write deltas anchored to the real content bottom. - // Viewport padding is not content and must not become cursor state. buffer += `\x1b[${contentRows};1H\x1b[?25l`; } buffer += this.#paintEndSequence; diff --git a/packages/tui/test/issue-2088-repro.test.ts b/packages/tui/test/issue-2088-repro.test.ts index c84d88955..5a33175ef 100644 --- a/packages/tui/test/issue-2088-repro.test.ts +++ b/packages/tui/test/issue-2088-repro.test.ts @@ -3,6 +3,7 @@ import { type Component, type NativeScrollbackCommittedRows, type NativeScrollbackLiveRegion, + type NativeScrollbackWidthEpoch, type RenderScheduler, type RenderTimer, TUI, @@ -80,10 +81,15 @@ class CommittedMutableLinesComponent implements Component, NativeScrollbackCommi } } -class WrappingStreamComponent implements Component, NativeScrollbackLiveRegion { +class WrappingStreamComponent implements Component, NativeScrollbackLiveRegion, NativeScrollbackWidthEpoch { #records: string[] = []; #stream = ""; #liveStart = 0; + #lastRenderedRecords: string[] = []; + #lastRenderedStream = ""; + #lastWidth = 0; + #lastRows: string[] = []; + #widthEpochBoundaries = new WeakMap(); append(record: string): void { this.#records.push(record); @@ -107,9 +113,35 @@ class WrappingStreamComponent implements Component, NativeScrollbackLiveRegion { rows.push(this.#stream.slice(offset, offset + chunkWidth)); } rows.push(""); + this.#lastRenderedRecords = this.#records.slice(); + this.#lastRenderedStream = this.#stream; + this.#lastWidth = chunkWidth; + this.#lastRows = rows; return rows; } + captureNativeScrollbackWidthEpoch(): unknown { + const marker = {}; + this.#widthEpochBoundaries.set(marker, { + records: this.#lastRenderedRecords.slice(), + stream: this.#lastRenderedStream, + }); + return marker; + } + + resolveNativeScrollbackWidthEpoch(boundary: unknown): number | undefined { + if (typeof boundary !== "object" || boundary === null || this.#lastWidth <= 0) return undefined; + const source = this.#widthEpochBoundaries.get(boundary); + if (!source) return undefined; + let rows = 0; + for (const record of source.records) rows += Math.ceil(record.length / this.#lastWidth) + 1; + return rows + Math.ceil(source.stream.length / this.#lastWidth); + } + + getNativeScrollbackWidthEpochRows(): number | undefined { + return Math.max(0, this.#lastRows.length - 1); + } + getNativeScrollbackLiveRegionStart(): number | undefined { return this.#liveStart; } @@ -269,7 +301,6 @@ const TMUX_ENV: Record = { ...NO_MULTIPLEXER_ENV, TM 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" }], - ["HERDR_ENV", { ...NO_MULTIPLEXER_ENV, TERM: "dumb", HERDR_ENV: "1" }], ]; const CMUX_SOCKET_ONLY_ENV: Record = { ...NO_MULTIPLEXER_ENV, @@ -634,8 +665,8 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => { }); }); - it("freezes committed coordinates across repeated Herdr width epochs", async () => { - await withEnvPatch({ ...NO_MULTIPLEXER_ENV, TERM: "dumb", HERDR_ENV: "1" }, async () => { + it("freezes committed coordinates across repeated multiplexer width epochs", async () => { + await withEnvPatch(TMUX_ENV, async () => { const term = new VirtualTerminal(40, 6, 10_000); const tui = new TUI(term); const component = new WrappingStreamComponent(); @@ -718,8 +749,82 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => { }); }); + it("maps a queued append through the settled width using its logical boundary", async () => { + await withEnvPatch(TMUX_ENV, async () => { + const term = new VirtualTerminal(40, 6, 10_000); + const tui = new TUI(term); + const component = new WrappingStreamComponent(); + for (let index = 0; index < 8; index++) { + component.append(`initial-${index.toString().padStart(2, "0")} ${"I".repeat(46)}`); + } + tui.addChild(component); + + try { + tui.start(); + await settle(term); + component.append(`queued-00 ${"Q".repeat(46)}`); + component.append(`queued-01 ${"R".repeat(46)}`); + tui.requestRender(); + term.resize(17, 6); + await Bun.sleep(DEBOUNCE_SETTLE_WAIT_MS); + await settle(term); + + const buffer = term.getScrollBuffer().map(line => line.trimEnd()); + for (const marker of [ + ...Array.from({ length: 8 }, (_value, index) => `initial-${index.toString().padStart(2, "0")}`), + "queued-00", + "queued-01", + ]) { + expect( + buffer.filter(line => line.includes(marker)), + marker, + ).toHaveLength(1); + } + } finally { + tui.stop(); + } + }); + }); + + it("retains forced output appended after SIGWINCH without cross-width row arithmetic", async () => { + await withEnvPatch(TMUX_ENV, async () => { + const term = new VirtualTerminal(40, 6, 10_000); + const tui = new TUI(term); + const component = new WrappingStreamComponent(); + for (let index = 0; index < 8; index++) { + component.append(`forced-initial-${index.toString().padStart(2, "0")} ${"I".repeat(46)}`); + } + tui.addChild(component); + + try { + tui.start(); + await settle(term); + term.resize(17, 6); + component.append(`forced-final-00 ${"F".repeat(46)}`); + component.append(`forced-final-01 ${"G".repeat(46)}`); + tui.requestRender(true); + await Bun.sleep(DEBOUNCE_SETTLE_WAIT_MS); + await settle(term); + + const buffer = term.getScrollBuffer().map(line => line.trimEnd()); + for (const marker of [ + ...Array.from({ length: 8 }, (_value, index) => `forced-initial-${index.toString().padStart(2, "0")}`), + "forced-final-00", + "forced-final-01", + ]) { + expect( + buffer.filter(line => line.includes(marker)), + marker, + ).toHaveLength(1); + } + } finally { + tui.stop(); + } + }); + }); + it("keeps the native commit count separate and retains bulk post-epoch output", async () => { - await withEnvPatch({ ...NO_MULTIPLEXER_ENV, TERM: "dumb", HERDR_ENV: "1" }, async () => { + await withEnvPatch(TMUX_ENV, async () => { const initial = Array.from({ length: 20 }, (_value, index) => `initial-${index.toString().padStart(2, "0")}`); const appended = Array.from( { length: 20 }, @@ -773,7 +878,7 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => { }); it("separates height-shrink movement from post-epoch append movement", async () => { - await withEnvPatch({ ...NO_MULTIPLEXER_ENV, TERM: "dumb", HERDR_ENV: "1" }, async () => { + await withEnvPatch(TMUX_ENV, async () => { const initial = Array.from({ length: 100 }, (_value, index) => `mixed-${index.toString().padStart(3, "0")}`); const appended = Array.from( { length: 5 }, @@ -812,7 +917,7 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => { }); it("does not attribute sparse-frame append movement to a height shrink", async () => { - await withEnvPatch({ ...NO_MULTIPLEXER_ENV, TERM: "dumb", HERDR_ENV: "1" }, async () => { + await withEnvPatch(TMUX_ENV, async () => { const initial = ["sparse-0", "sparse-1", "sparse-2"]; const appended = ["sparse-3", "sparse-4", "sparse-5", "sparse-6", "sparse-7"]; const term = new VirtualTerminal(40, 10, 10_000); @@ -849,7 +954,7 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => { }); it("backfills post-epoch rows appended behind an overlay", async () => { - await withEnvPatch({ ...NO_MULTIPLEXER_ENV, TERM: "dumb", HERDR_ENV: "1" }, async () => { + await withEnvPatch(TMUX_ENV, async () => { const initial = Array.from({ length: 12 }, (_value, index) => `initial-${index.toString().padStart(2, "0")}`); const appended = Array.from({ length: 12 }, (_value, index) => `hidden-${index.toString().padStart(2, "0")}`); const term = new VirtualTerminal(40, 6, 10_000); @@ -894,7 +999,7 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => { }); it("defers pinned live-region growth until width-epoch finalization", async () => { - await withEnvPatch({ ...NO_MULTIPLEXER_ENV, TERM: "dumb", HERDR_ENV: "1" }, async () => { + await withEnvPatch(TMUX_ENV, async () => { const initial = ["pinned-00", "pinned-01"]; const final = Array.from({ length: 10 }, (_value, index) => `pinned-${index.toString().padStart(2, "0")}`); const term = new VirtualTerminal(40, 4, 1000); @@ -933,7 +1038,7 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => { }); it("parks a short no-cursor width epoch at the real content bottom", async () => { - await withEnvPatch({ ...NO_MULTIPLEXER_ENV, TERM: "dumb", HERDR_ENV: "1" }, async () => { + await withEnvPatch(TMUX_ENV, async () => { const term = new VirtualTerminal(40, 6, 1000); const header = new MutableLinesComponent(["short-0", "short-1"]); const loader = new MutableLinesComponent(["loader-0"]); @@ -970,8 +1075,8 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => { }); }); - it("keeps Herdr height-only resize accounting unchanged", async () => { - await withEnvPatch({ ...NO_MULTIPLEXER_ENV, TERM: "dumb", HERDR_ENV: "1" }, async () => { + it("keeps multiplexer height-only resize accounting unchanged", async () => { + await withEnvPatch(TMUX_ENV, async () => { const term = new VirtualTerminal(40, 6, 10_000); const tui = new TUI(term); const lines = Array.from( @@ -1119,6 +1224,57 @@ describe("multiplexer detection gates ED3 on resize", () => { }); } + it("rebuilds direct HerdR scrollback from source across repeated widths", 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); + + for (const width of [80, 40, 80]) { + term.resize(width, 10); + await settleResize(term); + expect(visible(term)).toEqual(Array.from({ length: 10 }, (_value, index) => `line-${index + 10}`)); + const buffer = term.getScrollBuffer().map(line => line.trimEnd()); + for (let index = 0; index < 20; index++) { + expect( + buffer.filter(line => line === `line-${index}`), + `line-${index}`, + ).toHaveLength(1); + } + } + + 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); + 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); + term.resize(80, 10); + await Bun.sleep(DEBOUNCE_SETTLE_WAIT_MS); + await settle(term); + expect(writes.join("")).not.toContain(ED3); + } finally { + tui.stop(); + } + }); + }); + it("does not treat CMUX_SOCKET_PATH alone as a multiplexer session marker", async () => { await withEnvPatch(CMUX_SOCKET_ONLY_ENV, async () => { const term = new VirtualTerminal(40, 10, 1000); diff --git a/packages/tui/test/render-stress-harness.ts b/packages/tui/test/render-stress-harness.ts index f3564064b..e875f020b 100644 --- a/packages/tui/test/render-stress-harness.ts +++ b/packages/tui/test/render-stress-harness.ts @@ -571,7 +571,7 @@ function assertNever(value: never): never { function terminalStressTraits(scenario: Scenario): TerminalStressTraits { return { - preservesPaneHistory: scenario.envMode === "tmux" || scenario.envMode === "herdr", + preservesPaneHistory: scenario.envMode === "tmux", strictNativeScrollback: scenario.strictScrollback, syncOutputDisabled: scenario.envMode === "vteNoSync", viewportProbe: scenario.terminalMode === "normal" ? "known" : scenario.terminalMode, @@ -3726,10 +3726,9 @@ function coreTemplates(): ScenarioTemplate[] { heightChoices: [3, 4, 6], }, { - // True multiplexer width epochs preserve canonical pane history at - // its authored wrap. The renderer may repaint the viewport, but a - // width change itself must not advance the append-only shadow tape; - // only later finalized streaming output may do so. + // 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. name: "darwin-normal-herdr-reflow-stream-small", platform: "darwin", terminalMode: "normal", @@ -4112,8 +4111,9 @@ export async function runStressScenario(scenario: Scenario, options?: { patchEnv } export async function runWidthEpochOverlayReplayRegression(): Promise { - const template = coreTemplates().find(candidate => candidate.name === "darwin-normal-herdr-reflow-stream-small"); - if (template === undefined) throw new Error("Missing HerdR width-epoch stress template"); + 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 operations: readonly OperationKind[] = ["resizeWidth", "showOverlay", "streamOne", "streamOne", "hideOverlay"]; const scenario = materializeScenario( template, @@ -4128,8 +4128,9 @@ export async function runWidthEpochOverlayReplayRegression(): Promise { } export async function runWidthEpochHeightAppendReplayRegression(): Promise { - const base = coreTemplates().find(candidate => candidate.name === "darwin-normal-herdr-reflow-stream-small"); - if (base === undefined) throw new Error("Missing HerdR width-epoch stress template"); + 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 template: ScenarioTemplate = { ...base, columns: 40, diff --git a/packages/tui/test/render-stress-oracles.test.ts b/packages/tui/test/render-stress-oracles.test.ts index 148083f94..65e4c06cd 100644 --- a/packages/tui/test/render-stress-oracles.test.ts +++ b/packages/tui/test/render-stress-oracles.test.ts @@ -61,11 +61,11 @@ describe("render stress oracle helpers", () => { expect(stripPlainTerminalText(compositeExpectedLineAt("abcdef", "XY", 2, 2, 6))).toBe("abXYef"); }); - it("replays overlay-hidden growth across a HerdR width epoch", async () => { + it("replays overlay-hidden growth across a multiplexer width epoch", async () => { await runWidthEpochOverlayReplayRegression(); }); - it("replays append growth concurrent with a height shrink inside a HerdR width epoch", async () => { + it("replays append growth concurrent with a height shrink inside a multiplexer width epoch", async () => { await runWidthEpochHeightAppendReplayRegression(); }); });