Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion src/utils/idle-detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '';
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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, '');
}
}
92 changes: 92 additions & 0 deletions test/idle-detector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});