Skip to content

Commit 88a9c41

Browse files
committed
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.
1 parent 2d57a1e commit 88a9c41

7 files changed

Lines changed: 253 additions & 73 deletions

File tree

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

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,7 @@ import type {
2828
PermissionRequest,
2929
} from "../permission/types.js";
3030
import { defined } from "../../tests/helpers/defined.js";
31-
import {
32-
attachSessionBridge,
33-
createRecordingPort,
34-
} from "./runtime-bridge.js";
31+
import { attachSessionBridge, createRecordingPort } from "./runtime-bridge.js";
3532
import { withTestRenderer } from "./harness.js";
3633
import { createAppShell } from "./shell/index.js";
3734
import type { AppShell } from "./shell/internals.js";
@@ -83,6 +80,18 @@ async function settledWithin<T>(
8380
}
8481
}
8582

83+
/**
84+
* `gate.evaluate()` raises its card asynchronously (decide → approval seam →
85+
* emit → enqueue), so the overlay is never up on the very next line. Flush
86+
* macrotasks so the card is raised — shown, or queued behind the live gate —
87+
* before asserting on the host. All gate-side work is microtasks, so two
88+
* macrotask drains provably suffice; nothing here changes what is asserted.
89+
*/
90+
async function flushGateRaise(): Promise<void> {
91+
await new Promise((resolve) => setTimeout(resolve, 0));
92+
await new Promise((resolve) => setTimeout(resolve, 0));
93+
}
94+
8695
async function withWiredWorld(
8796
run: (world: {
8897
shell: AppShell;
@@ -138,6 +147,7 @@ describe("CL-8792 gate level: a second destructive evaluation re-prompts after a
138147
await withWiredWorld(async ({ shell, emitter }) => {
139148
const gate = createOverlayBackedGate(emitter);
140149
const first = gate.evaluate(shellCall("rm -rf /tmp/cl8792-a"));
150+
await flushGateRaise();
141151
expect(shell.overlayKind).toBe("permissions");
142152

143153
acceptChoice(shell, 1);
@@ -147,6 +157,7 @@ describe("CL-8792 gate level: a second destructive evaluation re-prompts after a
147157
expect(firstVerdict.value.allowed).toBe(true);
148158

149159
const second = gate.evaluate(shellCall("rm -rf /tmp/cl8792-b"));
160+
await flushGateRaise();
150161
// Allow-once persisted nothing, so the new command must prompt again.
151162
expect(shell.overlayKind).toBe("permissions");
152163
acceptChoice(shell, 1);
@@ -162,11 +173,13 @@ describe("CL-8792 gate level: a second destructive evaluation re-prompts after a
162173
const gate = createOverlayBackedGate(emitter);
163174
const command = "rm -rf /tmp/cl8792-same";
164175
const first = gate.evaluate(shellCall(command));
176+
await flushGateRaise();
165177
expect(shell.overlayKind).toBe("permissions");
166178
acceptChoice(shell, 1);
167179
await settledWithin(first, 500);
168180

169181
const second = gate.evaluate(shellCall(command));
182+
await flushGateRaise();
170183
// A grant would auto-allow with no overlay; allow-once must re-prompt.
171184
expect(shell.overlayKind).toBe("permissions");
172185
acceptChoice(shell, 1);
@@ -181,13 +194,16 @@ describe("CL-8792 gate level: a second destructive evaluation re-prompts after a
181194
await withWiredWorld(async ({ shell, emitter }) => {
182195
const gate = createOverlayBackedGate(emitter);
183196
const first = gate.evaluate(shellCall("rm -rf /tmp/cl8792-a"));
197+
await flushGateRaise();
184198
expect(shell.overlayKind).toBe("permissions");
185199

186200
// A slash opened under the live gate defers; the live gate keeps it waiting.
187201
openSlash(shell);
188202
expect(shell.overlayKind).toBe("permissions");
189203

190204
const second = gate.evaluate(shellCall("rm -rf /tmp/cl8792-b"));
205+
// Let the second card enqueue behind the live gate before accepting it.
206+
await flushGateRaise();
191207

192208
// Accept once on the first card: the queued card must take the host
193209
// before the deferred slash, and both evaluations must settle.
@@ -305,7 +321,10 @@ describe("CL-8792 overlay host: queued cards outrank deferred surfaces", () => {
305321
});
306322

307323
acceptChoice(shell, 2);
308-
expect(resolvedA).toMatchObject({ allow: true, persist: { id: "scope-a" } });
324+
expect(resolvedA).toMatchObject({
325+
allow: true,
326+
persist: { id: "scope-a" },
327+
});
309328
expect(shell.overlayKind).toBe("permissions");
310329

311330
acceptChoice(shell, 1);

‎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 non-surface popups (palette, mentions, pickers — they keep
309+
// their 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/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)