From ee7e1e3ae33cb3cd4e733ff75d25544047f0ebb2 Mon Sep 17 00:00:00 2001 From: roboomp Date: Sun, 7 Jun 2026 11:14:29 +0000 Subject: [PATCH] fix(tui): preserved tmux viewport on offscreen shrink Skipped repainting tmux panes when an offscreen shrink leaves the visible tail unchanged, and added regression coverage for the no-content-bytes contract. Fixes #2046 --- packages/tui/CHANGELOG.md | 2 ++ packages/tui/src/tui.ts | 15 ++++++++- packages/tui/test/render-regressions.test.ts | 34 ++++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 56903eddc..3274aa894 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -29,6 +29,8 @@ ### Fixed +- Fixed tmux offscreen-shrink frames to skip repainting when the visible tail is unchanged, avoiding intermittent blank/refresh flashes in pane terminals ([#2046](https://github.com/can1357/oh-my-pi/issues/2046)). + - Fixed `Loader` text updates to skip identical messages and preserve the rendered `Text` cache instead of invalidating it every timer tick. - Fixed fullscreen overlay alt-frame rendering to reuse the current line-preparation path instead of calling removed fitting helpers. diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index e71ee6818..68dfc9d8e 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -2049,7 +2049,9 @@ export class TUI extends Container { newLines.length < this.#previousLines.length && naturalViewportTop !== prevViewportTop ) { - return { kind: "viewportRepaint" }; + return this.#bottomAnchoredViewportUnchanged(newLines, height) + ? { kind: "deferredMutation" } + : { kind: "viewportRepaint" }; } // Direct-input shrink can also move the natural viewport upward even when @@ -2437,6 +2439,17 @@ export class TUI extends Container { return { kind: "liveRegionPinned", appendFrom, appendTo, renderViewportTop }; } + #bottomAnchoredViewportUnchanged(newLines: string[], height: number): boolean { + const previousViewportTop = Math.max(0, this.#previousLines.length - height); + const newViewportTop = Math.max(0, newLines.length - height); + for (let row = 0; row < height; row++) { + if ((newLines[newViewportTop + row] ?? "") !== (this.#previousLines[previousViewportTop + row] ?? "")) { + return false; + } + } + return true; + } + #planDeferredTailRepaint(newLines: string[], prevViewportTop: number, height: number): RenderIntent { const row = prevViewportTop + height - 1; if (row < 0 || row >= this.#previousLines.length || newLines.length !== this.#previousLines.length) { diff --git a/packages/tui/test/render-regressions.test.ts b/packages/tui/test/render-regressions.test.ts index 79e6385f7..29ff72ace 100644 --- a/packages/tui/test/render-regressions.test.ts +++ b/packages/tui/test/render-regressions.test.ts @@ -1469,6 +1469,40 @@ describe("TUI terminal-state regressions", () => { }); }); + it("tmux: offscreen shrink preserving the visible tail emits no repaint bytes", async () => { + await withEnvPatch({ TMUX: "1", STY: undefined, ZELLIJ: undefined }, async () => { + const term = new UnknownViewportTerminal(40, 4, 10_000); + const tui = new TUI(term); + const component = new MutableLinesComponent([ + "old-0", + "remove-me", + "old-2", + "old-3", + "tail-0", + "tail-1", + "tail-2", + "tail-3", + ]); + tui.addChild(component); + + try { + tui.start(); + await settle(term); + expect(visible(term)).toEqual(["tail-0", "tail-1", "tail-2", "tail-3"]); + + const writes = captureWrites(term); + component.setLines(["old-0", "old-2", "old-3", "tail-0", "tail-1", "tail-2", "tail-3"]); + tui.requestRender(); + await settle(term); + + expect(visible(term)).toEqual(["tail-0", "tail-1", "tail-2", "tail-3"]); + expect(writes).toEqual([]); + } finally { + tui.stop(); + } + }); + }); + // Root cause family: the dirty/replay machinery assumes native scrollback // can be cleared and rebuilt, which is never true inside a multiplexer — // tmux owns pane history, reflows it on resize itself, and a "replay" can