Merge PR #2775: fix(tui): reduce loader repaint flicker (@roboomp)

This commit is contained in:
can1357
2026-06-19 00:57:45 +02:00
3 changed files with 31 additions and 3 deletions
+5
View File
@@ -67,6 +67,11 @@
- Fixed CMUX sessions being treated as direct terminals during resize/reset because they do not set `TMUX`/`STY`/`ZELLIJ` and may run with `TERM=dumb`; the renderer now treats CMUX workspace/surface env markers as multiplexer signals and preserves pane scrollback instead of emitting ED3 (`CSI 3 J`).
- 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`.
### Fixed
- Fixed animated working-message loader frames repainting at 30fps on terminals without synchronized-output support, which could cause visible flicker during normal prompt rendering ([#2771](https://github.com/can1357/oh-my-pi/issues/2771)).
## [16.0.1] - 2026-06-15
### Added
+5 -2
View File
@@ -57,12 +57,15 @@ export class Loader extends Text {
this.#intervalId = setInterval(() => {
const now = performance.now();
const elapsed = now - this.#lastSpinnerTick;
if (elapsed >= SPINNER_ADVANCE_MS) {
const shouldAdvanceSpinner = elapsed >= SPINNER_ADVANCE_MS;
if (shouldAdvanceSpinner) {
const steps = Math.floor(elapsed / SPINNER_ADVANCE_MS);
this.#currentFrame = (this.#currentFrame + steps) % this.#frames.length;
this.#lastSpinnerTick += steps * SPINNER_ADVANCE_MS;
}
this.#updateDisplay();
if (shouldAdvanceSpinner || this.#ui?.synchronizedOutput === true) {
this.#updateDisplay();
}
}, intervalMs);
}
+21 -1
View File
@@ -92,7 +92,7 @@ describe("Loader component", () => {
it("requests render when animated message bytes change between spinner frames", () => {
vi.useFakeTimers();
setSystemTime(new Date(1_000));
const ui = { requestComponentRender: vi.fn() } as unknown as TUI;
const ui = { synchronizedOutput: true, requestComponentRender: vi.fn() } as unknown as TUI;
const colorMessage = ((text: string) => `${text}-${Date.now()}`) as LoaderMessageColorFn & { animated: true };
colorMessage.animated = true;
const loader = new Loader(ui, text => text, colorMessage, "Checking", ["0"]);
@@ -106,6 +106,26 @@ describe("Loader component", () => {
loader.stop();
});
it("holds animated message-only frames when synchronized output is unavailable", () => {
vi.useFakeTimers();
setSystemTime(new Date(1_000));
const ui = { synchronizedOutput: false, requestComponentRender: vi.fn() } as unknown as TUI;
const colorMessage = ((text: string) => `${text}-${Date.now()}`) as LoaderMessageColorFn & { animated: true };
colorMessage.animated = true;
const loader = new Loader(ui, text => text, colorMessage, "Checking", ["0", "1"]);
expect(ui.requestComponentRender).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(34);
expect(ui.requestComponentRender).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(67);
expect(ui.requestComponentRender).toHaveBeenCalledTimes(2);
expect(loader.render(40).join("\n")).toContain("1 Checking-");
loader.stop();
});
it("dispose() stops the animation so no further renders are scheduled", async () => {
const term = new VirtualTerminal(20, 4);
const tui = new TUI(term);