Skip to content

Commit 3e5c4e5

Browse files
CL-8792: allow-once no longer stalls a second destructive command without re-prompt (#1173)
* test(tui): red tests for allow-once second-prompt overlay stall * feat(tui): let a queued decision gate preempt a command surface Allow-once settles exactly once and frees the single-slot overlay host, so an already-queued or newly raised card paints before any deferred slash/settings/MCP surface. Command surfaces suspend and return after the gate settles; inline popups keep their stacking contracts. * fix(tui): keep suspended overlays live when a gate preempts them Suspend was a dismiss: it ran onCancel/onDispose and destroyed the list, so restore painted a dead widget and MCP could steal the host from the arriving gate. The preemptable-kind set also used command aliases, so /model and /connect never yielded.
1 parent 2ba4ab6 commit 3e5c4e5

8 files changed

Lines changed: 866 additions & 69 deletions

File tree

‎src/tui/allow-once-reprompt.test.ts‎

Lines changed: 624 additions & 0 deletions
Large diffs are not rendered by default.

‎src/tui/gate-wire.ts‎

Lines changed: 49 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ import {
1919
closeInsetOverlay,
2020
isOverlayHostIdle,
2121
onOverlayClosed,
22+
resumeSuspendedCommandSurface,
2223
setOverlayBody,
24+
suspendReplaceableOverlay,
2325
} from "./shell/overlay-host.js";
2426
import { EXPAND_KEY } from "./stream.js";
2527
import {
@@ -258,6 +260,7 @@ export function wireGates(
258260
// nothing on screen to answer — so a gate that arrives while another overlay
259261
// is up waits here and opens as soon as the host frees up.
260262
const pending: (() => void)[] = [];
263+
let disposed = false;
261264
// Owns queued-approval reconciliation (see src/permission/queue.ts): this
262265
// host only enqueues requests and renders whatever settle calls the queue
263266
// hands back — it never decides which grant covers which request.
@@ -295,11 +298,45 @@ export function wireGates(
295298
}
296299

297300
function openOrQueue(open: () => void): void {
298-
if (!isOverlayHostIdle(shell)) {
301+
if (isOverlayHostIdle(shell)) {
302+
openHost(open);
303+
return;
304+
}
305+
if (shell.overlayList !== null) {
306+
// A replaceable command surface yields to the decision gate and is
307+
// restored after the gate settles. The suspend is a no-op for live
308+
// gates and stacked popups (palette, mentions — they keep their
309+
// stacking contracts), so those arrivals simply stay queued.
299310
pending.push(open);
311+
suspendReplaceableOverlay(shell);
312+
// The suspend-close's idle-notify may already have opened an older
313+
// queued gate (FIFO): drain here only if the host is still free, so a
314+
// close-notify drain is never doubled.
315+
if (shell.overlayList === null) {
316+
const next = pending.shift();
317+
if (next !== undefined) openHost(next);
318+
}
319+
return;
320+
}
321+
pending.push(open);
322+
}
323+
324+
/**
325+
* Open the next queued gate, else return a suspended command surface to
326+
* the host. Every gate settle path runs this after resolving. Skipped past
327+
* teardown so a late settle cannot paint onto a dead shell.
328+
*/
329+
function drainPendingOrResume(): void {
330+
if (disposed || shell.disposed) return;
331+
// A close-notify drain may already have taken the host (Esc / timeout
332+
// while displayed): never double-open, and never tear down a live gate.
333+
if (shell.overlayList !== null) return;
334+
const next = pending.shift();
335+
if (next !== undefined) {
336+
openHost(next);
300337
return;
301338
}
302-
openHost(open);
339+
resumeSuspendedCommandSurface(shell);
303340
}
304341

305342
function unqueue(open: () => void): void {
@@ -351,6 +388,9 @@ export function wireGates(
351388
closeInsetOverlay(shell);
352389
}
353390
resolve(outcome);
391+
// The next queued gate takes the host before any deferred surface;
392+
// a suspended command surface returns only when no gate is waiting.
393+
drainPendingOrResume();
354394
});
355395

356396
const onToggleExpand = (): void => {
@@ -496,11 +536,7 @@ export function wireGates(
496536
// ask — or the overlay's generic accept echo — into the transcript.
497537
echoChoice: false,
498538
onAccept: (sel: OverlaySelection) => {
499-
if (settled) return;
500-
settled = true;
501-
clearTimers();
502-
operatorTeardowns.delete(teardown);
503-
resolve(
539+
settleOnce(
504540
operatorResultFromSelection(choices, {
505541
index: sel.index,
506542
...(sel.id !== undefined ? { id: sel.id } : {}),
@@ -510,11 +546,7 @@ export function wireGates(
510546
// The ask_operator contract offers a free-form answer, so the overlay
511547
// must be able to send one back rather than only an option index.
512548
onTextAnswer: (text: string) => {
513-
if (settled) return;
514-
settled = true;
515-
clearTimers();
516-
operatorTeardowns.delete(teardown);
517-
resolve(operatorCustomResult(text));
549+
settleOnce(operatorCustomResult(text));
518550
},
519551
// Esc must settle the awaited promise (as a cancel), not abandon it —
520552
// an unresolved gate hangs the run until the process is killed.
@@ -523,11 +555,7 @@ export function wireGates(
523555
// closeInsetOverlay itself; doing so would reenter this same
524556
// onCancel (see the permission gate's identical note on `settle`).
525557
onCancel: () => {
526-
if (settled) return;
527-
settled = true;
528-
clearTimers();
529-
operatorTeardowns.delete(teardown);
530-
resolve(operatorCancelResult());
558+
settleOnce(operatorCancelResult());
531559
},
532560
isGate: true,
533561
});
@@ -544,6 +572,9 @@ export function wireGates(
544572
closeInsetOverlay(shell);
545573
}
546574
resolve(result);
575+
// The next queued gate takes the host before any deferred surface;
576+
// a suspended command surface returns only when no gate is waiting.
577+
drainPendingOrResume();
547578
};
548579
const autoCancel = (): void => {
549580
settleOnce(operatorCancelResult());
@@ -568,6 +599,7 @@ export function wireGates(
568599
emitter.on("operator.gate", onOperator);
569600

570601
return () => {
602+
disposed = true;
571603
emitter.off("permission.gate", onPermission);
572604
emitter.off("operator.gate", onOperator);
573605
disposeReconciliation();

‎src/tui/overlay-view.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -435,5 +435,5 @@ export function createOverlayView(ctx: RenderContext) {
435435
);
436436
}
437437

438-
return { host, title, body, paintTitle, paintList, clearBody };
438+
return { host, title, body, paintTitle, paintList, clearBody, detachList };
439439
}

‎src/tui/runtime-bridge.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
import {
3131
clearShellBridgeHooks,
3232
setShellBridgeHooks,
33+
shellInternals,
3334
type AppShell,
3435
} from "./shell/internals.js";
3536
import { applyShellInterrupt, surfaceSystemNotice } from "./shell/prompt.js";
@@ -1185,10 +1186,18 @@ function syncShellOutputs(
11851186
/**
11861187
* Refresh every plain in-flight tool call's row with how long it has been
11871188
* running, frame-coalesced. `spawn_agent` dispatches already get this (and
1188-
* more) from `syncAgentProgress`, so they are skipped here.
1189+
* more) from `syncAgentProgress`, so they are skipped here. While a decision
1190+
* gate is outstanding but not on screen — queued behind another overlay — the
1191+
* tool waits on an operator who cannot see it yet, so its elapsed stays frozen
1192+
* and the row reads as paused instead of running. Once the gate is shown the
1193+
* clock runs again: the operator can see what blocks the tool.
11891194
*/
11901195
function syncToolElapsed(shell: AppShell, bag: BridgeBag, nowMs: number): void {
11911196
if (bag.toolCallStartedAt.size === 0) return;
1197+
const gateOnScreen =
1198+
shell.overlayList !== null &&
1199+
shellInternals(shell)?.primaryBindings.isGate === true;
1200+
const gateHidden = bag.turn.blockedGateCount > 0 && !gateOnScreen;
11921201
for (const [callId, startedAt] of bag.toolCallStartedAt) {
11931202
if (bag.taskCallIds.has(callId)) continue;
11941203
const index = bag.toolRows.get(callId);
@@ -1201,6 +1210,7 @@ function syncToolElapsed(shell: AppShell, bag: BridgeBag, nowMs: number): void {
12011210
bag.toolCallStartedAt.delete(callId);
12021211
continue;
12031212
}
1213+
if (gateHidden) continue;
12041214
const current = bag.pendingRowUpdates.get(index) ?? row;
12051215
const stat = clockLabel(nowMs - startedAt);
12061216
if (current.stat === stat) continue;

‎src/tui/shell/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,7 @@ export function createAppShell(
523523
overlayClosedListeners: new Set(),
524524
deferredCommandOverlay: null,
525525
deferredFlushScheduled: false,
526+
suspendedCommandSurface: null,
526527
overlayHostReservations: 0,
527528
overlayReservationEpoch: 0,
528529
paletteCatalog: paletteCatalogOpt,

‎src/tui/shell/internals.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -684,7 +684,7 @@ export const EMPTY_PRIMARY_BINDINGS: Readonly<PrimaryOverlayBindings> = {
684684
mcpAddHint: false,
685685
};
686686

687-
interface PriorOverlaySnapshot {
687+
export interface PriorOverlaySnapshot {
688688
readonly kind: PrimaryOverlayKind | null;
689689
readonly items: readonly string[];
690690
readonly bodyLines: readonly string[];
@@ -711,6 +711,13 @@ interface ShellInternals {
711711
overlayRawBodyText: string;
712712
/** Snapshot when palette stacks over another primary overlay. */
713713
priorOverlay: PriorOverlaySnapshot | null;
714+
/**
715+
* Replaceable command surface suspended while a decision gate holds the
716+
* host. One slot; restored after the gate settles when no queued gate
717+
* takes the host first. Never a gate or palette — those keep their own
718+
* stacking contracts.
719+
*/
720+
suspendedCommandSurface: PriorOverlaySnapshot | null;
714721
/** Advances on a new overlay taking the host, and when the host empties. */
715722
overlayGeneration: number;
716723
primaryBindings: PrimaryOverlayBindings;

0 commit comments

Comments
 (0)