diff --git a/CHANGELOG.md b/CHANGELOG.md index 544c8c31b4a..b43c507a018 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ Docs: https://docs.openclaw.ai +## Unreleased + +### Fixes + +- CLI/progress: suppress nested progress spinners and line clears while TUI input owns raw stdin, so Crestodian `/status` no longer disturbs the active input row. (#75003) Thanks @velvet-shark. + ## 2026.4.29 ### Highlights diff --git a/src/cli/progress.test.ts b/src/cli/progress.test.ts index 2f9004ee40c..3f7dd673c43 100644 --- a/src/cli/progress.test.ts +++ b/src/cli/progress.test.ts @@ -1,5 +1,22 @@ import { describe, expect, it, vi } from "vitest"; -import { createCliProgress } from "./progress.js"; +import { createCliProgress, shouldUseInteractiveProgressSpinner } from "./progress.js"; + +function withStdinIsRaw(isRaw: boolean, run: () => T): T { + const original = Object.getOwnPropertyDescriptor(process.stdin, "isRaw"); + Object.defineProperty(process.stdin, "isRaw", { + configurable: true, + value: isRaw, + }); + try { + return run(); + } finally { + if (original) { + Object.defineProperty(process.stdin, "isRaw", original); + } else { + Reflect.deleteProperty(process.stdin, "isRaw"); + } + } +} describe("cli progress", () => { it("logs progress when non-tty and fallback=log", () => { @@ -43,4 +60,45 @@ describe("cli progress", () => { expect(write).not.toHaveBeenCalled(); }); + + it("does not use readline-backed spinners while raw TUI input is active", () => { + expect( + shouldUseInteractiveProgressSpinner({ + streamIsTty: true, + stdinIsRaw: true, + }), + ).toBe(false); + }); + + it("keeps the normal interactive spinner for regular tty commands", () => { + expect( + shouldUseInteractiveProgressSpinner({ + streamIsTty: true, + stdinIsRaw: false, + }), + ).toBe(true); + }); + + it("does not write terminal controls when raw TUI input suppresses the default spinner", () => { + const writes: string[] = []; + const stream = { + isTTY: true, + write: vi.fn((chunk: string) => { + writes.push(chunk); + }), + } as unknown as NodeJS.WriteStream; + + withStdinIsRaw(true, () => { + const progress = createCliProgress({ + label: "Scanning", + total: 2, + stream, + }); + progress.setLabel("Still scanning"); + progress.tick(); + progress.done(); + }); + + expect(writes).toEqual([]); + }); }); diff --git a/src/cli/progress.ts b/src/cli/progress.ts index 4ec8f58211a..742bb958752 100644 --- a/src/cli/progress.ts +++ b/src/cli/progress.ts @@ -33,6 +33,15 @@ export type ProgressTotalsUpdate = { label?: string; }; +export function shouldUseInteractiveProgressSpinner(params: { + fallback?: ProgressOptions["fallback"]; + streamIsTty?: boolean; + stdinIsRaw?: boolean; +}): boolean { + const spinnerRequested = params.fallback === undefined || params.fallback === "spinner"; + return spinnerRequested && params.streamIsTty === true && params.stdinIsRaw !== true; +} + const noopReporter: ProgressReporter = { setLabel: () => {}, setPercent: () => {}, @@ -57,8 +66,16 @@ export function createCliProgress(options: ProgressOptions): ProgressReporter { const delayMs = typeof options.delayMs === "number" ? options.delayMs : DEFAULT_DELAY_MS; const canOsc = isTty && supportsOscProgress(process.env, isTty); - const allowSpinner = isTty && (options.fallback === undefined || options.fallback === "spinner"); + const stdinIsRaw = process.stdin.isRaw; + const allowSpinner = shouldUseInteractiveProgressSpinner({ + fallback: options.fallback, + streamIsTty: isTty, + stdinIsRaw, + }); const allowLine = isTty && options.fallback === "line"; + if (isTty && stdinIsRaw && (options.fallback === undefined || options.fallback === "spinner")) { + return noopReporter; + } let started = false; let label = options.label;