diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 2becaac84..328a7e2ae 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -396,6 +396,7 @@ These are read as runtime signals; they are usually set by the terminal/OS rathe | `PI_NO_DECCARA` | If set (truthy), disables Kitty DECCARA rectangular-SGR background fills (forces padded-string rendering) | | `PI_DEBUG_REDRAW` | If `1`, enables redraw debug logging | | `PI_FORCE_IMAGE_PROTOCOL` | Forces terminal image protocol detection (`kitty`, `iterm2`/`iterm`, `sixel`, `none`) | +| `PI_TUI_RESIZE_IN_PLACE` | `1`/`true` force in-place resize (no alt-screen borrow, no ED3 rewrap); `0`/`false` force the alt-screen fast path. Default-on for Warp, which re-reports its size on alt-screen toggles | --- diff --git a/docs/tui-core-renderer.md b/docs/tui-core-renderer.md index cce279c59..cb83695c2 100644 --- a/docs/tui-core-renderer.md +++ b/docs/tui-core-renderer.md @@ -340,6 +340,7 @@ default-on only for kitty/ghostty (`PI_NO_KITTY_PLACEHOLDERS` / | `PI_HARDWARE_CURSOR=1` | Show the real hardware cursor instead of a rendered one. | | `PI_NOTIFICATIONS=off\|0\|false` | Suppress terminal notifications. | | `PI_DEBUG_REDRAW=1` | Log the chosen render intent + ledger state per frame to the debug log. | +| `PI_TUI_RESIZE_IN_PLACE=1\|0` | Force resize to repaint in place (no alt-screen borrow, no ED3 rewrap) on / off. Default-on for terminals that re-report size on alt-screen toggles (Warp). | Removed with the old engine: `PI_TUI_ED3_SAFE` (no ED3-risk lever exists), `PI_CLEAR_ON_SHRINK` (shrinks always clear exactly), `PI_TUI_DEBUG` (per-render diff --git a/docs/tui-runtime-internals.md b/docs/tui-runtime-internals.md index 1ae283f77..5e23f43fc 100644 --- a/docs/tui-runtime-internals.md +++ b/docs/tui-runtime-internals.md @@ -142,6 +142,7 @@ Effects: - A resize is an explicit user gesture: outside multiplexers the engine erases and replays (`ED3` + full paint) so history rewraps at the new geometry; the commit ledger restarts from the replayed frame. - Inside terminal multiplexers, resize repaints the visible window in place after a settle debounce (issue #2088); pane history keeps its old wrap, like any shell output, because pane scrollback cannot be erased safely. +- Terminals that re-report their size when the alternate screen buffer is toggled (Warp reports a height one row different for the alt buffer) take the in-place path too. The non-multiplexer fast path borrows the alternate screen for drag frames, so on these terminals each alt enter/leave emits a fresh resize event, which re-enters the fast path — a self-sustaining loop that floods ED3 full repaints with stable geometry. `resizeRepaintsInPlace()` (covering multiplexers and these terminals; overridable via `PI_TUI_RESIZE_IN_PLACE`) routes them through the in-place repaint, which never touches the alt buffer. - Overlay visibility can depend on terminal dimensions (`OverlayOptions.visible`); focus is corrected when overlays become non-visible after resize. ## Streaming and incremental UI updates diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 1d7721716..64dbb60e7 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed a self-sustaining resize-redraw storm in Warp: the non-multiplexer resize fast path borrows the alternate screen, and Warp re-reports a one-row-different size whenever the alt buffer is toggled, so each drag frame fed back a fresh resize event and the TUI flooded ED3 full repaints with stable geometry. Resize now repaints in place (no alt-screen borrow, no ED3 rewrap) on terminals that re-report size on alt-screen toggles, matching the multiplexer path. Overridable with `PI_TUI_RESIZE_IN_PLACE=1|0`. + ## [16.0.1] - 2026-06-15 ### Added diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 4ae93663b..cae8cf400 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -379,6 +379,35 @@ function isMultiplexerSession(): boolean { return term.startsWith("tmux") || term.startsWith("screen"); } +/** + * Terminals that re-report their size whenever the alternate screen buffer is + * toggled. The non-multiplexer resize fast path ({@link TUI.#beginResizeViewport}) + * borrows the alternate screen for throwaway drag frames; on these terminals + * entering/leaving the alt buffer emits a fresh SIGWINCH (Warp reports a height + * one row different for the alt buffer), which re-enters the fast path — a + * self-sustaining resize loop that floods ED3 full repaints even though the + * geometry never actually changes. Routing them through the in-place + * (multiplexer) resize path never touches the alt buffer, breaking the loop. + * + * `PI_TUI_RESIZE_IN_PLACE=1|0` forces this on/off for any terminal. + */ +function reportsSizeOnAltScreenToggle(): boolean { + const override = Bun.env.PI_TUI_RESIZE_IN_PLACE; + if (override === "0" || override === "false") return false; + if (override === "1" || override === "true") return true; + return Bun.env.TERM_PROGRAM?.toLowerCase() === "warpterminal"; +} + +/** + * Resize should repaint the visible window in place — no alternate-screen + * borrow, no ED3 scrollback rewrap — for multiplexer panes and for terminals + * that loop on alt-screen toggles. The tradeoff is identical to a multiplexer: + * scrollback above the window keeps its old wrap instead of being re-flowed. + */ +function resizeRepaintsInPlace(): boolean { + return isMultiplexerSession() || reportsSizeOnAltScreenToggle(); +} + /** * Options for overlay positioning and sizing. * Values can be absolute numbers or percentage strings (e.g., "50%"). @@ -1293,7 +1322,7 @@ export class TUI extends Container { // `#resizeEventPending` is set first so the eventual render still // classifies as a resize. this.#resizeEventPending = true; - if (!isMultiplexerSession()) { + if (!resizeRepaintsInPlace()) { // 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. @@ -2377,13 +2406,15 @@ export class TUI extends Container { Math.min(frameLength, snapshotSafeEnd ?? byteStableBoundary), ); - // 4. Classify. A resize is an explicit user gesture: outside a - // multiplexer it erases and replays so history rewraps at the new - // geometry (the reader snapped to the bottom just dragged the window); - // inside one the pane reflows its own history, so repaint in place. + // 4. Classify. A resize is an explicit user gesture: normally the engine + // erases and replays so history rewraps at the new geometry (the reader + // snapped to the bottom just dragged the window). Multiplexer panes — and + // terminals that re-report size on alt-screen toggles — instead repaint in + // place, because an ED3 rewrap is unsafe (pane scrollback / alt-screen + // feedback loop), so committed history keeps its old wrap. const firstPaint = !this.#hasEverRendered; const replaceRequested = this.#clearScrollbackOnNextRender; - const geometryRebuild = geometryChanged && !isMultiplexerSession(); + const geometryRebuild = geometryChanged && !resizeRepaintsInPlace(); const fullPaint = firstPaint || replaceRequested || geometryRebuild; let windowTop: number; let chunkTo: number; @@ -2504,7 +2535,7 @@ export class TUI extends Container { windowTop, prevWindowTop, prevHardwareCursorRow, - forceWindowRewrite: this.#forceViewportRepaintOnNextRender || (geometryChanged && isMultiplexerSession()), + forceWindowRewrite: this.#forceViewportRepaintOnNextRender || (geometryChanged && resizeRepaintsInPlace()), }); for (let i = this.#committedPrefix.length; i < chunkTo; i++) { this.#committedPrefix.push(rawFrame[i] ?? ""); diff --git a/packages/tui/test/issue-2088-repro.test.ts b/packages/tui/test/issue-2088-repro.test.ts index c24ee8d4c..ab6d8e21d 100644 --- a/packages/tui/test/issue-2088-repro.test.ts +++ b/packages/tui/test/issue-2088-repro.test.ts @@ -106,6 +106,10 @@ const NO_MULTIPLEXER_ENV: Record = { STY: undefined, ZELLIJ: undefined, TERM: "xterm-256color", + // Resize classification also keys off TERM_PROGRAM (Warp takes the in-place + // path), so neutralize it to keep this direct-terminal case deterministic. + TERM_PROGRAM: undefined, + PI_TUI_RESIZE_IN_PLACE: undefined, }; describe("issue #2088: tmux pane-resize race produces viewport flash", () => { diff --git a/packages/tui/test/overlay-scroll.test.ts b/packages/tui/test/overlay-scroll.test.ts index b5f19cc34..915d5092c 100644 --- a/packages/tui/test/overlay-scroll.test.ts +++ b/packages/tui/test/overlay-scroll.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { type Component, CURSOR_MARKER, TUI } from "@oh-my-pi/pi-tui"; import { VirtualTerminal } from "./virtual-terminal"; @@ -120,6 +120,25 @@ async function settleResize(term: VirtualTerminal): Promise { } describe("TUI overlays", () => { + let savedTerminalEnv: Record = {}; + beforeEach(() => { + // A resize on Warp takes the in-place path (no ED3), so neutralize the + // ambient terminal identity to keep the direct-terminal resize/scrollback + // assertions below deterministic on any dev machine. + for (const key of ["TERM_PROGRAM", "PI_TUI_RESIZE_IN_PLACE"]) { + savedTerminalEnv[key] = Bun.env[key]; + delete Bun.env[key]; + } + }); + afterEach(() => { + for (const key in savedTerminalEnv) { + const value = savedTerminalEnv[key]; + if (value === undefined) delete Bun.env[key]; + else Bun.env[key] = value; + } + savedTerminalEnv = {}; + }); + it("does not scroll the terminal when an overlay is shown with a large historical working area", async () => { const term = new VirtualTerminal(80, 24); const tui = new TUI(term); diff --git a/packages/tui/test/render-regressions.test.ts b/packages/tui/test/render-regressions.test.ts index 3fed42895..fffd6820d 100644 --- a/packages/tui/test/render-regressions.test.ts +++ b/packages/tui/test/render-regressions.test.ts @@ -188,10 +188,18 @@ async function withEnvPatch(patch: Record, run: ( describe("TUI terminal-state regressions", () => { let monotonicNow = 0; + let savedTerminalEnv: Record = {}; // Keep TUI's ~33ms render throttle deterministic without sleeping a real frame per render. beforeEach(() => { monotonicNow = 0; + // Resize classification now depends on TERM_PROGRAM (Warp takes the + // in-place path), so neutralize the ambient terminal identity to keep + // these direct-terminal assertions deterministic on any dev machine. + for (const key of ["TERM_PROGRAM", "PI_TUI_RESIZE_IN_PLACE"]) { + savedTerminalEnv[key] = Bun.env[key]; + delete Bun.env[key]; + } vi.spyOn(performance, "now").mockImplementation(() => { monotonicNow += 40; return monotonicNow; @@ -199,6 +207,12 @@ describe("TUI terminal-state regressions", () => { }); afterEach(() => { + for (const key in savedTerminalEnv) { + const value = savedTerminalEnv[key]; + if (value === undefined) delete Bun.env[key]; + else Bun.env[key] = value; + } + savedTerminalEnv = {}; vi.restoreAllMocks(); }); diff --git a/packages/tui/test/resize-viewport-defer.test.ts b/packages/tui/test/resize-viewport-defer.test.ts index a91b99632..c3ef58b83 100644 --- a/packages/tui/test/resize-viewport-defer.test.ts +++ b/packages/tui/test/resize-viewport-defer.test.ts @@ -17,7 +17,16 @@ import { VirtualTerminal } from "./virtual-terminal"; // 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 }; +const NO_MULTIPLEXER_ENV: Record = { + TMUX: undefined, + STY: undefined, + ZELLIJ: undefined, + // Pin terminal identity so the alt-screen fast-path assertions below are + // deterministic even when the suite runs inside Warp (which otherwise takes + // the in-place path — see the Warp describe block at the bottom). + TERM_PROGRAM: undefined, + PI_TUI_RESIZE_IN_PLACE: undefined, +}; const ALT_SCREEN_ENTER = "\x1b[?1049h"; const ALT_SCREEN_EXIT = "\x1b[?1049l"; @@ -364,3 +373,103 @@ describe("non-multiplexer resize viewport fast path", () => { }); }); }); + +describe("resize repaints in place on terminals that re-report size on alt-screen toggle (Warp)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + const WARP_ENV: Record = { ...NO_MULTIPLEXER_ENV, TERM_PROGRAM: "WarpTerminal" }; + + 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 }; + } + + // Warp reports a height one row different for the alternate screen buffer, so + // the alt-screen-borrowing fast path would toggle the buffer, receive a fresh + // SIGWINCH for free, and re-enter the fast path forever — a self-sustaining + // ED3 repaint storm with completely stable geometry. The in-place path never + // touches the alt buffer, so even a resize burst yields zero fast-path paints + // and zero scrollback erases, leaving no alt<->normal toggle to feed back on. + it("never borrows the alternate screen or emits ED3 across a resize burst", async () => { + await withEnvPatch(WARP_ENV, async () => { + const term = new VirtualTerminal(40, 10, 1000); + const { tui, scheduler } = makeTui(term); + try { + tui.start(); + await scheduler.flushImmediates(term); + + const writes = captureWrites(term); + + term.resize(60, 10); + await scheduler.flushImmediates(term); + term.resize(75, 10); + await scheduler.flushImmediates(term); + term.resize(80, 10); + await scheduler.flushImmediates(term); + + // The fast path is never entered: no viewport-only paints, no alt buffer. + expect(tui.resizeViewportActive).toBe(false); + expect(tui.resizeViewportPaints).toBe(0); + expect(writes.join("")).not.toContain(ALT_SCREEN_ENTER); + + // Settle the debounced in-place repaint: still no scrollback erase, + // so there is no alt<->normal toggle for Warp to re-trigger on. + await scheduler.flushAll(term); + expect(eraseScrollbackCount(writes)).toBe(0); + expect(writes.join("")).not.toContain(ALT_SCREEN_ENTER); + expect(visible(term).at(-1)).toBe("b14-y"); + } finally { + tui.stop(); + } + }); + }); + + it("PI_TUI_RESIZE_IN_PLACE=0 opts Warp back into the alt-screen fast path", async () => { + await withEnvPatch({ ...WARP_ENV, PI_TUI_RESIZE_IN_PLACE: "0" }, async () => { + const term = new VirtualTerminal(40, 10, 1000); + const { tui, scheduler } = makeTui(term); + try { + tui.start(); + await scheduler.flushImmediates(term); + + const writes = captureWrites(term); + term.resize(60, 10); + await scheduler.flushImmediates(term); + + expect(tui.resizeViewportActive).toBe(true); + expect(writes.join("")).toContain(ALT_SCREEN_ENTER); + } finally { + tui.stop(); + } + }); + }); + + it("PI_TUI_RESIZE_IN_PLACE=1 forces the in-place path on an ordinary terminal", async () => { + await withEnvPatch({ ...NO_MULTIPLEXER_ENV, PI_TUI_RESIZE_IN_PLACE: "1" }, async () => { + const term = new VirtualTerminal(40, 10, 1000); + const { tui, scheduler } = makeTui(term); + try { + tui.start(); + await scheduler.flushImmediates(term); + + const writes = captureWrites(term); + term.resize(60, 10); + await scheduler.flushImmediates(term); + + expect(tui.resizeViewportActive).toBe(false); + expect(tui.resizeViewportPaints).toBe(0); + expect(writes.join("")).not.toContain(ALT_SCREEN_ENTER); + + await scheduler.flushAll(term); + expect(eraseScrollbackCount(writes)).toBe(0); + } finally { + tui.stop(); + } + }); + }); +}); diff --git a/packages/tui/test/streaming-scrollback-defer.test.ts b/packages/tui/test/streaming-scrollback-defer.test.ts index 9e6c8294b..2758604e7 100644 --- a/packages/tui/test/streaming-scrollback-defer.test.ts +++ b/packages/tui/test/streaming-scrollback-defer.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { type Component, type NativeScrollbackCommittedRows, @@ -123,6 +123,25 @@ function rows(prefix: string, count: number): string[] { } describe("streaming scrollback defer", () => { + let savedTerminalEnv: Record = {}; + beforeEach(() => { + // A resize on Warp takes the in-place path (no ED3), so neutralize the + // ambient terminal identity to keep the direct-terminal scrollback + // assertions below deterministic on any dev machine. + for (const key of ["TERM_PROGRAM", "PI_TUI_RESIZE_IN_PLACE"]) { + savedTerminalEnv[key] = Bun.env[key]; + delete Bun.env[key]; + } + }); + afterEach(() => { + for (const key in savedTerminalEnv) { + const value = savedTerminalEnv[key]; + if (value === undefined) delete Bun.env[key]; + else Bun.env[key] = value; + } + savedTerminalEnv = {}; + }); + it("keeps mutable live-region head rows out of native scrollback", async () => { if (process.platform === "win32") return; const term = new VirtualTerminal(20, 4);