fix(tui): bounded WSL idle animation CPU

- Removed the Loader backpressure cap so slow ConPTY paints retain the documented proportional duty cycle.

- Made WSL terminal-title working state static to avoid a second periodic OSC write loop.

Fixes #8012
This commit is contained in:
roboomp
2026-08-08 13:17:01 +00:00
parent 08819b279c
commit a6aa462a60
7 changed files with 60 additions and 31 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed terminal-title spinner writes consuming CPU during WSL/ConPTY agent waits by using the same static working separator as native Windows ([#8012](https://github.com/can1357/oh-my-pi/issues/8012)).
## [17.2.11] - 2026-08-07
### Added
@@ -6,6 +6,7 @@ import * as path from "node:path";
import { type Api, type AssistantMessage, completeSimple, type Model } from "@oh-my-pi/pi-ai";
import { StreamMarkupHealing } from "@oh-my-pi/pi-ai/utils/stream-markup-healing";
import { isConPTYHosted } from "@oh-my-pi/pi-tui";
import { isTerminalHeadless, logger, prompt } from "@oh-my-pi/pi-utils";
import type { ModelRegistry } from "../config/model-registry";
@@ -533,6 +534,7 @@ function emitTerminalTitle(): void {
terminalTitleRuntime.state,
terminalTitleRuntime.frame,
terminalTitleRuntime.enabled,
isConPTYHosted() ? "win32" : process.platform,
);
setTerminalTitle(next);
}
@@ -543,7 +545,7 @@ function stopTerminalTitleSpinner(): void {
}
function startTerminalTitleSpinner(): void {
if (process.platform === "win32" || terminalTitleRuntime.timer || !process.stdout.isTTY) return;
if (isConPTYHosted() || terminalTitleRuntime.timer || !process.stdout.isTTY) return;
terminalTitleRuntime.timer = setInterval(() => {
terminalTitleRuntime.frame = (terminalTitleRuntime.frame + 1) % TITLE_SPINNER_FRAMES.length;
emitTerminalTitle();
@@ -5,6 +5,7 @@ import {
setSessionTerminalTitle,
setTerminalTitleState,
} from "@oh-my-pi/pi-coding-agent/utils/title-generator";
import { isConPTYHosted } from "@oh-my-pi/pi-tui";
import { setTerminalHeadless } from "@oh-my-pi/pi-utils";
import { mockWindowsConsoleTitle, type WindowsConsoleTitleMock } from "./terminal-title-test-utils";
@@ -117,31 +118,28 @@ describe("disposeTerminalTitleState", () => {
vi.useRealTimers();
});
it.skipIf(process.platform === "win32")(
"stops the spinner so no further OSC-title write fires on a tick after dispose",
() => {
// CONTRACT (the fix): entering `working` arms the spinner interval; once
// `disposeTerminalTitleState()` runs, advancing the clock across many tick
// periods must produce ZERO additional OSC-title writes. A pending tick
// re-emitting the title after teardown is exactly the shell-tab leak.
setTerminalTitleState("working");
it.skipIf(isConPTYHosted())("stops the spinner so no further OSC-title write fires on a tick after dispose", () => {
// CONTRACT (the fix): entering `working` arms the spinner interval; once
// `disposeTerminalTitleState()` runs, advancing the clock across many tick
// periods must produce ZERO additional OSC-title writes. A pending tick
// re-emitting the title after teardown is exactly the shell-tab leak.
setTerminalTitleState("working");
// Control: BEFORE dispose the interval is live — advancing the clock across
// several 80ms tick periods DOES emit further OSC-title writes (proves the
// timer was actually running, so the post-dispose silence is meaningful and
// not a headless/TTY misconfiguration masking all writes).
writes.length = 0;
vi.advanceTimersByTime(400);
const ticksWhileLive = writes.filter(payload => payload.includes(OSC_TITLE_SEQ)).length;
expect(ticksWhileLive).toBeGreaterThan(0);
// Control: BEFORE dispose the interval is live — advancing the clock across
// several 80ms tick periods DOES emit further OSC-title writes (proves the
// timer was actually running, so the post-dispose silence is meaningful and
// not a headless/TTY misconfiguration masking all writes).
writes.length = 0;
vi.advanceTimersByTime(400);
const ticksWhileLive = writes.filter(payload => payload.includes(OSC_TITLE_SEQ)).length;
expect(ticksWhileLive).toBeGreaterThan(0);
// The fix under test.
disposeTerminalTitleState();
// The fix under test.
disposeTerminalTitleState();
// After dispose: advance far past many tick periods. No tick may fire.
writes.length = 0;
vi.advanceTimersByTime(4000);
expect(writes.filter(payload => payload.includes(OSC_TITLE_SEQ))).toEqual([]);
},
);
// After dispose: advance far past many tick periods. No tick may fire.
writes.length = 0;
vi.advanceTimersByTime(4000);
expect(writes.filter(payload => payload.includes(OSC_TITLE_SEQ))).toEqual([]);
});
});
@@ -730,6 +730,28 @@ describe("terminal title runtime", () => {
}
});
it("keeps the working title static under WSL", () => {
const originalPlatform = process.platform;
const originalWslDistro = process.env.WSL_DISTRO_NAME;
try {
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
process.env.WSL_DISTRO_NAME = "Ubuntu";
setSessionTerminalTitle("wsl-project");
writes.length = 0;
setTerminalTitleState("working");
expect(emittedTitles()).toEqual(["π : wsl-project"]);
writes.length = 0;
vi.advanceTimersByTime(400);
expect(writes).toEqual([]);
} finally {
if (originalWslDistro === undefined) delete process.env.WSL_DISTRO_NAME;
else process.env.WSL_DISTRO_NAME = originalWslDistro;
Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
}
});
it("uses SetConsoleTitleW without an OSC write on Windows", () => {
const originalPlatform = process.platform;
const native = windowsTitleMock;
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed slow Loader paints exceeding their cost-aware CPU duty cycle on WSL/ConPTY when a 200 ms backpressure cap was shorter than the proportional delay ([#8012](https://github.com/can1357/oh-my-pi/issues/8012)).
## [17.2.11] - 2026-08-07
### Fixed
+2 -3
View File
@@ -4,7 +4,6 @@ import { Text } from "./text";
const RENDER_INTERVAL_MS = 1000 / 30;
const SPINNER_ADVANCE_MS = 80;
const MAX_RENDER_BACKPRESSURE_MS = 200;
const RENDER_BACKPRESSURE_MULTIPLIER = 9;
type ColorFn = (str: string) => string;
@@ -144,8 +143,8 @@ export class Loader extends Text {
if (this.#intervalId !== timer) return;
const cadenceDelayMs = Math.max(0, intervalMs - frameCostMs);
// Idle for nine times the paint cost to keep animation at or below
// 10% CPU, while cheap frames retain their original cadence.
const backpressureDelayMs = Math.min(MAX_RENDER_BACKPRESSURE_MS, frameCostMs * RENDER_BACKPRESSURE_MULTIPLIER);
// 10% CPU, even when a slow ConPTY write exceeds the normal cadence.
const backpressureDelayMs = frameCostMs * RENDER_BACKPRESSURE_MULTIPLIER;
this.#scheduleTick(intervalMs, Math.max(cadenceDelayMs, backpressureDelayMs));
}, delayMs);
this.#intervalId = timer;
+3 -3
View File
@@ -140,7 +140,7 @@ describe("Loader component", () => {
const ui = {
synchronizedOutput: true,
requestDirectWrite: vi.fn(() => {
now += 20;
now += 40;
}),
requestComponentRender: vi.fn(),
};
@@ -153,9 +153,9 @@ describe("Loader component", () => {
vi.advanceTimersByTime(34);
expect(ui.requestDirectWrite).toHaveBeenCalledTimes(2);
vi.advanceTimersByTime(170);
vi.advanceTimersByTime(200);
expect(ui.requestDirectWrite).toHaveBeenCalledTimes(2);
vi.advanceTimersByTime(10);
vi.advanceTimersByTime(160);
expect(ui.requestDirectWrite).toHaveBeenCalledTimes(3);
loader.stop();