feat(tui): added viewport-only transcript tail rendering and deferred resize repaint
- Added tail-only transcript rendering via `renderViewportTail`, stopping at `maxRows` and returning `EMPTY_TAIL`. - Added `ViewportTailProvider`/`asViewportTailProvider` API and resize getters for viewport state. - Implemented viewport-only painting during non-mux resize with deferred full repaint after settle. - Added settle-resize helpers and coverage for deferred repaint timing and cancel paths.
This commit is contained in:
@@ -1,21 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Changed
|
||||
|
||||
- Terminal resize now repaints only the viewport while a drag is in flight and defers the full transcript replay until the drag settles. Outside a multiplexer, every SIGWINCH used to erase and replay the entire transcript at the new width — re-laying-out (and, for markdown, re-lexing) all of history on each event, work thrown away the instant the next event arrived and re-done dozens of times a second during a drag. The TUI now composes and paints only the visible tail mid-drag — a new `ViewportTailProvider` fast path that the transcript implements by rendering blocks bottom-up and skipping everything above the fold, touching no commit/scrollback state — then runs the single authoritative rewrap + native-scrollback rebuild ~120 ms after the last resize event.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Updated `docs/models.md` and `docs/providers.md` to reference the `omp models` subcommand (and `omp models canonical` / `omp models find`) instead of the removed top-level `--list-models` flag ([#2458](https://github.com/can1357/oh-my-pi/issues/2458))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed unknown `--`-prefixed flags being silently consumed as prompt text, which let a stale or typoed flag start a real agent session (connecting to MCP servers, waiting on the model) instead of failing fast. `parseArgs` now tracks unrecognized flag-shaped tokens and `runRootCommand` calls `reportUnrecognizedFlags` immediately after the post-extension reparse, exiting `2` with `Error: unknown flag: --…` before any session, MCP, or initial-message work runs. Extension-registered flags still pass cleanly since the validation runs after the extension-aware reparse ([#2459](https://github.com/can1357/oh-my-pi/issues/2459)).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed prompt templates discovered from `cwd/.omp/prompts/` and the agent prompts directory not appearing in the slash-command autocomplete picker. `InteractiveMode.refreshSlashCommandState()` now feeds `session.promptTemplates` into the autocomplete provider alongside builtins and file-based slash commands; templates whose names collide with an existing command are skipped to mirror the runtime expansion order (`expandSlashCommand` precedes `expandPromptTemplate`) ([#2462](https://github.com/can1357/oh-my-pi/issues/2462)).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed empty-Enter steering interrupts leaving the newest queued steer visible after aborting an auto-continued queued turn; queued turns now re-drain after every abort/settle cycle.
|
||||
- Added an agent-visible notice when the write tool auto-marks shebang files executable, so agents do not redundantly run `chmod +x`.
|
||||
|
||||
@@ -10371,4 +10365,4 @@ Initial public release.
|
||||
- Git branch display in footer
|
||||
- Message queueing during streaming responses
|
||||
- OAuth integration for Gmail and Google Calendar access
|
||||
- HTML export with syntax highlighting and collapsible sections
|
||||
- HTML export with syntax highlighting and collapsible sections
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type NativeScrollbackCommittedRows,
|
||||
type NativeScrollbackLiveRegion,
|
||||
type RenderStablePrefix,
|
||||
type ViewportTailProvider,
|
||||
} from "@oh-my-pi/pi-tui";
|
||||
|
||||
const kSnapshot = Symbol("transcript.liveDiffSnapshot");
|
||||
@@ -139,6 +140,8 @@ interface BlockSegment {
|
||||
}
|
||||
|
||||
const EMPTY_SEGMENTS: BlockSegment[] = [];
|
||||
/** Shared empty result for an empty viewport-tail render (no allocation). */
|
||||
const EMPTY_TAIL: readonly string[] = [];
|
||||
|
||||
interface LiveCommitState {
|
||||
appendOnly: boolean;
|
||||
@@ -415,7 +418,7 @@ function deriveLiveCommitState(
|
||||
*/
|
||||
export class TranscriptContainer
|
||||
extends Container
|
||||
implements NativeScrollbackLiveRegion, NativeScrollbackCommittedRows, RenderStablePrefix
|
||||
implements NativeScrollbackLiveRegion, NativeScrollbackCommittedRows, RenderStablePrefix, ViewportTailProvider
|
||||
{
|
||||
// Bumped to retire every block's diff snapshot at once (theme change /
|
||||
// clear); a snapshot is only honored when its stored generation matches.
|
||||
@@ -520,6 +523,49 @@ export class TranscriptContainer
|
||||
return index === children.length - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render only the bottom `maxRows` rows of the transcript at `width`, walking
|
||||
* blocks from the last toward the first and stopping the instant enough rows
|
||||
* are collected — blocks above the fold are never rendered. The engine's
|
||||
* resize viewport fast path uses this so a drag (a SIGWINCH burst, each event
|
||||
* a fresh width that misses every per-width cache) re-lays-out only the
|
||||
* handful of visible blocks instead of the whole history every event.
|
||||
*
|
||||
* State-isolated by contract: touches none of the persistent full-compose
|
||||
* fields (#lines, #segments, the per-block diff snapshots, the commit/stable
|
||||
* bookkeeping), so the authoritative full render on settle reconciles exactly
|
||||
* as if this never ran. Calling each block's render() still warms its own
|
||||
* per-width cache, which that settle render then reuses for free.
|
||||
*
|
||||
* Consecutive visible blocks are joined by exactly one blank separator, the
|
||||
* same rule render() applies, so the result equals the bottom of a full
|
||||
* render except for an at-most-one-row separator on the topmost included
|
||||
* block — a transient discrepancy the settle paint overwrites.
|
||||
*/
|
||||
renderViewportTail(width: number, maxRows: number): readonly string[] {
|
||||
width = Math.max(1, width);
|
||||
if (maxRows <= 0) return EMPTY_TAIL;
|
||||
const collected: (readonly string[])[] = [];
|
||||
let total = 0;
|
||||
for (let i = this.children.length - 1; i >= 0 && total < maxRows; i--) {
|
||||
const contribution = stripPlainBlankEdges(this.children[i]!.render(width));
|
||||
if (contribution.length === 0) continue;
|
||||
// One blank separator sits between this block and the (already
|
||||
// collected) visible block below it.
|
||||
if (collected.length > 0) total += 1;
|
||||
collected.push(contribution);
|
||||
total += contribution.length;
|
||||
}
|
||||
if (collected.length === 0) return EMPTY_TAIL;
|
||||
const rows: string[] = [];
|
||||
for (let k = collected.length - 1; k >= 0; k--) {
|
||||
if (rows.length > 0) rows.push("");
|
||||
const body = collected[k]!;
|
||||
for (let j = 0; j < body.length; j++) rows.push(body[j]!);
|
||||
}
|
||||
return rows.length > maxRows ? rows.slice(rows.length - maxRows) : rows;
|
||||
}
|
||||
|
||||
override render(width: number): readonly string[] {
|
||||
width = Math.max(1, width);
|
||||
this.#nativeScrollbackLiveRegionStart = undefined;
|
||||
|
||||
@@ -612,3 +612,62 @@ describe("TranscriptContainer isBlockInLiveRegion", () => {
|
||||
expect(container.isBlockInLiveRegion(new StreamingBlock(["x"], false))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TranscriptContainer renderViewportTail", () => {
|
||||
const W = 40;
|
||||
// Four two-row blocks. A full render joins them with one blank separator:
|
||||
// b0a b0b "" b1a b1b "" b2a b2b "" b3a b3b → 11 rows.
|
||||
function fourBlocks(): { container: TranscriptContainer; blocks: CountingFinalizedBlock[] } {
|
||||
const blocks = [0, 1, 2, 3].map(i => new CountingFinalizedBlock([`b${i}a`, `b${i}b`]));
|
||||
const container = new TranscriptContainer();
|
||||
for (const b of blocks) container.addChild(b);
|
||||
return { container, blocks };
|
||||
}
|
||||
|
||||
it("returns exactly the bottom rows of a full render", () => {
|
||||
const { container } = fourBlocks();
|
||||
const full = [...container.render(W)];
|
||||
expect(full).toEqual(["b0a", "b0b", "", "b1a", "b1b", "", "b2a", "b2b", "", "b3a", "b3b"]);
|
||||
// A clean block boundary (the separator before b2) lands at the fold.
|
||||
expect([...container.renderViewportTail(W, 5)]).toEqual(full.slice(full.length - 5));
|
||||
// A mid-block fold still yields the exact bottom rows.
|
||||
expect([...container.renderViewportTail(W, 4)]).toEqual(full.slice(full.length - 4));
|
||||
expect([...container.renderViewportTail(W, 1)]).toEqual(["b3b"]);
|
||||
});
|
||||
|
||||
it("renders only the blocks needed to fill the request", () => {
|
||||
const { container, blocks } = fourBlocks();
|
||||
container.render(W);
|
||||
for (const b of blocks) b.renderCount = 0;
|
||||
// Bottom 4 rows span b3 (whole) and b2 (its last row visible): blocks
|
||||
// above the fold must never be rendered.
|
||||
container.renderViewportTail(W, 4);
|
||||
expect(blocks.map(b => b.renderCount)).toEqual([0, 0, 1, 1]);
|
||||
});
|
||||
|
||||
it("returns the whole transcript top-aligned when it is shorter than the request", () => {
|
||||
const { container } = fourBlocks();
|
||||
const full = [...container.render(W)];
|
||||
expect([...container.renderViewportTail(W, 100)]).toEqual(full);
|
||||
});
|
||||
|
||||
it("never mutates persistent full-compose state", () => {
|
||||
const { container } = fourBlocks();
|
||||
const before = [...container.render(W)];
|
||||
const liveBefore = container.getNativeScrollbackLiveRegionStart();
|
||||
// Interleave tail renders at other widths and sizes.
|
||||
container.renderViewportTail(80, 3);
|
||||
container.renderViewportTail(20, 7);
|
||||
container.renderViewportTail(W, 2);
|
||||
const after = [...container.render(W)];
|
||||
expect(after).toEqual(before);
|
||||
expect(container.getNativeScrollbackLiveRegionStart()).toBe(liveBefore);
|
||||
});
|
||||
|
||||
it("handles empty and zero-row requests", () => {
|
||||
const empty = new TranscriptContainer();
|
||||
expect([...empty.renderViewportTail(W, 10)]).toEqual([]);
|
||||
const { container } = fourBlocks();
|
||||
expect([...container.renderViewportTail(W, 0)]).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
|
||||
- Added `ViewportTailProvider` to let child components provide their visible tail rows during fast-path non-multiplexer resize rendering
|
||||
- Added `TUI.resizeViewportPaints` and `TUI.resizeViewportActive` getters to expose deferred resize viewport repaint diagnostics
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed non-multiplexer terminal resize handling so each SIGWINCH paints only the visible viewport and defers the full rewrap and native scrollback replay until the resize settles
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed issue #2088 viewport flash and repeated full rewrites during rapid terminal drags outside multiplexers by replaying the full transcript only after the resize settle window
|
||||
|
||||
## [15.12.4] - 2026-06-13
|
||||
|
||||
@@ -1374,4 +1386,4 @@ Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mon
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0))
|
||||
- **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0))
|
||||
@@ -239,6 +239,36 @@ function getRenderStablePrefixRows(component: Component): number | undefined {
|
||||
return (component as Component & Partial<RenderStablePrefix>).getRenderStablePrefixRows?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opt-in fast path for composing only the visible tail of a tall component
|
||||
* during a terminal resize. A drag emits a SIGWINCH burst, and the width
|
||||
* changes on every event: a full compose re-lays-out (and, for markdown,
|
||||
* re-lexes) the entire transcript per event — O(history) work that is
|
||||
* discarded the instant the next event arrives. While the resize is in flight
|
||||
* the engine paints only the viewport, so it asks each tall root child for at
|
||||
* most `maxRows` rows from the bottom of its render at `width` and skips
|
||||
* composing everything above the fold. The authoritative full paint replays
|
||||
* once the drag settles (see {@link TUI} resize handling).
|
||||
*
|
||||
* Contract:
|
||||
* - Returns the BOTTOM rows of the component's full render at `width`, in
|
||||
* top-to-bottom order, capped at `maxRows` (fewer when the component is
|
||||
* shorter). The rows MUST be byte-identical to the corresponding tail of
|
||||
* what `render(width)` would have returned, modulo a one-row separator at
|
||||
* the very top edge (a transient frame the settle paint overwrites).
|
||||
* - MUST NOT mutate any persistent full-compose state: the next `render()`
|
||||
* (the settle paint) has to reconcile exactly as if the tail render never
|
||||
* happened. Warming pure per-width render caches is fine and desirable.
|
||||
*/
|
||||
export interface ViewportTailProvider {
|
||||
renderViewportTail(width: number, maxRows: number): readonly string[];
|
||||
}
|
||||
|
||||
function asViewportTailProvider(component: Component): ViewportTailProvider | undefined {
|
||||
const candidate = component as Component & Partial<ViewportTailProvider>;
|
||||
return typeof candidate.renderViewportTail === "function" ? (candidate as ViewportTailProvider) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for components that can receive focus and display a cursor.
|
||||
* When focused, the component should emit CURSOR_MARKER at the cursor position
|
||||
@@ -629,6 +659,20 @@ export class TUI extends Container {
|
||||
// arrives (issue #2088). Coalescing every SIGWINCH inside this window into
|
||||
// a single forced render lets the multiplexer settle first.
|
||||
static readonly #MULTIPLEXER_RESIZE_DEBOUNCE_MS = 50;
|
||||
// Resize viewport fast path (non-multiplexer). A drag emits a SIGWINCH burst,
|
||||
// and outside a multiplexer the host gets each new geometry atomically. The
|
||||
// authoritative resize paint erases and replays the entire transcript so it
|
||||
// rewraps at the new width — O(history) compose (markdown re-lexes every
|
||||
// block, the per-width cache missing on every distinct drag width) plus an
|
||||
// O(history) write that pushes all of it back through native scrollback. At
|
||||
// drag rates that whole-history pass is recomputed dozens of times a second
|
||||
// and discarded the instant the next event lands. While the drag is in
|
||||
// flight the engine instead composes and paints ONLY the viewport (see
|
||||
// `#renderResizeViewport`): a state-isolated, throwaway frame that never
|
||||
// touches the commit ledger. The authoritative full replay fires once, after
|
||||
// the drag has been quiet for this long. Multiplexer sessions keep their own
|
||||
// debounce (`#armMultiplexerResizeTimer`, see #2088) and never take this path.
|
||||
static readonly #RESIZE_VIEWPORT_SETTLE_MS = 120;
|
||||
// Ghostty can drop Kitty graphics commands sent during its first post-startup
|
||||
// settle window, leaving only Unicode placeholder cells. Hold the first image
|
||||
// paint until that window has passed; later images render normally.
|
||||
@@ -713,6 +757,19 @@ export class TUI extends Container {
|
||||
// flag below so the settled paint still honours every caller's request.
|
||||
#multiplexerResizeTimer: RenderTimer | undefined;
|
||||
#deferredForcedClearScrollback = false;
|
||||
// True from the first SIGWINCH of a non-multiplexer drag until the settle
|
||||
// timer fires. While set, every `#doRender` short-circuits to the viewport
|
||||
// fast path (`#renderResizeViewport`) instead of an authoritative full
|
||||
// paint, and no commit/window/diff state is advanced.
|
||||
#resizeViewportActive = false;
|
||||
// Quiet-window timer that ends the drag: its callback clears the flag and
|
||||
// drives the one authoritative full paint. Reset on every resize event so it
|
||||
// only fires once the drag stops. Cancelled on stop().
|
||||
#resizeViewportSettleTimer: RenderTimer | undefined;
|
||||
// Count of transient viewport-only resize paints emitted. Distinct from
|
||||
// `#fullRedrawCount`: these never enter native scrollback and exist only for
|
||||
// the lifetime of the drag. Exposed for tests/diagnostics.
|
||||
#resizeViewportPaintCount = 0;
|
||||
#stopped = false;
|
||||
|
||||
// Transient alternate-screen state for a fullscreen overlay. While active, the
|
||||
@@ -944,6 +1001,20 @@ export class TUI extends Container {
|
||||
return this.#fullRedrawCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transient viewport-only paints emitted by the non-multiplexer resize fast
|
||||
* path. These never touch native scrollback or the commit ledger, so they
|
||||
* are counted apart from {@link fullRedraws}.
|
||||
*/
|
||||
get resizeViewportPaints(): number {
|
||||
return this.#resizeViewportPaintCount;
|
||||
}
|
||||
|
||||
/** Whether a non-multiplexer resize drag is currently in flight. */
|
||||
get resizeViewportActive(): boolean {
|
||||
return this.#resizeViewportActive;
|
||||
}
|
||||
|
||||
/** Shared budget that caps how many inline images render as live graphics. */
|
||||
get imageBudget(): ImageBudget {
|
||||
return this.#imageBudget;
|
||||
@@ -1144,6 +1215,10 @@ export class TUI extends Container {
|
||||
// classifies as a resize.
|
||||
this.#resizeEventPending = true;
|
||||
if (!isMultiplexerSession()) {
|
||||
// Enter the viewport fast path and (re)arm the settle timer, then
|
||||
// request the cheap viewport-only paint. The authoritative full
|
||||
// replay fires from the settle timer once the drag goes quiet.
|
||||
this.#beginResizeViewport();
|
||||
this.requestRender(true);
|
||||
return;
|
||||
}
|
||||
@@ -1360,6 +1435,11 @@ export class TUI extends Container {
|
||||
this.#multiplexerResizeTimer.cancel();
|
||||
this.#multiplexerResizeTimer = undefined;
|
||||
}
|
||||
if (this.#resizeViewportSettleTimer) {
|
||||
this.#resizeViewportSettleTimer.cancel();
|
||||
this.#resizeViewportSettleTimer = undefined;
|
||||
}
|
||||
this.#resizeViewportActive = false;
|
||||
this.#clearPostFullPaintSettle();
|
||||
this.#deferredForcedClearScrollback = false;
|
||||
// Place the parent shell on the first line after the rendered content. When
|
||||
@@ -2093,6 +2173,20 @@ export class TUI extends Container {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resize viewport fast path. While a non-multiplexer drag is in flight,
|
||||
// paint only the viewport and skip composing the off-screen history.
|
||||
// Strictly state-isolated: it never consumes #resizeEventPending nor
|
||||
// advances any commit/window/diff field, so the authoritative full paint
|
||||
// the settle timer queues reconciles as if these throwaway frames never
|
||||
// ran. A visible overlay composites over the transcript and needs the
|
||||
// whole window, so fall through to the normal forced paint when one is up
|
||||
// (overlay resizes are not on the drag-cost hot path).
|
||||
if (this.#resizeViewportActive && this.#hasEverRendered && this.#getTopmostVisibleOverlay() === undefined) {
|
||||
this.#componentRenderTargets.clear();
|
||||
this.#renderResizeViewport(width, height);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Compose the frame. Bracket the render so the image budget observes
|
||||
// every inline image in display order (overlays carry none). A
|
||||
// component-scoped frame skips the budget pass instead — it is gated on
|
||||
@@ -2679,6 +2773,101 @@ export class TUI extends Container {
|
||||
this.#commit(frame, window, width, height, cursorControl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter (or extend) the non-multiplexer resize fast path. Marks the drag
|
||||
* active so subsequent `#doRender` calls paint viewport-only, then (re)arms
|
||||
* the quiet-window timer whose callback ends the drag with one authoritative
|
||||
* full paint. Reset on every SIGWINCH, so the full replay fires only once the
|
||||
* user stops dragging.
|
||||
*/
|
||||
#beginResizeViewport(): void {
|
||||
this.#resizeViewportActive = true;
|
||||
this.#resizeViewportSettleTimer?.cancel();
|
||||
this.#resizeViewportSettleTimer = this.#renderScheduler.scheduleRender(() => {
|
||||
this.#resizeViewportSettleTimer = undefined;
|
||||
this.#resizeViewportActive = false;
|
||||
if (this.#stopped) return;
|
||||
// The drag is quiet: replay the rewrapped transcript authoritatively.
|
||||
// #resizeEventPending was preserved across every viewport-only frame
|
||||
// (the fast path never consumes it), so this classifies as a geometry
|
||||
// rebuild — ED3 + full history — and the clearScrollback intent below
|
||||
// matches the gesture-driven reset path.
|
||||
this.#resizeEventPending = true;
|
||||
this.requestRender(true, { clearScrollback: !isMultiplexerSession() });
|
||||
}, TUI.#RESIZE_VIEWPORT_SETTLE_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose and paint only the viewport for one resize fast-path frame.
|
||||
* State-isolated: advances no commit/window/diff field and calls neither
|
||||
* `#commit` nor `#emitFullPaint`, so the settle full paint reconciles against
|
||||
* the pre-drag screen state.
|
||||
*/
|
||||
#renderResizeViewport(width: number, height: number): void {
|
||||
if (width <= 0 || height <= 0) return;
|
||||
// Tail renders call block.render(), which can push image ids onto the
|
||||
// budget's in-flight pass. Reset the pass each frame so a long drag does
|
||||
// not accumulate; never endPass() here — that mutates the demotion ledger
|
||||
// off a partial (tail-only) walk. The settle paint's own
|
||||
// beginPass()/endPass() is the authoritative accounting, and its
|
||||
// beginPass() wipes whatever these frames observed.
|
||||
this.#imageBudget.beginPass();
|
||||
const window = this.#composeResizeViewport(width, height);
|
||||
this.#emitResizeViewport(window, height);
|
||||
this.#resizeViewportPaintCount += 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the viewport window for a resize fast-path frame: the bottom
|
||||
* `height` rows of the would-be full frame, collected bottom-up across root
|
||||
* children. {@link ViewportTailProvider}s (the transcript) yield only their
|
||||
* tail; the small live-region children below render in full — so every child
|
||||
* entirely above the fold is skipped. A frame shorter than the viewport is
|
||||
* top-aligned with blank rows below, matching the full-paint window geometry
|
||||
* (windowTop = max(0, frameLength - height)). Cursor markers are stripped
|
||||
* (the drag hides the hardware cursor) and rows are width-fitted via the
|
||||
* stateless preparer, so no persistent prepared-frame cache is touched.
|
||||
*/
|
||||
#composeResizeViewport(width: number, height: number): string[] {
|
||||
const tail: string[] = []; // bottom-first
|
||||
const children = this.children;
|
||||
for (let i = children.length - 1; i >= 0 && tail.length < height; i--) {
|
||||
const child = children[i]!;
|
||||
const provider = asViewportTailProvider(child);
|
||||
const rows = provider ? provider.renderViewportTail(width, height - tail.length) : child.render(width);
|
||||
for (let r = rows.length - 1; r >= 0 && tail.length < height; r--) {
|
||||
tail.push(rows[r]!);
|
||||
}
|
||||
}
|
||||
const count = tail.length;
|
||||
const window: string[] = new Array(height);
|
||||
for (let screenRow = 0; screenRow < height; screenRow++) {
|
||||
// `tail` holds the bottom `count` frame rows, bottom-first. They fill
|
||||
// the viewport when the frame overflows it and sit at the top (blanks
|
||||
// below) when it underflows.
|
||||
window[screenRow] = screenRow < count ? tail[count - 1 - screenRow]! : "";
|
||||
}
|
||||
this.#extractCursorMarkers(window);
|
||||
return this.#prepareLinesArray(window, width);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a throwaway viewport repaint for the resize fast path: erase the
|
||||
* visible screen (ED2 — never ED3, so native scrollback survives the drag)
|
||||
* and write the prepared window from home with the cursor held hidden. No
|
||||
* scrollback push, no committed-prefix write, no `#commit` — none of the
|
||||
* paint accounting is advanced.
|
||||
*/
|
||||
#emitResizeViewport(window: readonly string[], height: number): void {
|
||||
let buffer = `${this.#paintBeginSequence}\x1b[2J\x1b[H`;
|
||||
for (let r = 0; r < height; r++) {
|
||||
if (r > 0) buffer += "\r\n";
|
||||
buffer += this.#terminalLine(window[r] ?? "");
|
||||
}
|
||||
buffer += this.#paintEndSequence;
|
||||
this.terminal.write(buffer);
|
||||
}
|
||||
|
||||
/** Topmost visible overlay requests the alternate-screen buffer. */
|
||||
#wantsAltScreen(): boolean {
|
||||
for (let i = this.overlayStack.length - 1; i >= 0; i--) {
|
||||
|
||||
@@ -53,6 +53,15 @@ async function settle(term: VirtualTerminal): Promise<void> {
|
||||
await term.flush();
|
||||
}
|
||||
|
||||
// A non-multiplexer resize paints the viewport immediately (plain rows) and
|
||||
// defers the authoritative full paint — which is where DECCARA rectangle fills
|
||||
// are planned — until the drag has been quiet for the resize settle window
|
||||
// (120 ms). Integration test against the real scheduler, so wait it out.
|
||||
async function settleResize(term: VirtualTerminal): Promise<void> {
|
||||
await Bun.sleep(160);
|
||||
await settle(term);
|
||||
}
|
||||
|
||||
function captureWrites(term: VirtualTerminal): string[] {
|
||||
const writes: string[] = [];
|
||||
const realWrite = term.write.bind(term);
|
||||
@@ -499,8 +508,8 @@ describe("TUI DECCARA integration", () => {
|
||||
await settle(term);
|
||||
|
||||
const writes = captureWrites(term);
|
||||
term.resize(40, 10); // height change, content unchanged -> viewportRepaint
|
||||
await settle(term);
|
||||
term.resize(40, 10); // height change, content unchanged
|
||||
await settleResize(term); // DECCARA fills land on the deferred full paint
|
||||
const out = writes.join("");
|
||||
|
||||
expect(out).toContain(DECSACE_RECT);
|
||||
|
||||
@@ -71,6 +71,18 @@ async function settle(term: VirtualTerminal): Promise<void> {
|
||||
await term.flush();
|
||||
}
|
||||
|
||||
// Pad the non-multiplexer resize viewport settle window (120 ms) so the test
|
||||
// reliably observes the deferred authoritative full paint. These are
|
||||
// integration tests against the real render scheduler (process.nextTick
|
||||
// immediates interleaved with setTimeout debounces), so the settle window is
|
||||
// driven with a real delay rather than fake timers.
|
||||
const RESIZE_VIEWPORT_SETTLE_WAIT_MS = 160;
|
||||
|
||||
async function settleResize(term: VirtualTerminal): Promise<void> {
|
||||
await Bun.sleep(RESIZE_VIEWPORT_SETTLE_WAIT_MS);
|
||||
await settle(term);
|
||||
}
|
||||
|
||||
function captureWrites(term: VirtualTerminal): string[] {
|
||||
const writes: string[] = [];
|
||||
const realWrite = term.write.bind(term);
|
||||
@@ -142,7 +154,7 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders immediately on resize outside a multiplexer", async () => {
|
||||
it("paints the viewport immediately on resize outside a multiplexer, then replays on settle", async () => {
|
||||
await withEnvPatch(NO_MULTIPLEXER_ENV, async () => {
|
||||
const term = new VirtualTerminal(40, 10, 1000);
|
||||
const tui = new TUI(term);
|
||||
@@ -153,10 +165,21 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => {
|
||||
await settle(term);
|
||||
|
||||
const baselineRedraws = tui.fullRedraws;
|
||||
const baselinePaints = tui.resizeViewportPaints;
|
||||
const expectedViewport = Array.from({ length: 10 }, (_v, i) => `line-${i + 10}`);
|
||||
term.resize(80, 10);
|
||||
await settle(term);
|
||||
|
||||
// In flight: a cheap viewport-only paint lands at once (no native
|
||||
// scrollback replay), and the authoritative full paint is deferred.
|
||||
expect(tui.resizeViewportPaints).toBeGreaterThan(baselinePaints);
|
||||
expect(tui.fullRedraws).toBe(baselineRedraws);
|
||||
expect(visible(term)).toEqual(expectedViewport);
|
||||
|
||||
// Once the drag goes quiet the full replay fires exactly once.
|
||||
await settleResize(term);
|
||||
expect(tui.fullRedraws).toBeGreaterThan(baselineRedraws);
|
||||
expect(visible(term)).toEqual(Array.from({ length: 10 }, (_v, i) => `line-${i + 10}`));
|
||||
expect(visible(term)).toEqual(expectedViewport);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
|
||||
@@ -110,6 +110,15 @@ async function flushRender(term: VirtualTerminal): Promise<void> {
|
||||
await term.flush();
|
||||
}
|
||||
|
||||
// A non-multiplexer resize paints the viewport immediately and defers the
|
||||
// authoritative full replay (the native-scrollback rebuild) until the drag has
|
||||
// been quiet for the resize settle window (120 ms). Integration test against the
|
||||
// real render scheduler, so the window is driven with a real delay.
|
||||
async function settleResize(term: VirtualTerminal): Promise<void> {
|
||||
await Bun.sleep(160);
|
||||
await flushRender(term);
|
||||
}
|
||||
|
||||
describe("TUI overlays", () => {
|
||||
it("does not scroll the terminal when an overlay is shown with a large historical working area", async () => {
|
||||
const term = new VirtualTerminal(80, 24);
|
||||
@@ -383,7 +392,7 @@ describe("TUI overlays", () => {
|
||||
expect(term.getScrollBuffer().join("\n").includes("wide-row-0")).toBeTruthy();
|
||||
|
||||
term.resize(20, 4);
|
||||
await flushRender(term);
|
||||
await settleResize(term);
|
||||
|
||||
const scrollback = term.getScrollBuffer().join("\n");
|
||||
expect(scrollback.includes("narrow-row-0")).toBeTruthy();
|
||||
@@ -408,6 +417,9 @@ describe("TUI overlays", () => {
|
||||
term.resize(40, count % 2 === 0 ? 4 : 5);
|
||||
await flushRender(term);
|
||||
}
|
||||
// The drag only painted the viewport; let the settle window elapse so
|
||||
// the authoritative rebuild commits the overflow into native scrollback.
|
||||
await settleResize(term);
|
||||
|
||||
const scrollbackLines = term.getScrollBuffer().map(line => line.trim());
|
||||
expect(scrollbackLines).toContain("row-0");
|
||||
|
||||
@@ -129,6 +129,16 @@ async function settle(term: VirtualTerminal): Promise<void> {
|
||||
await term.flush();
|
||||
}
|
||||
|
||||
// Outside a multiplexer a resize paints the viewport immediately and defers the
|
||||
// authoritative full replay (rewrap + ED3 + history rebuild) until the drag has
|
||||
// been quiet for the resize settle window (120 ms). Tests asserting the settled
|
||||
// end state wait past that window. These are integration tests against the real
|
||||
// render scheduler, so the window is driven with a real delay, not fake timers.
|
||||
async function settleResize(term: VirtualTerminal): Promise<void> {
|
||||
await Bun.sleep(160);
|
||||
await settle(term);
|
||||
}
|
||||
|
||||
function captureWrites(term: VirtualTerminal): string[] {
|
||||
const writes: string[] = [];
|
||||
const realWrite = term.write.bind(term);
|
||||
@@ -509,7 +519,7 @@ describe("TUI terminal-state regressions", () => {
|
||||
await settle(term);
|
||||
|
||||
term.resize(49, 5);
|
||||
await settle(term);
|
||||
await settleResize(term);
|
||||
|
||||
const buffer = term.getScrollBuffer().join("\n");
|
||||
expect(buffer.includes("shell-")).toBeFalsy();
|
||||
@@ -589,7 +599,7 @@ describe("TUI terminal-state regressions", () => {
|
||||
expect(narrow).toContain(`L0:${"x".repeat(17)}`);
|
||||
|
||||
term.resize(40, 4);
|
||||
await settle(term);
|
||||
await settleResize(term);
|
||||
|
||||
const wide = term.getScrollBuffer().map(line => line.trimEnd());
|
||||
// The resize rewraps history at the new width; the narrow fragment is gone.
|
||||
@@ -647,7 +657,7 @@ describe("TUI terminal-state regressions", () => {
|
||||
await settle(term);
|
||||
component.setLines(["A".repeat(20), "B".repeat(20), "C".repeat(20)]);
|
||||
tui.requestRender();
|
||||
await settle(term);
|
||||
await settleResize(term);
|
||||
|
||||
expect(term.getScrollBuffer().map(line => line.trimEnd())).toEqual([
|
||||
"AAAAAAAAAA",
|
||||
@@ -1169,7 +1179,7 @@ describe("TUI terminal-state regressions", () => {
|
||||
const final = [...lines, "stream-0", "tail-0", "tail-1", "tail-2"];
|
||||
component.setLines(final);
|
||||
tui.requestRender();
|
||||
await settle(term);
|
||||
await settleResize(term);
|
||||
|
||||
// Scrolling back must show exactly the transcript: no phantom blank
|
||||
// row, no offset rows, no duplicates.
|
||||
@@ -1240,7 +1250,7 @@ describe("TUI terminal-state regressions", () => {
|
||||
const final = [...lines, "row-12"];
|
||||
component.setLines(final);
|
||||
tui.requestRender();
|
||||
await settle(term);
|
||||
await settleResize(term);
|
||||
|
||||
expect(visible(term)).toEqual(final.slice(7));
|
||||
expect(term.getScrollBuffer().map(line => line.trimEnd())).toEqual(final);
|
||||
@@ -1593,6 +1603,9 @@ describe("TUI terminal-state regressions", () => {
|
||||
tui.requestRender();
|
||||
await settle(term);
|
||||
}
|
||||
// The aggressive drag only ever painted the viewport; let the settle
|
||||
// window elapse so the authoritative rebuild commits the full history.
|
||||
await settleResize(term);
|
||||
|
||||
const scrollback = term.getScrollBuffer();
|
||||
const duplicated: number[] = [];
|
||||
@@ -1631,7 +1644,7 @@ describe("TUI terminal-state regressions", () => {
|
||||
|
||||
// User sits at the bottom (not scrolled) and narrows the terminal.
|
||||
term.resize(28, 5);
|
||||
await settle(term);
|
||||
await settleResize(term);
|
||||
|
||||
const scrollback = term.getScrollBuffer();
|
||||
for (let i = 0; i < 12; i++) {
|
||||
@@ -1657,7 +1670,7 @@ describe("TUI terminal-state regressions", () => {
|
||||
|
||||
component.setLines(rows("line-", 8));
|
||||
term.resize(28, 5);
|
||||
await settle(term);
|
||||
await settleResize(term);
|
||||
|
||||
// A resize is a clean reset: history is rebuilt in place at the new
|
||||
// geometry instead of deferring to keep the reader scrolled. Each line
|
||||
@@ -3069,7 +3082,7 @@ describe("TUI terminal-state regressions", () => {
|
||||
|
||||
// Grow the terminal so it has more rows than the rendered content.
|
||||
term.resize(40, 20);
|
||||
await settle(term);
|
||||
await settleResize(term);
|
||||
|
||||
// Regression: the cursor must follow the marker, not the bottom
|
||||
// of the now-taller viewport.
|
||||
@@ -4239,6 +4252,9 @@ describe("foreground-tool streaming on ED3-risk terminals", () => {
|
||||
tui.requestRender();
|
||||
await settle(term);
|
||||
}
|
||||
// Each drag step only repainted the viewport; let the settle window
|
||||
// elapse so the authoritative rebuild collapses history to one copy.
|
||||
await settleResize(term);
|
||||
const scrollback = term.getScrollBuffer();
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
expect(
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import { afterEach, describe, expect, it, vi } from "bun:test";
|
||||
import {
|
||||
type Component,
|
||||
type RenderScheduler,
|
||||
type RenderTimer,
|
||||
TUI,
|
||||
type ViewportTailProvider,
|
||||
} from "@oh-my-pi/pi-tui";
|
||||
import { VirtualTerminal } from "./virtual-terminal";
|
||||
|
||||
// Outside a multiplexer a resize used to erase-and-replay the whole transcript
|
||||
// on every SIGWINCH. A drag fires a burst of those, each at a fresh width that
|
||||
// misses every per-width render cache, so the entire history is re-laid-out and
|
||||
// re-pushed through scrollback dozens of times a second and discarded the
|
||||
// instant the next event lands. The fast path instead paints ONLY the viewport
|
||||
// while the drag is in flight — composing just the visible tail and skipping
|
||||
// the off-screen history — and replays the rewrapped transcript once, after the
|
||||
// drag settles.
|
||||
|
||||
const NO_MULTIPLEXER_ENV: Record<string, string | undefined> = { TMUX: undefined, STY: undefined, ZELLIJ: undefined };
|
||||
|
||||
async function withEnvPatch<T>(patch: Record<string, string | undefined>, run: () => T | Promise<T>): Promise<T> {
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
for (const key in patch) {
|
||||
saved[key] = Bun.env[key];
|
||||
const value = patch[key];
|
||||
if (value === undefined) delete Bun.env[key];
|
||||
else Bun.env[key] = value;
|
||||
}
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
for (const key in saved) {
|
||||
const value = saved[key];
|
||||
if (value === undefined) delete Bun.env[key];
|
||||
else Bun.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic scheduler so the test drives the resize settle window itself
|
||||
// instead of waiting on the wall clock. `scheduleImmediate` callbacks are the
|
||||
// per-event viewport paints; `scheduleRender` callbacks are delayed timers (the
|
||||
// settle). `flushImmediates` paints the mid-drag state without firing the
|
||||
// settle; `flushAll` fires the settle and the authoritative replay it queues.
|
||||
class DeferScheduler implements RenderScheduler {
|
||||
#time = 0;
|
||||
#immediates: (() => void)[] = [];
|
||||
#renders = new Map<number, () => void>();
|
||||
#nextId = 0;
|
||||
|
||||
now(): number {
|
||||
this.#time += 20;
|
||||
return this.#time;
|
||||
}
|
||||
|
||||
scheduleImmediate(callback: () => void): void {
|
||||
this.#immediates.push(callback);
|
||||
}
|
||||
|
||||
scheduleRender(callback: () => void, _delayMs: number): RenderTimer {
|
||||
const id = this.#nextId++;
|
||||
this.#renders.set(id, callback);
|
||||
return {
|
||||
cancel: () => {
|
||||
this.#renders.delete(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
get pendingRenders(): number {
|
||||
return this.#renders.size;
|
||||
}
|
||||
|
||||
async flushImmediates(term: VirtualTerminal): Promise<void> {
|
||||
let rounds = 0;
|
||||
while (this.#immediates.length > 0) {
|
||||
if (++rounds > 100) throw new Error("immediates did not settle");
|
||||
const batch = this.#immediates;
|
||||
this.#immediates = [];
|
||||
for (const callback of batch) callback();
|
||||
}
|
||||
await term.flush();
|
||||
}
|
||||
|
||||
async flushAll(term: VirtualTerminal): Promise<void> {
|
||||
let rounds = 0;
|
||||
while (this.#immediates.length > 0 || this.#renders.size > 0) {
|
||||
if (++rounds > 100) throw new Error("scheduler did not settle");
|
||||
const immediates = this.#immediates;
|
||||
this.#immediates = [];
|
||||
for (const callback of immediates) callback();
|
||||
if (this.#immediates.length > 0) continue;
|
||||
const renders = [...this.#renders.values()];
|
||||
this.#renders.clear();
|
||||
for (const callback of renders) callback();
|
||||
}
|
||||
await term.flush();
|
||||
}
|
||||
}
|
||||
|
||||
function captureWrites(term: VirtualTerminal): string[] {
|
||||
const writes: string[] = [];
|
||||
const realWrite = term.write.bind(term);
|
||||
vi.spyOn(term, "write").mockImplementation((data: string) => {
|
||||
writes.push(data);
|
||||
realWrite(data);
|
||||
});
|
||||
return writes;
|
||||
}
|
||||
|
||||
function visible(term: VirtualTerminal): string[] {
|
||||
return term.getViewport().map(line => line.trimEnd());
|
||||
}
|
||||
|
||||
function eraseScrollbackCount(writes: string[]): number {
|
||||
return writes.filter(chunk => chunk.includes("\x1b[3J")).length;
|
||||
}
|
||||
|
||||
// A transcript block that records how many times it was laid out. Whole blocks
|
||||
// render when they sit in (or partially in) the viewport tail; blocks above the
|
||||
// fold must never be rendered during the drag.
|
||||
class CountingBlock implements Component {
|
||||
renderCount = 0;
|
||||
#lines: string[];
|
||||
constructor(lines: string[]) {
|
||||
this.#lines = lines;
|
||||
}
|
||||
invalidate(): void {}
|
||||
render(width: number): string[] {
|
||||
this.renderCount++;
|
||||
return this.#lines.map(line => line.slice(0, width));
|
||||
}
|
||||
}
|
||||
|
||||
// A minimal transcript: blocks concatenated with no separators, plus a bottom-up
|
||||
// tail render that touches only the blocks needed to fill the request.
|
||||
class TailTranscript implements Component, ViewportTailProvider {
|
||||
blocks: CountingBlock[];
|
||||
constructor(blocks: CountingBlock[]) {
|
||||
this.blocks = blocks;
|
||||
}
|
||||
invalidate(): void {}
|
||||
render(width: number): string[] {
|
||||
const out: string[] = [];
|
||||
for (const block of this.blocks) out.push(...block.render(width));
|
||||
return out;
|
||||
}
|
||||
renderViewportTail(width: number, maxRows: number): readonly string[] {
|
||||
const tail: string[] = [];
|
||||
for (let i = this.blocks.length - 1; i >= 0 && tail.length < maxRows; i--) {
|
||||
const rows = this.blocks[i]!.render(width);
|
||||
for (let r = rows.length - 1; r >= 0 && tail.length < maxRows; r--) tail.unshift(rows[r]!);
|
||||
}
|
||||
return tail;
|
||||
}
|
||||
}
|
||||
|
||||
describe("non-multiplexer resize viewport fast path", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// 15 two-row blocks (30 rows) over a 10-row viewport: only the last few rows
|
||||
// are ever on screen, so a drag must not re-lay-out the rows above the fold.
|
||||
function makeTui(term: VirtualTerminal): { tui: TUI; blocks: CountingBlock[]; scheduler: DeferScheduler } {
|
||||
const blocks = Array.from({ length: 15 }, (_v, i) => new CountingBlock([`b${i}-x`, `b${i}-y`]));
|
||||
const scheduler = new DeferScheduler();
|
||||
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
|
||||
tui.addChild(new TailTranscript(blocks));
|
||||
return { tui, blocks, scheduler };
|
||||
}
|
||||
|
||||
it("paints only the viewport during a drag and never re-lays-out off-screen history", async () => {
|
||||
await withEnvPatch(NO_MULTIPLEXER_ENV, async () => {
|
||||
const term = new VirtualTerminal(40, 10, 1000);
|
||||
const { tui, blocks, scheduler } = makeTui(term);
|
||||
try {
|
||||
tui.start();
|
||||
await scheduler.flushImmediates(term);
|
||||
|
||||
const baselineFull = tui.fullRedraws;
|
||||
const writes = captureWrites(term);
|
||||
for (const b of blocks) b.renderCount = 0;
|
||||
|
||||
// A drag burst: several SIGWINCHes at intermediate widths, each
|
||||
// followed by its viewport paint but never the settle.
|
||||
term.resize(60, 10);
|
||||
await scheduler.flushImmediates(term);
|
||||
term.resize(75, 10);
|
||||
await scheduler.flushImmediates(term);
|
||||
term.resize(80, 10);
|
||||
await scheduler.flushImmediates(term);
|
||||
|
||||
// In flight: viewport-only paints, no authoritative full redraw, and
|
||||
// crucially no ED3 — native scrollback is left untouched.
|
||||
expect(tui.resizeViewportActive).toBe(true);
|
||||
expect(tui.resizeViewportPaints).toBe(3);
|
||||
expect(tui.fullRedraws).toBe(baselineFull);
|
||||
expect(eraseScrollbackCount(writes)).toBe(0);
|
||||
|
||||
// Blocks above the fold are never rendered during the drag; only the
|
||||
// visible tail is.
|
||||
expect(blocks.slice(0, 10).every(b => b.renderCount === 0)).toBe(true);
|
||||
expect(blocks.at(-1)!.renderCount).toBeGreaterThan(0);
|
||||
|
||||
// The viewport still shows the bottom of the transcript, rewrapped
|
||||
// at the new width.
|
||||
expect(visible(term).at(-1)).toBe("b14-y");
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("replays the full rewrapped history once the drag settles", async () => {
|
||||
await withEnvPatch(NO_MULTIPLEXER_ENV, async () => {
|
||||
const term = new VirtualTerminal(40, 10, 1000);
|
||||
const { tui, blocks, scheduler } = makeTui(term);
|
||||
try {
|
||||
tui.start();
|
||||
await scheduler.flushImmediates(term);
|
||||
|
||||
const baselineFull = tui.fullRedraws;
|
||||
const writes = captureWrites(term);
|
||||
|
||||
term.resize(60, 10);
|
||||
await scheduler.flushImmediates(term);
|
||||
term.resize(80, 10);
|
||||
await scheduler.flushImmediates(term);
|
||||
|
||||
// Settle window elapses: exactly one authoritative full paint that
|
||||
// erases native scrollback (ED3) and replays every block.
|
||||
for (const b of blocks) b.renderCount = 0;
|
||||
await scheduler.flushAll(term);
|
||||
|
||||
expect(tui.resizeViewportActive).toBe(false);
|
||||
expect(tui.fullRedraws).toBeGreaterThan(baselineFull);
|
||||
expect(eraseScrollbackCount(writes)).toBeGreaterThan(0);
|
||||
// The full replay lays out the whole transcript, off-screen blocks
|
||||
// included.
|
||||
expect(blocks.every(b => b.renderCount > 0)).toBe(true);
|
||||
|
||||
// Scrollback holds the entire transcript exactly once — no
|
||||
// duplication from the interleaved viewport-only frames.
|
||||
const buffer = term.getScrollBuffer().map(line => line.trimEnd());
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
expect(buffer.filter(line => line === `b${i}-x`).length).toBe(1);
|
||||
expect(buffer.filter(line => line === `b${i}-y`).length).toBe(1);
|
||||
}
|
||||
expect(visible(term).at(-1)).toBe("b14-y");
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("does not leave a pending settle paint after stop()", async () => {
|
||||
await withEnvPatch(NO_MULTIPLEXER_ENV, async () => {
|
||||
const term = new VirtualTerminal(40, 10, 1000);
|
||||
const { tui, scheduler } = makeTui(term);
|
||||
tui.start();
|
||||
await scheduler.flushImmediates(term);
|
||||
|
||||
const writes = captureWrites(term);
|
||||
term.resize(80, 10);
|
||||
tui.stop();
|
||||
|
||||
// stop() cancels the settle timer, so the authoritative replay never
|
||||
// fires: no ED3 bytes land even after the scheduler is fully drained.
|
||||
await scheduler.flushAll(term);
|
||||
expect(eraseScrollbackCount(writes)).toBe(0);
|
||||
expect(scheduler.pendingRenders).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -71,6 +71,15 @@ async function settle(term: VirtualTerminal): Promise<void> {
|
||||
await term.flush();
|
||||
}
|
||||
|
||||
// The non-multiplexer resize fast path paints the viewport at once and defers
|
||||
// the authoritative full replay (the ED3 scrollback rebuild) until the drag has
|
||||
// been quiet for the resize settle window (120 ms). This is an integration test
|
||||
// against the real render scheduler, so the window is driven with a real delay.
|
||||
async function settleResize(term: VirtualTerminal): Promise<void> {
|
||||
await Bun.sleep(160);
|
||||
await settle(term);
|
||||
}
|
||||
|
||||
function capture(term: VirtualTerminal): string[] {
|
||||
const writes: string[] = [];
|
||||
const realWrite = term.write.bind(term);
|
||||
@@ -459,10 +468,11 @@ describe("streaming scrollback defer", () => {
|
||||
expect(streamed).toEqual([...rows("stream-", 30), "prompt"].slice(0, streamed.length));
|
||||
|
||||
// Resize mid-stream. The terminal re-wrapped its saved lines at the old
|
||||
// width, so the rebuild must erase them (ED 3) rather than capping to a
|
||||
// viewport repaint that would leave the corrupt history on screen.
|
||||
// width, so the authoritative rebuild must erase them (ED 3) rather than
|
||||
// leaving the corrupt history on screen. That rebuild is deferred until
|
||||
// the drag settles; while in flight only the viewport is repainted.
|
||||
term.resize(30, 10);
|
||||
await settle(term);
|
||||
await settleResize(term);
|
||||
|
||||
expect(eraseScrollbackCount(writes)).toBeGreaterThan(0);
|
||||
expect(term.getScrollBuffer().map(line => line.trimEnd())).toEqual([...rows("stream-", 30), "prompt"]);
|
||||
|
||||
Reference in New Issue
Block a user