diff --git a/src/utils/idle-detector.ts b/src/utils/idle-detector.ts index e776f7d9..debe2075 100644 --- a/src/utils/idle-detector.ts +++ b/src/utils/idle-detector.ts @@ -9,6 +9,10 @@ const SPINNER_RE = /[·✢✳✶✻✽⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏■⬝]/; const QUIESCENCE_MS = 2_000; /** Spinner guard — don't declare idle if spinner seen within this window */ const SPINNER_GUARD_MS = 3_000; +/** Short grace window after readyPattern fires (ms) — covers a transient + * redraw of the input box, but is NOT reset by arbitrary TUI status-bar + * churn the way QUIESCENCE_MS is. Matches completionPattern's 500ms. */ +const READY_GRACE_MS = 500; export class IdleDetector { private outputTail = ''; @@ -77,6 +81,28 @@ export class IdleDetector { // When readyPattern is set, suppress quiescence until the input prompt appears. if (this.readyPattern && !this.readySeen) return; + // Once the input prompt is visible, the CLI is visually "ready" — don't + // make the user wait QUIESCENCE_MS (2s) on top of the spawn time. A short + // grace window (READY_GRACE_MS, 500ms) covers a transient redraw of the + // input box. The grace timer is NOT reset by arbitrary TUI status-bar + // churn (every ~1ms in Hermes/Codex TUIs): once the prompt is up, idle + // is the only sensible state from a user-input perspective. Spinner chars + // in the post-ready output are already excluded by the `!readySeen` guard + // at the top of feed() (status-bar ·· is not a real spinner) — we do not + // re-check them here. + if (this.readyPattern && this.readySeen && !this.completionPattern) { + // Only arm the grace timer once. If it's already armed, leave it + // running — TUI status-bar redraws every ~1ms in Hermes/Codex would + // otherwise clear+reset the timer indefinitely, recreating the + // original 13s detection delay. + if (this.quiescenceTimer) return; + this.quiescenceTimer = setTimeout(() => { + this.quiescenceTimer = null; + if (!this.isIdle) this.markIdle(); + }, READY_GRACE_MS); + return; + } + this.clearTimer(); this.quiescenceTimer = setTimeout(() => this.quiescenceCheck(), QUIESCENCE_MS); } @@ -135,6 +161,6 @@ export class IdleDetector { private stripAnsi(str: string): string { return str .replace(/\x1b\[(\d*)C/g, (_m, n) => ' '.repeat(Number(n) || 1)) - .replace(/\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[()][0-9A-B]|\x1b\[[\?]?[0-9;]*[hlmsuJ]/g, ''); + .replace(/\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[()][0-9A-B]|\x1b\[\??[0-9;]*[hlmsuJ]/g, ''); } } diff --git a/test/idle-detector.test.ts b/test/idle-detector.test.ts index 2cbf7afa..816b98d2 100644 --- a/test/idle-detector.test.ts +++ b/test/idle-detector.test.ts @@ -721,3 +721,95 @@ describe('IdleDetector: CoCo readyPattern compatibility', () => { detector.dispose(); }); }); + +// ─── readyPattern grace timer (regression: Hermes/Codex TUI redraws) ────── + +describe('IdleDetector: readyPattern grace timer for TUI redraws', () => { + beforeEach(() => { vi.useFakeTimers(); }); + afterEach(() => { vi.useRealTimers(); }); + + // Hermes TUI redraws status-bar chars every ~1ms for 11s after ❯ appears. + // With the legacy code path (readySeen → 2000ms QUIESCENCE), each redraw + // reset the timer and idle detection took 13s. The grace timer (500ms) + // is armed once and NOT reset by subsequent feeds, so the post-❯ redraw + // storm does not delay the idle signal. + + it('fires idle within 500ms of readyPattern, even with continuous TUI redraws', () => { + const detector = new IdleDetector(makeCli({ readyPattern: /❯/ })); + const cb = vi.fn(); + detector.onIdle(cb); + + detector.feed('initializing...'); + detector.feed('┊ loading skills ┊\n'); + detector.feed('❯ '); // ← readyPattern fires + + // Simulate 11s of 1ms-spaced TUI redraws (status bar updates) + for (let i = 0; i < 11_000; i++) { + vi.advanceTimersByTime(1); + detector.feed('┊\r'); + } + + // After 11000ms of redraws + 500ms grace we should be idle exactly once. + expect(cb).toHaveBeenCalledTimes(1); + detector.dispose(); + }); + + it('arms the grace timer only once (status-bar redraws do not re-arm it)', () => { + const detector = new IdleDetector(makeCli({ readyPattern: /❯/ })); + const cb = vi.fn(); + detector.onIdle(cb); + + detector.feed('❯ '); // readyPattern → arm 500ms grace + vi.advanceTimersByTime(100); // only 100ms in + detector.feed('┊ redraw ┊\n'); + vi.advanceTimersByTime(100); + detector.feed('┊ redraw ┊\n'); + vi.advanceTimersByTime(100); + detector.feed('┊ redraw ┊\n'); + vi.advanceTimersByTime(300); // total 600ms since readyPattern + + // Even though we got 3 redraws, the grace timer is NOT reset by them — + // it was armed at 0ms and fires at 500ms regardless. + expect(cb).toHaveBeenCalledTimes(1); + detector.dispose(); + }); + + it('fires idle at 500ms (not 2000ms QUIESCENCE) after readyPattern without completionPattern', () => { + // Verify the grace window is 500ms, not the 2000ms QUIESCENCE_MS that + // pre-fix this path used. + const detector = new IdleDetector(makeCli({ readyPattern: /❯/ })); + const cb = vi.fn(); + detector.onIdle(cb); + detector.feed('❯ '); + + vi.advanceTimersByTime(499); + expect(cb).toHaveBeenCalledTimes(0); + vi.advanceTimersByTime(2); // total 501ms + expect(cb).toHaveBeenCalledTimes(1); + detector.dispose(); + }); + + it('falls back to 2000ms QUIESCENCE when readyPattern AND completionPattern are both set', () => { + // Claude Code has both patterns: ❯ for input box, COMPLETION_RE for turn + // done. The completion pattern already has its own 500ms grace path, + // so the readyPattern grace path should NOT shadow it — completion + // pattern continues to control idle detection for turn boundaries. + const detector = new IdleDetector(makeCli({ + readyPattern: /❯/, + completionPattern: /DONE/, + })); + const cb = vi.fn(); + detector.onIdle(cb); + detector.feed('❯ '); + vi.advanceTimersByTime(600); // > READY_GRACE_MS, but no completion marker + // No completion marker yet → still not idle (the readyPattern grace + // path is skipped because completionPattern is set; we are in the + // normal quiescence path). + expect(cb).toHaveBeenCalledTimes(0); + + detector.feed('DONE'); + vi.advanceTimersByTime(600); // completion's own 500ms grace + expect(cb).toHaveBeenCalledTimes(1); + detector.dispose(); + }); +});