diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 2cd34b89b..e4a4909f6 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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 diff --git a/packages/tui/src/components/loader.ts b/packages/tui/src/components/loader.ts index 65a5c1292..387d5c4c7 100644 --- a/packages/tui/src/components/loader.ts +++ b/packages/tui/src/components/loader.ts @@ -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); } diff --git a/packages/tui/test/loader.test.ts b/packages/tui/test/loader.test.ts index 77c341a1f..e200e1b24 100644 --- a/packages/tui/test/loader.test.ts +++ b/packages/tui/test/loader.test.ts @@ -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);