diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1cb7c55d0..16e8e423a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/utils/title-generator.ts b/packages/coding-agent/src/utils/title-generator.ts index 1b20aaa37..c138fc710 100644 --- a/packages/coding-agent/src/utils/title-generator.ts +++ b/packages/coding-agent/src/utils/title-generator.ts @@ -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(); diff --git a/packages/coding-agent/test/terminal-title-state.test.ts b/packages/coding-agent/test/terminal-title-state.test.ts index d5d84c75f..44a1204ce 100644 --- a/packages/coding-agent/test/terminal-title-state.test.ts +++ b/packages/coding-agent/test/terminal-title-state.test.ts @@ -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([]); + }); }); diff --git a/packages/coding-agent/test/title-generator.test.ts b/packages/coding-agent/test/title-generator.test.ts index a8a8317a1..0f631fbfc 100644 --- a/packages/coding-agent/test/title-generator.test.ts +++ b/packages/coding-agent/test/title-generator.test.ts @@ -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; diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index c105ee533..d8281a7dc 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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 diff --git a/packages/tui/src/components/loader.ts b/packages/tui/src/components/loader.ts index 8c5c0daaf..637a0320f 100644 --- a/packages/tui/src/components/loader.ts +++ b/packages/tui/src/components/loader.ts @@ -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; diff --git a/packages/tui/test/loader.test.ts b/packages/tui/test/loader.test.ts index efcf6ac3c..5cbbf04d0 100644 --- a/packages/tui/test/loader.test.ts +++ b/packages/tui/test/loader.test.ts @@ -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();