diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 03a9d52ac..16697b5e5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 \ No newline at end of file diff --git a/packages/coding-agent/src/modes/components/transcript-container.ts b/packages/coding-agent/src/modes/components/transcript-container.ts index 6519ae393..39b526875 100644 --- a/packages/coding-agent/src/modes/components/transcript-container.ts +++ b/packages/coding-agent/src/modes/components/transcript-container.ts @@ -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; diff --git a/packages/coding-agent/test/modes/components/transcript-container.test.ts b/packages/coding-agent/test/modes/components/transcript-container.test.ts index 1784be14d..2b3734869 100644 --- a/packages/coding-agent/test/modes/components/transcript-container.test.ts +++ b/packages/coding-agent/test/modes/components/transcript-container.test.ts @@ -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([]); + }); +}); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 74e3ef291..bc01ff5c7 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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)) \ No newline at end of file diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index f16cd7473..8bc218193 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -239,6 +239,36 @@ function getRenderStablePrefixRows(component: Component): number | undefined { return (component as Component & Partial).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; + 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--) { diff --git a/packages/tui/test/deccara.test.ts b/packages/tui/test/deccara.test.ts index b95bb1b23..e33bad5fa 100644 --- a/packages/tui/test/deccara.test.ts +++ b/packages/tui/test/deccara.test.ts @@ -53,6 +53,15 @@ async function settle(term: VirtualTerminal): Promise { 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 { + 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); diff --git a/packages/tui/test/issue-2088-repro.test.ts b/packages/tui/test/issue-2088-repro.test.ts index a19d72b85..3230cb70c 100644 --- a/packages/tui/test/issue-2088-repro.test.ts +++ b/packages/tui/test/issue-2088-repro.test.ts @@ -71,6 +71,18 @@ async function settle(term: VirtualTerminal): Promise { 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 { + 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(); } diff --git a/packages/tui/test/overlay-scroll.test.ts b/packages/tui/test/overlay-scroll.test.ts index af85a123e..9f4ef9811 100644 --- a/packages/tui/test/overlay-scroll.test.ts +++ b/packages/tui/test/overlay-scroll.test.ts @@ -110,6 +110,15 @@ async function flushRender(term: VirtualTerminal): Promise { 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 { + 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"); diff --git a/packages/tui/test/render-regressions.test.ts b/packages/tui/test/render-regressions.test.ts index 26d2011a8..13a6ecbae 100644 --- a/packages/tui/test/render-regressions.test.ts +++ b/packages/tui/test/render-regressions.test.ts @@ -129,6 +129,16 @@ async function settle(term: VirtualTerminal): Promise { 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 { + 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( diff --git a/packages/tui/test/resize-viewport-defer.test.ts b/packages/tui/test/resize-viewport-defer.test.ts new file mode 100644 index 000000000..8991d766f --- /dev/null +++ b/packages/tui/test/resize-viewport-defer.test.ts @@ -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 = { TMUX: undefined, STY: undefined, ZELLIJ: undefined }; + +async function withEnvPatch(patch: Record, run: () => T | Promise): Promise { + const saved: Record = {}; + 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 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 { + 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 { + 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); + }); + }); +}); diff --git a/packages/tui/test/streaming-scrollback-defer.test.ts b/packages/tui/test/streaming-scrollback-defer.test.ts index 221ede108..075e31803 100644 --- a/packages/tui/test/streaming-scrollback-defer.test.ts +++ b/packages/tui/test/streaming-scrollback-defer.test.ts @@ -71,6 +71,15 @@ async function settle(term: VirtualTerminal): Promise { 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 { + 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"]);