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
This commit is contained in:
roboomp
2026-06-07 11:14:29 +00:00
parent b6d422ff65
commit ee7e1e3ae3
3 changed files with 50 additions and 1 deletions
+2
View File
@@ -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.
+14 -1
View File
@@ -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) {
@@ -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