fix(session): never settle a just-prompted pane idle at launch

40560ace made the 3 s launch settle announce its idle. For a CLI with
work detection that edge could now land in the middle of a turn: a
prompt sent ~2.9 s after launch has not been marked working yet (the
working line goes through the deferred parsers), so the settle called
the pane idle and a send-and-wait registered for that prompt resolved
before the turn even started. Before 40560ace the settle was silent, so
this edge is new.

The settle now also leaves a pane to `_confirmIdle()` when a prompt was
submitted since the timer was armed, the same way it already does for a
pane marked working; that confirmation reads the screen before it ends
the turn. A CLI without work detection still settles: nothing else ever
would.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-09 02:18:26 +02:00
parent b539780f33
commit db168cdbe5
2 changed files with 54 additions and 8 deletions
+14 -8
View File
@@ -3465,7 +3465,8 @@ export class Session extends EventEmitter {
private _armPaneSettle(isRestored: boolean): void { private _armPaneSettle(isRestored: boolean): void {
const applies = isRestored ? !getCli(this.mode)?.capabilities.workDetect : isExternalCliMode(this.mode); const applies = isRestored ? !getCli(this.mode)?.capabilities.workDetect : isExternalCliMode(this.mode);
if (!applies) return; 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 * never tripped `_markWorking()`, the later `_confirmIdle()` found the status
* already idle and emitted nothing, so no browser ever learned the pane was ready. * 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 * A pane is left to `_confirmIdle()` only when its CLI declares
* `capabilities.workDetect`: that CLI's composer glyph arms `_confirmIdle()`, which * `capabilities.workDetect` (its composer glyph arms that confirmation, which reads
* reads the screen and ends the turn properly. For every other CLI this timer is the * the screen first) AND it is already working, or a prompt was submitted since the
* only thing that ever settles a fresh pane, so it settles it even if a stray spinner * timer was armed: a turn started 2.9 s in is not marked working before the deferred
* glyph in the launch paint latched `_isWorking`. * 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 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; this._promptCheckTimeout = null;
if (this._isStopped) return; 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 (this._status === 'busy' && !leaveToConfirm) this._concludeIdle(false);
if (refreshScreen) this.emit('needsRefresh'); if (refreshScreen) this.emit('needsRefresh');
} }
+40
View File
@@ -430,6 +430,46 @@ describe('external CLI launch settle', () => {
expect(session.isWorking).toBe(false); 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', () => { it('settles a RESTORED pane of a CLI without work detection, without a refetch', () => {
vi.useFakeTimers(); vi.useFakeTimers();
// A Codeman restart re-attaches every surviving pane through startInteractive(), // A Codeman restart re-attaches every surviving pane through startInteractive(),