fix(tui): rebuild direct HerdR scrollback from source

This commit is contained in:
Shawn Koh
2026-08-11 17:34:22 +08:00
parent ee527f2d69
commit ef803e3880
11 changed files with 594 additions and 117 deletions
+40 -25
View File
@@ -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.
+4 -3
View File
@@ -162,9 +162,10 @@ Resize events are event-driven from `ProcessTerminal` to `TUI.requestRender()`.
Effects:
- A resize is an explicit user gesture: 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
+4
View File
@@ -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
@@ -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<NativeScrollbackWidthEpoch>;
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;
@@ -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);
+1 -1
View File
@@ -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
+46 -2
View File
@@ -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<object, string>();
// 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;
}
+217 -62
View File
@@ -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<NativeScrollbackCommittedRows>).setNativeScrollbackCommittedRows?.(rows);
}
function getNativeScrollbackWidthEpoch(component: Component): NativeScrollbackWidthEpoch | undefined {
const candidate = component as Component & Partial<NativeScrollbackWidthEpoch>;
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<object, { component: Component; childBoundary: unknown }>();
#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<object, { component: Component; childBoundary: unknown }>();
// 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;
+168 -12
View File
@@ -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<object, { records: string[]; stream: string }>();
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<string, string | undefined> = { ...NO_MULTIPLEXER_ENV, TM
const MULTIPLEXER_ENV_CASES: Array<[string, Record<string, string | undefined>]> = [
["CMUX_WORKSPACE_ID", { ...NO_MULTIPLEXER_ENV, TERM: "dumb", CMUX_WORKSPACE_ID: "workspace:cmux-2088" }],
["CMUX_SURFACE_ID", { ...NO_MULTIPLEXER_ENV, TERM: "dumb", CMUX_SURFACE_ID: "surface:cmux-2088" }],
["HERDR_ENV", { ...NO_MULTIPLEXER_ENV, TERM: "dumb", HERDR_ENV: "1" }],
];
const CMUX_SOCKET_ONLY_ENV: Record<string, string | undefined> = {
...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);
+10 -9
View File
@@ -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<void> {
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<void> {
}
export async function runWidthEpochHeightAppendReplayRegression(): Promise<void> {
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,
@@ -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();
});
});