diff --git a/src/session.ts b/src/session.ts index 391f6b87..3654d7d2 100644 --- a/src/session.ts +++ b/src/session.ts @@ -3465,7 +3465,8 @@ export class Session extends EventEmitter { private _armPaneSettle(isRestored: boolean): void { const applies = isRestored ? !getCli(this.mode)?.capabilities.workDetect : isExternalCliMode(this.mode); if (!applies) return; - this._promptCheckTimeout = setTimeout(() => this._settlePaneStartup(!isRestored), 3000); + const armedAt = Date.now(); + this._promptCheckTimeout = setTimeout(() => this._settlePaneStartup(!isRestored, armedAt), 3000); } /** @@ -3478,18 +3479,23 @@ export class Session extends EventEmitter { * never tripped `_markWorking()`, the later `_confirmIdle()` found the status * already idle and emitted nothing, so no browser ever learned the pane was ready. * - * A pane marked working by then is left alone only when its CLI declares - * `capabilities.workDetect`: that CLI's composer glyph arms `_confirmIdle()`, which - * reads the screen and ends the turn properly. For every other CLI this timer is the - * only thing that ever settles a fresh pane, so it settles it even if a stray spinner - * glyph in the launch paint latched `_isWorking`. + * A pane is left to `_confirmIdle()` only when its CLI declares + * `capabilities.workDetect` (its composer glyph arms that confirmation, which reads + * the screen first) AND it is already working, or a prompt was submitted since the + * timer was armed: a turn started 2.9 s in is not marked working before the deferred + * parsers run, and an idle edge here would end a send-and-wait registered for it. + * For every other CLI this timer is the only thing that ever settles a fresh pane, + * so it settles it even if a stray spinner glyph in the launch paint latched + * `_isWorking`. * * @param refreshScreen emit `needsRefresh` (a new pane; an attach refetches by itself) + * @param armedAt when the timer was armed (a submit at or after it means a prompt) */ - private _settlePaneStartup(refreshScreen: boolean): void { + private _settlePaneStartup(refreshScreen: boolean, armedAt: number): void { this._promptCheckTimeout = null; if (this._isStopped) return; - const leaveToConfirm = this._isWorking && !!getCli(this.mode)?.capabilities.workDetect; + const busyWithTurn = this._isWorking || this.lastSubmitAt >= armedAt; + const leaveToConfirm = busyWithTurn && !!getCli(this.mode)?.capabilities.workDetect; if (this._status === 'busy' && !leaveToConfirm) this._concludeIdle(false); if (refreshScreen) this.emit('needsRefresh'); } diff --git a/test/session-activity.test.ts b/test/session-activity.test.ts index e72f1255..84c20d5f 100644 --- a/test/session-activity.test.ts +++ b/test/session-activity.test.ts @@ -430,6 +430,46 @@ describe('external CLI launch settle', () => { expect(session.isWorking).toBe(false); }); + it('never announces idle for a codex pane prompted before the timer, so a send-and-wait is not ended early', () => { + vi.useFakeTimers(); + let screen = CODEX_READY; + const session = withFakePane(() => screen, 'codex'); + const events = launch(session); + + // A prompt 2 s in: its turn has not been marked working when the timer fires. + vi.advanceTimersByTime(2000); + session.markPromptSubmitted(); + screen = CODEX_WORKING; + vi.advanceTimersByTime(1000); + + expect(events).toEqual(['needsRefresh']); + expect(session.status).toBe('busy'); + + for (let i = 0; i < 4; i++) { + feed(session, CODEX_COMPOSER_REPAINT); + vi.advanceTimersByTime(1000); + } + screen = CODEX_FINISHED; + vi.advanceTimersByTime(30_000); + + // The turn's own end is the one idle edge. + expect(events.filter((e) => e === 'idle')).toEqual(['idle']); + expect(events[events.length - 1]).toBe('idle'); + expect(session.status).toBe('idle'); + }); + + it('still settles a prompted CLI without work detection, which has nothing else to settle it', () => { + vi.useFakeTimers(); + const session = new Session({ workingDir: '/tmp', mode: 'opencode' }); + const events = launch(session); + + vi.advanceTimersByTime(2000); + session.markPromptSubmitted(); + vi.advanceTimersByTime(1000); + + expect(events).toEqual(['idle', 'needsRefresh']); + }); + it('settles a RESTORED pane of a CLI without work detection, without a refetch', () => { vi.useFakeTimers(); // A Codeman restart re-attaches every surviving pane through startInteractive(),