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:
can1357
2026-06-13 21:27:36 +02:00
parent f9a8aa1d96
commit 2bbf995a1d
11 changed files with 674 additions and 28 deletions
+4 -10
View File
@@ -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([]);
});
});
+13 -1
View File
@@ -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))
+189
View File
@@ -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--) {
+11 -2
View File
@@ -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);
+25 -2
View File
@@ -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();
}
+13 -1
View File
@@ -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");
+24 -8
View File
@@ -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"]);