From 2d57a1e07ac622a7f04c7b860c700302fadf8d05 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 25 Sep 2026 08:05:31 -0700 Subject: [PATCH 1/3] test(tui): red tests for allow-once second-prompt overlay stall --- src/tui/allow-once-reprompt.test.ts | 458 ++++++++++++++++++++++++++++ 1 file changed, 458 insertions(+) create mode 100644 src/tui/allow-once-reprompt.test.ts diff --git a/src/tui/allow-once-reprompt.test.ts b/src/tui/allow-once-reprompt.test.ts new file mode 100644 index 000000000..5644b1abb --- /dev/null +++ b/src/tui/allow-once-reprompt.test.ts @@ -0,0 +1,458 @@ +/** + * CL-8792: allow-once must not stall a second destructive command. + * + * The overlay host is a single slot. Before the fix, accepting the first + * permission card let a deferred replaceable surface (slash help, settings, + * MCP) take the host while the second permission card stayed queued forever: + * its overlay never opened and its evaluation never settled. These tests pin + * the fixed contract: + * + * 1. After Accept once, an already-queued or newly raised permission / + * operator card takes the host before a deferred slash/settings/MCP + * surface. + * 2. A replaceable command surface already on screen yields to a new + * decision gate and returns after that gate settles. + * 3. A slash requested while a live gate holds the host still waits. + * 4. A queued card's auto-deny timer does not run until actually shown. + * 5. A pending tool row does not advance elapsed while a decision gate is + * outstanding but not on screen. + * 6. Allow Once persists nothing; Allow Always / Reject drain in the same + * order as Accept once. + */ +import { EventEmitter } from "node:events"; +import { describe, expect, test } from "bun:test"; +import type { ToolCall } from "@intx/types/runtime"; +import { createPermissionGate } from "../permission/gate.js"; +import type { + ApprovalOutcome, + PermissionRequest, +} from "../permission/types.js"; +import { defined } from "../../tests/helpers/defined.js"; +import { + attachSessionBridge, + createRecordingPort, +} from "./runtime-bridge.js"; +import { withTestRenderer } from "./harness.js"; +import { createAppShell } from "./shell/index.js"; +import type { AppShell } from "./shell/internals.js"; +import { + acceptOverlaySelection, + openListOverlay, +} from "./shell/overlay-host.js"; +import { moveOverlaySelection } from "./shell/overlay-list.js"; +import { streamRowCount } from "./shell/transcript.js"; +import { wireGates } from "./gate-wire.js"; +import type { PermissionGateEvent } from "./gate-events.js"; +import { createGateRequestApproval } from "./request-approval.js"; + +const shellCall = (command: string): ToolCall => ({ + id: "c", + name: "run_shell", + arguments: { command }, +}); + +const destructiveRequest = (subject: string): PermissionRequest => ({ + tool: "run_shell", + action: "Run shell command", + subject, + scopes: [], +}); + +/** Accept the highlighted choice after moving to `index` (0 Reject, 1 Accept once). */ +function acceptChoice(shell: AppShell, index: 0 | 1 | 2): void { + for (let i = 0; i < index; i++) moveOverlaySelection(shell, 1); + acceptOverlaySelection(shell); +} + +/** Resolves false when `promise` does not settle within `ms`. */ +async function settledWithin( + promise: Promise, + ms: number, +): Promise<{ settled: true; value: T } | { settled: false }> { + let timer: ReturnType | undefined; + try { + const value = await Promise.race([ + promise.then((value) => ({ settled: true as const, value })), + new Promise<{ settled: false }>((resolve) => { + timer = setTimeout(() => resolve({ settled: false }), ms); + }), + ]); + return value; + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + +async function withWiredWorld( + run: (world: { + shell: AppShell; + emitter: EventEmitter; + dispose: () => void; + }) => void | Promise, +): Promise { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + run: "idle", + }); + const emitter = new EventEmitter(); + const dispose = wireGates(emitter, shell); + try { + await run({ shell, emitter, dispose }); + } finally { + dispose(); + shell.dispose(); + } + }); +} + +/** A real permission gate whose prompts flow into the TUI overlay host. */ +function createOverlayBackedGate(emitter: EventEmitter) { + const requestApproval = createGateRequestApproval({ + emitGate: (event: PermissionGateEvent) => { + emitter.emit("permission.gate", event); + return true; + }, + approvalTimeout: () => undefined, + }); + return createPermissionGate({ + approvals: [], + requestApproval, + interactive: true, + skipPermissions: false, + reactorGated: false, + }); +} + +function openSlash(shell: AppShell): void { + openListOverlay(shell, { + kind: "help", + title: "Slash", + items: ["Help"], + deferIfBusy: true, + }); +} + +describe("CL-8792 gate level: a second destructive evaluation re-prompts after allow-once", () => { + test("different command re-prompts through the overlay host and settles", async () => { + await withWiredWorld(async ({ shell, emitter }) => { + const gate = createOverlayBackedGate(emitter); + const first = gate.evaluate(shellCall("rm -rf /tmp/cl8792-a")); + expect(shell.overlayKind).toBe("permissions"); + + acceptChoice(shell, 1); + const firstVerdict = await settledWithin(first, 500); + expect(firstVerdict.settled).toBe(true); + if (!firstVerdict.settled) throw new Error("first evaluation hung"); + expect(firstVerdict.value.allowed).toBe(true); + + const second = gate.evaluate(shellCall("rm -rf /tmp/cl8792-b")); + // Allow-once persisted nothing, so the new command must prompt again. + expect(shell.overlayKind).toBe("permissions"); + acceptChoice(shell, 1); + const secondVerdict = await settledWithin(second, 500); + expect(secondVerdict.settled).toBe(true); + if (!secondVerdict.settled) throw new Error("second evaluation hung"); + expect(secondVerdict.value.allowed).toBe(true); + }); + }); + + test("same command re-prompts: allow-once mints no grant", async () => { + await withWiredWorld(async ({ shell, emitter }) => { + const gate = createOverlayBackedGate(emitter); + const command = "rm -rf /tmp/cl8792-same"; + const first = gate.evaluate(shellCall(command)); + expect(shell.overlayKind).toBe("permissions"); + acceptChoice(shell, 1); + await settledWithin(first, 500); + + const second = gate.evaluate(shellCall(command)); + // A grant would auto-allow with no overlay; allow-once must re-prompt. + expect(shell.overlayKind).toBe("permissions"); + acceptChoice(shell, 1); + const verdict = await settledWithin(second, 500); + expect(verdict.settled).toBe(true); + if (!verdict.settled) throw new Error("second evaluation hung"); + expect(verdict.value.allowed).toBe(true); + }); + }); + + test("second evaluation settles under timeout while a deferred slash waits", async () => { + await withWiredWorld(async ({ shell, emitter }) => { + const gate = createOverlayBackedGate(emitter); + const first = gate.evaluate(shellCall("rm -rf /tmp/cl8792-a")); + expect(shell.overlayKind).toBe("permissions"); + + // A slash opened under the live gate defers; the live gate keeps it waiting. + openSlash(shell); + expect(shell.overlayKind).toBe("permissions"); + + const second = gate.evaluate(shellCall("rm -rf /tmp/cl8792-b")); + + // Accept once on the first card: the queued card must take the host + // before the deferred slash, and both evaluations must settle. + acceptChoice(shell, 1); + const firstVerdict = await settledWithin(first, 500); + expect(firstVerdict.settled).toBe(true); + + expect(shell.overlayKind).toBe("permissions"); + acceptChoice(shell, 1); + const secondVerdict = await settledWithin(second, 500); + expect(secondVerdict.settled).toBe(true); + if (!secondVerdict.settled) throw new Error("second evaluation hung"); + expect(secondVerdict.value.allowed).toBe(true); + }); + }); +}); + +describe("CL-8792 overlay host: queued cards outrank deferred surfaces", () => { + test("queued permission card takes the host before a deferred slash after accept once", async () => { + await withWiredWorld(async ({ shell, emitter }) => { + let resolvedA: unknown; + let resolvedB: unknown; + emitter.emit("permission.gate", { + id: "req-a", + request: destructiveRequest("rm -rf /tmp/cl8792-a"), + resolve: (outcome: unknown) => { + resolvedA = outcome; + }, + }); + expect(shell.overlayKind).toBe("permissions"); + + openSlash(shell); + + emitter.emit("permission.gate", { + id: "req-b", + request: destructiveRequest("rm -rf /tmp/cl8792-b"), + resolve: (outcome: unknown) => { + resolvedB = outcome; + }, + }); + + acceptChoice(shell, 1); + expect(resolvedA).toEqual({ allow: true }); + expect(shell.overlayKind).toBe("permissions"); + expect(shell.overlayItems).toContain("Accept once"); + + acceptChoice(shell, 1); + expect(resolvedB).toEqual({ allow: true }); + + // With every gate settled, the deferred slash finally takes the host. + await Promise.resolve(); + expect(shell.overlayKind).toBe("help"); + expect(shell.overlayItems).toEqual(["Help"]); + }); + }); + + test("reject drains like accept once: the next card still outranks the deferred slash", async () => { + await withWiredWorld(async ({ shell, emitter }) => { + let resolvedA: unknown; + let resolvedB: unknown; + emitter.emit("permission.gate", { + id: "req-a", + request: destructiveRequest("git push --force"), + resolve: (outcome: unknown) => { + resolvedA = outcome; + }, + }); + openSlash(shell); + emitter.emit("permission.gate", { + id: "req-b", + request: destructiveRequest("rm -rf /tmp/cl8792-b"), + resolve: (outcome: unknown) => { + resolvedB = outcome; + }, + }); + + acceptChoice(shell, 0); + expect(resolvedA).toEqual({ allow: false }); + expect(shell.overlayKind).toBe("permissions"); + + acceptChoice(shell, 1); + expect(resolvedB).toEqual({ allow: true }); + }); + }); + + test("allow always drains like accept once and still mints its grant", async () => { + await withWiredWorld(async ({ shell, emitter }) => { + let resolvedA: unknown; + let resolvedB: unknown; + emitter.emit("permission.gate", { + id: "req-a", + request: { + ...destructiveRequest("rm -rf /tmp/cl8792-a"), + scopes: [ + { + id: "scope-a", + label: "Allow rm *", + pattern: "rm *", + hint: "family", + grant: "session", + }, + ], + }, + resolve: (outcome: unknown) => { + resolvedA = outcome; + }, + }); + openSlash(shell); + emitter.emit("permission.gate", { + id: "req-b", + request: destructiveRequest("rm -rf /tmp/cl8792-b"), + resolve: (outcome: unknown) => { + resolvedB = outcome; + }, + }); + + acceptChoice(shell, 2); + expect(resolvedA).toMatchObject({ allow: true, persist: { id: "scope-a" } }); + expect(shell.overlayKind).toBe("permissions"); + + acceptChoice(shell, 1); + expect(resolvedB).toEqual({ allow: true }); + }); + }); + + test("replaceable command surface on screen yields to a new gate and returns after settle", async () => { + await withWiredWorld(async ({ shell, emitter }) => { + let resolved: unknown; + openSlash(shell); + expect(shell.overlayKind).toBe("help"); + + emitter.emit("permission.gate", { + id: "req-g", + request: destructiveRequest("rm -rf /tmp/cl8792-g"), + resolve: (outcome: unknown) => { + resolved = outcome; + }, + }); + expect(shell.overlayKind).toBe("permissions"); + + acceptChoice(shell, 1); + expect(resolved).toEqual({ allow: true }); + expect(shell.overlayKind).toBe("help"); + expect(shell.overlayItems).toEqual(["Help"]); + }); + }); + + test("queued card arms its auto-deny timer only once actually shown", async () => { + await withWiredWorld(async ({ shell, emitter }) => { + let resolvedA: unknown; + let resolvedB: unknown; + emitter.emit("permission.gate", { + id: "req-a", + request: destructiveRequest("rm -rf /tmp/cl8792-a"), + resolve: (outcome: unknown) => { + resolvedA = outcome; + }, + timeoutMs: 60_000, + timeoutMessage: "denied", + }); + // A deferred slash competes for the host: the queued card must still + // take it first after Accept once (single-slot overlay host). + openSlash(shell); + emitter.emit("permission.gate", { + id: "req-b", + request: destructiveRequest("rm -rf /tmp/cl8792-b"), + resolve: (outcome: ApprovalOutcome) => { + resolvedB = outcome; + }, + timeoutMs: 25, + timeoutMessage: "queued card timed out", + }); + + // The queued card waits far past its own timeout without firing. + await new Promise((resolve) => setTimeout(resolve, 80)); + expect(resolvedB).toBeUndefined(); + + acceptChoice(shell, 1); + expect(resolvedA).toEqual({ allow: true }); + expect(shell.overlayKind).toBe("permissions"); + + // Now shown, the same timeout arms and auto-denies. + const denied = await settledWithin( + (async () => { + while (resolvedB === undefined) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + return resolvedB; + })(), + 500, + ); + expect(denied.settled).toBe(true); + expect(resolvedB).toMatchObject({ allow: false }); + }); + }); +}); + +describe("CL-8792 elapsed: pending tool row freezes while a gate is outstanding but hidden", () => { + test("tool row clock holds while the gate is hidden and runs once it shows", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "busy", + }); + const emitter = new EventEmitter(); + const disposeGates = wireGates(emitter, shell); + let nowMs = 0; + let tick: (() => void) | undefined; + const bridge = attachSessionBridge(shell, createRecordingPort(), { + now: () => nowMs, + schedule: (fn) => { + tick = fn; + return () => { + tick = undefined; + }; + }, + }); + try { + bridge.handle({ type: "inference.start", data: {} }); + bridge.handle({ + type: "inference.tool_call.end", + data: { + name: "run_shell", + callId: "c1", + arguments: "rm -rf /tmp/cl8792-a", + }, + }); + const index = streamRowCount(shell) - 1; + const stat = () => defined(shell.streamLog[index], "tool row").stat; + + // A decision gate goes outstanding for the pending call while + // another surface holds the single overlay slot: hidden gate. + bridge.gateOpened(); + nowMs = 5_000; + tick?.(); + await h.renderOnce(); + expect(stat()).toBeUndefined(); + + // The same outstanding gate, now actually on screen: the clock runs. + let resolved: unknown; + emitter.emit("permission.gate", { + id: "req-g", + request: destructiveRequest("rm -rf /tmp/cl8792-a"), + resolve: (outcome: unknown) => { + resolved = outcome; + }, + }); + expect(shell.overlayKind).toBe("permissions"); + nowMs = 10_000; + tick?.(); + await h.renderOnce(); + expect(stat()).toBe("0:10"); + + acceptChoice(shell, 1); + expect(resolved).toEqual({ allow: true }); + bridge.gateClosed(); + } finally { + bridge.dispose(); + disposeGates(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); +}); From 88a9c4171cbfc96db2cd603dff390f8917854f39 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 25 Sep 2026 08:30:43 -0700 Subject: [PATCH 2/3] 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. --- src/tui/allow-once-reprompt.test.ts | 29 +++++- src/tui/gate-wire.ts | 66 ++++++++---- src/tui/runtime-bridge.ts | 12 ++- src/tui/shell/index.ts | 1 + src/tui/shell/internals.ts | 9 +- src/tui/shell/overlay-host.ts | 153 +++++++++++++++++++++------- src/tui/slash-popup-gate.test.ts | 56 +++++++--- 7 files changed, 253 insertions(+), 73 deletions(-) diff --git a/src/tui/allow-once-reprompt.test.ts b/src/tui/allow-once-reprompt.test.ts index 5644b1abb..82f5b3c69 100644 --- a/src/tui/allow-once-reprompt.test.ts +++ b/src/tui/allow-once-reprompt.test.ts @@ -28,10 +28,7 @@ import type { PermissionRequest, } from "../permission/types.js"; import { defined } from "../../tests/helpers/defined.js"; -import { - attachSessionBridge, - createRecordingPort, -} from "./runtime-bridge.js"; +import { attachSessionBridge, createRecordingPort } from "./runtime-bridge.js"; import { withTestRenderer } from "./harness.js"; import { createAppShell } from "./shell/index.js"; import type { AppShell } from "./shell/internals.js"; @@ -83,6 +80,18 @@ async function settledWithin( } } +/** + * `gate.evaluate()` raises its card asynchronously (decide → approval seam → + * emit → enqueue), so the overlay is never up on the very next line. Flush + * macrotasks so the card is raised — shown, or queued behind the live gate — + * before asserting on the host. All gate-side work is microtasks, so two + * macrotask drains provably suffice; nothing here changes what is asserted. + */ +async function flushGateRaise(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + async function withWiredWorld( run: (world: { shell: AppShell; @@ -138,6 +147,7 @@ describe("CL-8792 gate level: a second destructive evaluation re-prompts after a await withWiredWorld(async ({ shell, emitter }) => { const gate = createOverlayBackedGate(emitter); const first = gate.evaluate(shellCall("rm -rf /tmp/cl8792-a")); + await flushGateRaise(); expect(shell.overlayKind).toBe("permissions"); acceptChoice(shell, 1); @@ -147,6 +157,7 @@ describe("CL-8792 gate level: a second destructive evaluation re-prompts after a expect(firstVerdict.value.allowed).toBe(true); const second = gate.evaluate(shellCall("rm -rf /tmp/cl8792-b")); + await flushGateRaise(); // Allow-once persisted nothing, so the new command must prompt again. expect(shell.overlayKind).toBe("permissions"); acceptChoice(shell, 1); @@ -162,11 +173,13 @@ describe("CL-8792 gate level: a second destructive evaluation re-prompts after a const gate = createOverlayBackedGate(emitter); const command = "rm -rf /tmp/cl8792-same"; const first = gate.evaluate(shellCall(command)); + await flushGateRaise(); expect(shell.overlayKind).toBe("permissions"); acceptChoice(shell, 1); await settledWithin(first, 500); const second = gate.evaluate(shellCall(command)); + await flushGateRaise(); // A grant would auto-allow with no overlay; allow-once must re-prompt. expect(shell.overlayKind).toBe("permissions"); acceptChoice(shell, 1); @@ -181,6 +194,7 @@ describe("CL-8792 gate level: a second destructive evaluation re-prompts after a await withWiredWorld(async ({ shell, emitter }) => { const gate = createOverlayBackedGate(emitter); const first = gate.evaluate(shellCall("rm -rf /tmp/cl8792-a")); + await flushGateRaise(); expect(shell.overlayKind).toBe("permissions"); // A slash opened under the live gate defers; the live gate keeps it waiting. @@ -188,6 +202,8 @@ describe("CL-8792 gate level: a second destructive evaluation re-prompts after a expect(shell.overlayKind).toBe("permissions"); const second = gate.evaluate(shellCall("rm -rf /tmp/cl8792-b")); + // Let the second card enqueue behind the live gate before accepting it. + await flushGateRaise(); // Accept once on the first card: the queued card must take the host // before the deferred slash, and both evaluations must settle. @@ -305,7 +321,10 @@ describe("CL-8792 overlay host: queued cards outrank deferred surfaces", () => { }); acceptChoice(shell, 2); - expect(resolvedA).toMatchObject({ allow: true, persist: { id: "scope-a" } }); + expect(resolvedA).toMatchObject({ + allow: true, + persist: { id: "scope-a" }, + }); expect(shell.overlayKind).toBe("permissions"); acceptChoice(shell, 1); diff --git a/src/tui/gate-wire.ts b/src/tui/gate-wire.ts index 0712bf07d..70c3f9d0a 100644 --- a/src/tui/gate-wire.ts +++ b/src/tui/gate-wire.ts @@ -19,7 +19,9 @@ import { closeInsetOverlay, isOverlayHostIdle, onOverlayClosed, + resumeSuspendedCommandSurface, setOverlayBody, + suspendReplaceableOverlay, } from "./shell/overlay-host.js"; import { EXPAND_KEY } from "./stream.js"; import { @@ -258,6 +260,7 @@ export function wireGates( // nothing on screen to answer — so a gate that arrives while another overlay // is up waits here and opens as soon as the host frees up. const pending: (() => void)[] = []; + let disposed = false; // Owns queued-approval reconciliation (see src/permission/queue.ts): this // host only enqueues requests and renders whatever settle calls the queue // hands back — it never decides which grant covers which request. @@ -295,11 +298,45 @@ export function wireGates( } function openOrQueue(open: () => void): void { - if (!isOverlayHostIdle(shell)) { + if (isOverlayHostIdle(shell)) { + openHost(open); + return; + } + if (shell.overlayList !== null) { + // A replaceable command surface yields to the decision gate and is + // restored after the gate settles. The suspend is a no-op for live + // gates and non-surface popups (palette, mentions, pickers — they keep + // their stacking contracts), so those arrivals simply stay queued. pending.push(open); + suspendReplaceableOverlay(shell); + // The suspend-close's idle-notify may already have opened an older + // queued gate (FIFO): drain here only if the host is still free, so a + // close-notify drain is never doubled. + if (shell.overlayList === null) { + const next = pending.shift(); + if (next !== undefined) openHost(next); + } + return; + } + pending.push(open); + } + + /** + * Open the next queued gate, else return a suspended command surface to + * the host. Every gate settle path runs this after resolving. Skipped past + * teardown so a late settle cannot paint onto a dead shell. + */ + function drainPendingOrResume(): void { + if (disposed || shell.disposed) return; + // A close-notify drain may already have taken the host (Esc / timeout + // while displayed): never double-open, and never tear down a live gate. + if (shell.overlayList !== null) return; + const next = pending.shift(); + if (next !== undefined) { + openHost(next); return; } - openHost(open); + resumeSuspendedCommandSurface(shell); } function unqueue(open: () => void): void { @@ -351,6 +388,9 @@ export function wireGates( closeInsetOverlay(shell); } resolve(outcome); + // The next queued gate takes the host before any deferred surface; + // a suspended command surface returns only when no gate is waiting. + drainPendingOrResume(); }); const onToggleExpand = (): void => { @@ -496,11 +536,7 @@ export function wireGates( // ask — or the overlay's generic accept echo — into the transcript. echoChoice: false, onAccept: (sel: OverlaySelection) => { - if (settled) return; - settled = true; - clearTimers(); - operatorTeardowns.delete(teardown); - resolve( + settleOnce( operatorResultFromSelection(choices, { index: sel.index, ...(sel.id !== undefined ? { id: sel.id } : {}), @@ -510,11 +546,7 @@ export function wireGates( // The ask_operator contract offers a free-form answer, so the overlay // must be able to send one back rather than only an option index. onTextAnswer: (text: string) => { - if (settled) return; - settled = true; - clearTimers(); - operatorTeardowns.delete(teardown); - resolve(operatorCustomResult(text)); + settleOnce(operatorCustomResult(text)); }, // Esc must settle the awaited promise (as a cancel), not abandon it — // an unresolved gate hangs the run until the process is killed. @@ -523,11 +555,7 @@ export function wireGates( // closeInsetOverlay itself; doing so would reenter this same // onCancel (see the permission gate's identical note on `settle`). onCancel: () => { - if (settled) return; - settled = true; - clearTimers(); - operatorTeardowns.delete(teardown); - resolve(operatorCancelResult()); + settleOnce(operatorCancelResult()); }, isGate: true, }); @@ -544,6 +572,9 @@ export function wireGates( closeInsetOverlay(shell); } resolve(result); + // The next queued gate takes the host before any deferred surface; + // a suspended command surface returns only when no gate is waiting. + drainPendingOrResume(); }; const autoCancel = (): void => { settleOnce(operatorCancelResult()); @@ -568,6 +599,7 @@ export function wireGates( emitter.on("operator.gate", onOperator); return () => { + disposed = true; emitter.off("permission.gate", onPermission); emitter.off("operator.gate", onOperator); disposeReconciliation(); diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index 03efb5f59..d5e819a74 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -30,6 +30,7 @@ import { import { clearShellBridgeHooks, setShellBridgeHooks, + shellInternals, type AppShell, } from "./shell/internals.js"; import { applyShellInterrupt, surfaceSystemNotice } from "./shell/prompt.js"; @@ -1185,10 +1186,18 @@ function syncShellOutputs( /** * Refresh every plain in-flight tool call's row with how long it has been * running, frame-coalesced. `spawn_agent` dispatches already get this (and - * more) from `syncAgentProgress`, so they are skipped here. + * more) from `syncAgentProgress`, so they are skipped here. While a decision + * gate is outstanding but not on screen — queued behind another overlay — the + * tool waits on an operator who cannot see it yet, so its elapsed stays frozen + * and the row reads as paused instead of running. Once the gate is shown the + * clock runs again: the operator can see what blocks the tool. */ function syncToolElapsed(shell: AppShell, bag: BridgeBag, nowMs: number): void { if (bag.toolCallStartedAt.size === 0) return; + const gateOnScreen = + shell.overlayList !== null && + shellInternals(shell)?.primaryBindings.isGate === true; + const gateHidden = bag.turn.blockedGateCount > 0 && !gateOnScreen; for (const [callId, startedAt] of bag.toolCallStartedAt) { if (bag.taskCallIds.has(callId)) continue; const index = bag.toolRows.get(callId); @@ -1201,6 +1210,7 @@ function syncToolElapsed(shell: AppShell, bag: BridgeBag, nowMs: number): void { bag.toolCallStartedAt.delete(callId); continue; } + if (gateHidden) continue; const current = bag.pendingRowUpdates.get(index) ?? row; const stat = clockLabel(nowMs - startedAt); if (current.stat === stat) continue; diff --git a/src/tui/shell/index.ts b/src/tui/shell/index.ts index 0e5a6df1b..7379385c6 100644 --- a/src/tui/shell/index.ts +++ b/src/tui/shell/index.ts @@ -523,6 +523,7 @@ export function createAppShell( overlayClosedListeners: new Set(), deferredCommandOverlay: null, deferredFlushScheduled: false, + suspendedCommandSurface: null, overlayHostReservations: 0, overlayReservationEpoch: 0, paletteCatalog: paletteCatalogOpt, diff --git a/src/tui/shell/internals.ts b/src/tui/shell/internals.ts index c1e6c7d73..b83dec431 100644 --- a/src/tui/shell/internals.ts +++ b/src/tui/shell/internals.ts @@ -684,7 +684,7 @@ export const EMPTY_PRIMARY_BINDINGS: Readonly = { mcpAddHint: false, }; -interface PriorOverlaySnapshot { +export interface PriorOverlaySnapshot { readonly kind: PrimaryOverlayKind | null; readonly items: readonly string[]; readonly bodyLines: readonly string[]; @@ -711,6 +711,13 @@ interface ShellInternals { overlayRawBodyText: string; /** Snapshot when palette stacks over another primary overlay. */ priorOverlay: PriorOverlaySnapshot | null; + /** + * Replaceable command surface suspended while a decision gate holds the + * host. One slot; restored after the gate settles when no queued gate + * takes the host first. Never a gate or palette — those keep their own + * stacking contracts. + */ + suspendedCommandSurface: PriorOverlaySnapshot | null; /** Advances on a new overlay taking the host, and when the host empties. */ overlayGeneration: number; primaryBindings: PrimaryOverlayBindings; diff --git a/src/tui/shell/overlay-host.ts b/src/tui/shell/overlay-host.ts index 627f02d74..5ebb1a0ac 100644 --- a/src/tui/shell/overlay-host.ts +++ b/src/tui/shell/overlay-host.ts @@ -34,6 +34,7 @@ import { type OpenListOverlayOpts, type OverlaySelection, type PrimaryOverlayKind, + type PriorOverlaySnapshot, shellInternals, slashPopups, } from "./internals.js"; @@ -117,6 +118,116 @@ export function applyOverlayBodyText( shell.overlayBodyFgs = lines.map(() => UI.text); } +/** + * Snapshot the live primary frame: list, body, bindings, answer field, title. + * The palette stack and command-surface suspend share it so a suspended + * surface returns pixel-identical. + */ +function capturePrimaryFrame( + shell: AppShell, + bag: NonNullable>, +): PriorOverlaySnapshot | null { + const list = shell.overlayList; + if (list === null) return null; + return { + kind: shell.overlayKind, + items: shell.overlayItems, + bodyLines: shell.overlayBodyLines, + bodyFgs: shell.overlayBodyFgs, + list, + title: String(shell.overlayTitle.content), + paletteCommands: shell.paletteCommands, + primaryBindings: { ...bag.primaryBindings }, + answer: bag.overlayAnswer, + titleText: bag.overlayTitleText, + }; +} + +/** Restore a frame captured by `capturePrimaryFrame` onto the empty host. */ +function restorePrimaryFrame( + shell: AppShell, + bag: NonNullable>, + frame: PriorOverlaySnapshot, +): void { + // Restore prior primary overlay paint; focus should already be overlay. + shell.overlayItems = frame.items; + shell.overlayKind = frame.kind; + shell.overlayBodyLines = frame.bodyLines; + shell.overlayBodyFgs = frame.bodyFgs; + shell.overlayList = frame.list; + shell.paletteCommands = frame.paletteCommands; + shell.overlayTitle.visible = true; + shell.overlayTitle.content = frame.title; + bag.primaryBindings = { ...frame.primaryBindings }; + bag.overlayAnswer = frame.answer; + bag.overlayTitleText = frame.titleText; + // If focus was not stacked (edge case), re-open overlay frame. + if (focusOwner(shell.focus) !== "overlay") { + shell.focus = openOverlay(shell.focus, OVERLAY_FRAME_ID, { + target: "overlay", + scrollOwner: "overlay", + }); + } + relayoutOverlayHost(shell, frame.list.count); + applyFocus(shell); + paintOverlayList(shell); +} + +/** + * Kinds opened through `openCommandSurface` (`CommandSurfaceKind` in + * ../command-surfaces.ts, restated here to keep shell/ import-clean). Only + * these yield to a decision gate. Inline popups (mentions, palette, pickers) + * keep their stacking contracts — suspending one would strand its owner, the + * CL-6698 mention-refresh stall — so a gate arriving behind them stays queued. + */ +const GATE_PREEMPTABLE_SURFACE_KINDS: ReadonlySet = new Set([ + "help", + "settings", + "permissions", + "plugins", + "hooks", + "mcp", + "models", + "add-provider", +]); + +/** + * Suspend the live replaceable command surface so a decision gate can take + * the host; the surface returns after the gate settles (see + * `resumeSuspendedCommandSurface`). Live gates and non-surface popups + * (palette, mentions, pickers) keep their contracts: arrivals behind them + * stay queued. Never loses a surface: a second suspend is a no-op while one + * is held. + */ +export function suspendReplaceableOverlay(shell: AppShell): void { + const bag = shellInternals(shell); + if (!bag || shell.overlayList === null) return; + const kind = shell.overlayKind; + if (kind === null || !GATE_PREEMPTABLE_SURFACE_KINDS.has(kind)) return; + if (bag.primaryBindings.isGate === true) return; + if (bag.suspendedCommandSurface !== null) return; + const frame = capturePrimaryFrame(shell, bag); + if (frame === null) return; + bag.suspendedCommandSurface = frame; + // Unsuspended close: idle-notify lets an older queued gate take the host + // first (FIFO); the caller opens its gate only if the host is still free. + closeInsetOverlay(shell); +} + +/** + * Return a suspended command surface to the host. No-op unless the host is + * empty — a queued gate always takes it first (the settle path drains gates + * before calling here). + */ +export function resumeSuspendedCommandSurface(shell: AppShell): void { + const bag = shellInternals(shell); + const suspended = bag?.suspendedCommandSurface; + if (!bag || !suspended) return; + if (shell.overlayList !== null) return; + bag.suspendedCommandSurface = null; + restorePrimaryFrame(shell, bag, suspended); +} + /** * Open an inset list overlay on the shared host (permissions / operator / picker / palette). * Measures body + list into geometry — no guessed absolute paint. @@ -145,18 +256,8 @@ export function openListOverlay( if (shell.overlayKind !== "palette") { const bag = shellInternals(shell); if (bag) { - bag.priorOverlay = { - kind: shell.overlayKind, - items: shell.overlayItems, - bodyLines: shell.overlayBodyLines, - bodyFgs: shell.overlayBodyFgs, - list: shell.overlayList, - title: String(shell.overlayTitle.content), - paletteCommands: shell.paletteCommands, - primaryBindings: { ...bag.primaryBindings }, - answer: bag.overlayAnswer, - titleText: bag.overlayTitleText, - }; + const frame = capturePrimaryFrame(shell, bag); + if (frame !== null) bag.priorOverlay = frame; } // Leave prior overlay focus frame; palette will stack above it. } else { @@ -442,28 +543,7 @@ export function closeInsetOverlay( if (prior && bag) { bag.priorOverlay = null; - // Restore prior primary overlay paint; focus should already be overlay. - shell.overlayItems = prior.items; - shell.overlayKind = prior.kind; - shell.overlayBodyLines = prior.bodyLines; - shell.overlayBodyFgs = prior.bodyFgs; - shell.overlayList = prior.list; - shell.paletteCommands = prior.paletteCommands; - shell.overlayTitle.visible = true; - shell.overlayTitle.content = prior.title; - bag.primaryBindings = { ...prior.primaryBindings }; - bag.overlayAnswer = prior.answer; - bag.overlayTitleText = prior.titleText; - // If focus was not stacked (edge case), re-open overlay frame. - if (focusOwner(shell.focus) !== "overlay") { - shell.focus = openOverlay(shell.focus, OVERLAY_FRAME_ID, { - target: "overlay", - scrollOwner: "overlay", - }); - } - relayoutOverlayHost(shell, prior.list.count); - applyFocus(shell); - paintOverlayList(shell); + restorePrimaryFrame(shell, bag, prior); return; } @@ -578,7 +658,10 @@ export function reserveOverlayHost(shell: AppShell): () => void { /** Drop in-flight host holds. Stale `release()` callbacks become no-ops. */ export function abortOverlayHostReservations(shell: AppShell): void { const bag = shellInternals(shell); - if (!bag || bag.overlayHostReservations === 0) return; + if (!bag) return; + // A torn-down session must not resurrect its suspended surface. + bag.suspendedCommandSurface = null; + if (bag.overlayHostReservations === 0) return; bag.overlayReservationEpoch += 1; bag.overlayHostReservations = 0; bag.overlayGeneration += 1; diff --git a/src/tui/slash-popup-gate.test.ts b/src/tui/slash-popup-gate.test.ts index dd70e7be4..01fa99e98 100644 --- a/src/tui/slash-popup-gate.test.ts +++ b/src/tui/slash-popup-gate.test.ts @@ -445,7 +445,12 @@ describe("slash/palette accept holds the host until dispatch settles", () => { }); }); - test("live gate plus queued gate plus stacked /help does not arm the queued timeout", async () => { + // CL-8792: the single-slot host drains queued gates before deferred + // command surfaces. Denying the live gate opens the queued card (arming its + // timer only now that it is shown); the deferred /help waits until no gate + // is outstanding. While the queued card is still hidden its timer stays + // unarmed even past its deadline. + test("queued gate takes the host before a deferred /help after the live gate settles", async () => { await withShell(async ({ shell }) => { const emitter = new EventEmitter(); const dispose = wireGates(emitter, shell); @@ -478,19 +483,26 @@ describe("slash/palette accept holds the host until dispatch settles", () => { acceptOverlaySelection(shell); expect(shell.overlayKind).toBe("permissions"); + // Past the queued card's deadline while it is still hidden: the timer + // must not have run. + await Bun.sleep(20); + expect(shell.overlayKind).toBe("permissions"); + expect(queuedResolved).toBeUndefined(); + + // Denying the live gate opens the queued card before the deferred + // /help surface. acceptOverlaySelection(shell); await Promise.resolve(); - expect(shell.overlayKind).toBe("help"); + expect(shell.overlayKind).toBe("permissions"); expect(liveResolved).toEqual({ allow: false }); expect(queuedResolved).toBeUndefined(); - await Bun.sleep(20); + // Settling the queued card hands the host to the deferred /help. + acceptOverlaySelection(shell); + await Promise.resolve(); + await Promise.resolve(); expect(shell.overlayKind).toBe("help"); - expect(queuedResolved).toBeUndefined(); - - closeInsetOverlay(shell); - expect(shell.overlayKind).toBe("permissions"); - expect(queuedResolved).toBeUndefined(); + expect(queuedResolved).toEqual({ allow: false }); } finally { dispose(); } @@ -832,7 +844,10 @@ describe("overlay host occupancy and opt-in deferral", () => { }); }); - test("accepting plugins from settings while a gate is queued opens plugins", async () => { + // CL-8792: a gate arriving over settings preempts it (settings is + // suspended, not lost) and settling the gate returns settings, from where + // plugins accept still works. + test("a gate preempts settings and settling it returns settings for plugins accept", async () => { const hanging = hangingSettingsList(); await withShell(async ({ shell }) => { const emitter = new EventEmitter(); @@ -877,9 +892,15 @@ describe("overlay host occupancy and opt-in deferral", () => { emitPermissionGate(emitter, (outcome) => { resolved = outcome; }); - expect(shell.overlayKind).toBe("settings"); + expect(shell.overlayKind).toBe("permissions"); expect(resolved).toBeUndefined(); + acceptOverlaySelection(shell); + await Promise.resolve(); + await Promise.resolve(); + expect(resolved).toEqual({ allow: false }); + expect(shell.overlayKind).toBe("settings"); + const pluginsIdx = shell.overlayItems.findIndex((row) => row.includes("plugins"), ); @@ -887,7 +908,6 @@ describe("overlay host occupancy and opt-in deferral", () => { for (let i = 0; i < pluginsIdx; i++) moveOverlaySelection(shell, 1); acceptOverlaySelection(shell); expect(shell.overlayKind).toBe("plugins"); - expect(resolved).toBeUndefined(); } finally { dispose(); } @@ -1021,7 +1041,10 @@ describe("overlay host occupancy and opt-in deferral", () => { ); }); - test("re-opening help while a gate is queued does not drain the gate", async () => { + // CL-8792: a replaceable command surface yields to a newly raised + // decision gate and returns after that gate settles. Re-opening help while + // the gate holds the host must neither settle the gate nor lose the surface. + test("a new gate preempts help and help returns after the gate settles", async () => { await withShell(async ({ shell }) => { const emitter = new EventEmitter(); const dispose = wireGates(emitter, shell); @@ -1034,14 +1057,19 @@ describe("overlay host occupancy and opt-in deferral", () => { emitPermissionGate(emitter, (outcome) => { resolved = outcome; }); - expect(shell.overlayKind).toBe("help"); + expect(shell.overlayKind).toBe("permissions"); expect(isOverlayHostIdle(shell)).toBe(false); expect(resolved).toBeUndefined(); openHelpOverlay(shell); - expect(shell.overlayKind).toBe("help"); + expect(shell.overlayKind).toBe("permissions"); expect(isOverlayHostIdle(shell)).toBe(false); expect(resolved).toBeUndefined(); + + acceptOverlaySelection(shell); + await Promise.resolve(); + expect(resolved).toEqual({ allow: false }); + expect(shell.overlayKind).toBe("help"); } finally { dispose(); } From 60bdaa82a81e5e0fe60e9a8167f8d212d25de561 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 26 Sep 2026 08:34:57 -0700 Subject: [PATCH 3/3] 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. --- src/tui/allow-once-reprompt.test.ts | 147 ++++++++++++++++++++++++++++ src/tui/gate-wire.ts | 4 +- src/tui/overlay-view.ts | 2 +- src/tui/shell/overlay-host.ts | 46 +++++---- 4 files changed, 179 insertions(+), 20 deletions(-) diff --git a/src/tui/allow-once-reprompt.test.ts b/src/tui/allow-once-reprompt.test.ts index 82f5b3c69..fa1d2f997 100644 --- a/src/tui/allow-once-reprompt.test.ts +++ b/src/tui/allow-once-reprompt.test.ts @@ -34,6 +34,7 @@ import { createAppShell } from "./shell/index.js"; import type { AppShell } from "./shell/internals.js"; import { acceptOverlaySelection, + closeInsetOverlay, openListOverlay, } from "./shell/overlay-host.js"; import { moveOverlaySelection } from "./shell/overlay-list.js"; @@ -41,6 +42,7 @@ import { streamRowCount } from "./shell/transcript.js"; import { wireGates } from "./gate-wire.js"; import type { PermissionGateEvent } from "./gate-events.js"; import { createGateRequestApproval } from "./request-approval.js"; +import { openAddProviderOverlay, openModelPickerOverlay } from "./overlays.js"; const shellCall = (command: string): ToolCall => ({ id: "c", @@ -475,3 +477,148 @@ describe("CL-8792 elapsed: pending tool row freezes while a gate is outstanding ); }); }); + +describe("CL-8792 overlay host: suspend preserves the surface instead of dismissing it", () => { + test("suspend does not fire onCancel or onDispose", async () => { + await withWiredWorld(async ({ shell, emitter }) => { + const events: string[] = []; + openListOverlay(shell, { + kind: "help", + title: "Slash", + items: ["Help"], + onCancel: () => events.push("cancel"), + onDispose: () => events.push("dispose"), + }); + expect(shell.overlayKind).toBe("help"); + + let resolved: unknown; + emitter.emit("permission.gate", { + id: "req-suspend-hooks", + request: destructiveRequest("rm -rf /tmp/cl8792-suspend-hooks"), + resolve: (outcome: unknown) => { + resolved = outcome; + }, + }); + expect(shell.overlayKind).toBe("permissions"); + expect(events).toEqual([]); + + acceptChoice(shell, 1); + expect(resolved).toEqual({ allow: true }); + expect(shell.overlayKind).toBe("help"); + expect(events).toEqual([]); + + closeInsetOverlay(shell); + expect(events).toEqual(["dispose", "cancel"]); + }); + }); + + test("restored SelectRenderable is live and parented in overlayView.body", async () => { + await withWiredWorld(async ({ shell, emitter }) => { + openSlash(shell); + const list = defined(shell.overlayList, "slash list"); + expect(list.select.isDestroyed).toBe(false); + expect(list.select.parent).toBe(shell.overlayView.body); + + let resolved: unknown; + emitter.emit("permission.gate", { + id: "req-restore-list", + request: destructiveRequest("rm -rf /tmp/cl8792-restore-list"), + resolve: (outcome: unknown) => { + resolved = outcome; + }, + }); + expect(shell.overlayKind).toBe("permissions"); + + acceptChoice(shell, 1); + expect(resolved).toEqual({ allow: true }); + expect(shell.overlayKind).toBe("help"); + expect(shell.overlayList).toBe(list); + expect(list.select.isDestroyed).toBe(false); + expect(list.select.parent).toBe(shell.overlayView.body); + expect(shell.overlayView.body.getChildren()).toContain(list.select); + }); + }); + + test.each([ + { + kind: "model_picker" as const, + open: (shell: AppShell) => + openModelPickerOverlay(shell, { items: ["grok-3"] }), + }, + { + kind: "add_provider" as const, + open: (shell: AppShell) => + openAddProviderOverlay(shell, { + items: ["custom"], + itemIds: ["custom"], + }), + }, + ])( + "$kind yields to a newly raised gate and returns after settle", + async ({ kind, open }) => { + await withWiredWorld(async ({ shell, emitter }) => { + open(shell); + expect(shell.overlayKind).toBe(kind); + + let resolved: unknown; + emitter.emit("permission.gate", { + id: `req-yield-${kind}`, + request: destructiveRequest(`rm -rf /tmp/cl8792-yield-${kind}`), + resolve: (outcome: unknown) => { + resolved = outcome; + }, + }); + expect(shell.overlayKind).toBe("permissions"); + + acceptChoice(shell, 1); + expect(resolved).toEqual({ allow: true }); + expect(shell.overlayKind).toBe(kind); + }); + }, + ); + + test("MCP onCancel during suspend does not steal the host from a queued gate while a deferred slash occupies idle", async () => { + await withWiredWorld(async ({ shell, emitter }) => { + let cancelOpens = 0; + openListOverlay(shell, { + kind: "mcp", + title: `remove stolen`, + items: ["Remove stolen", "Cancel"], + onCancel: () => { + cancelOpens += 1; + openListOverlay(shell, { + kind: "mcp", + title: "mcp", + items: ["stolen-server"], + }); + }, + }); + expect(shell.overlayKind).toBe("mcp"); + + openSlash(shell); + expect(shell.overlayKind).toBe("mcp"); + + let resolved: unknown; + emitter.emit("permission.gate", { + id: "req-mcp-cancel-steal", + request: destructiveRequest("rm -rf /tmp/cl8792-mcp-steal"), + resolve: (outcome: unknown) => { + resolved = outcome; + }, + }); + expect(cancelOpens).toBe(0); + expect(shell.overlayKind).toBe("permissions"); + expect(shell.overlayItems).toContain("Accept once"); + + acceptChoice(shell, 1); + expect(resolved).toEqual({ allow: true }); + expect(cancelOpens).toBe(0); + expect(shell.overlayKind).toBe("mcp"); + expect(shell.overlayItems).toEqual(["Remove stolen", "Cancel"]); + + await Promise.resolve(); + expect(shell.overlayKind).toBe("mcp"); + expect(shell.overlayItems).not.toEqual(["Help"]); + }); + }); +}); diff --git a/src/tui/gate-wire.ts b/src/tui/gate-wire.ts index 70c3f9d0a..6ccaa93fe 100644 --- a/src/tui/gate-wire.ts +++ b/src/tui/gate-wire.ts @@ -305,8 +305,8 @@ export function wireGates( if (shell.overlayList !== null) { // A replaceable command surface yields to the decision gate and is // restored after the gate settles. The suspend is a no-op for live - // gates and non-surface popups (palette, mentions, pickers — they keep - // their stacking contracts), so those arrivals simply stay queued. + // gates and stacked popups (palette, mentions — they keep their + // stacking contracts), so those arrivals simply stay queued. pending.push(open); suspendReplaceableOverlay(shell); // The suspend-close's idle-notify may already have opened an older diff --git a/src/tui/overlay-view.ts b/src/tui/overlay-view.ts index d198df32f..b5e75462d 100644 --- a/src/tui/overlay-view.ts +++ b/src/tui/overlay-view.ts @@ -435,5 +435,5 @@ export function createOverlayView(ctx: RenderContext) { ); } - return { host, title, body, paintTitle, paintList, clearBody }; + return { host, title, body, paintTitle, paintList, clearBody, detachList }; } diff --git a/src/tui/shell/overlay-host.ts b/src/tui/shell/overlay-host.ts index 5ebb1a0ac..f8a55ed0f 100644 --- a/src/tui/shell/overlay-host.ts +++ b/src/tui/shell/overlay-host.ts @@ -174,28 +174,32 @@ function restorePrimaryFrame( } /** - * Kinds opened through `openCommandSurface` (`CommandSurfaceKind` in - * ../command-surfaces.ts, restated here to keep shell/ import-clean). Only - * these yield to a decision gate. Inline popups (mentions, palette, pickers) - * keep their stacking contracts — suspending one would strand its owner, the - * CL-6698 mention-refresh stall — so a gate arriving behind them stays queued. + * Command surfaces that occupy the shared host and can yield to a decision + * gate. Kinds are the live `PrimaryOverlayKind` values those surfaces open + * with (`model_picker` / `add_provider`, not the command-surface aliases + * `models` / `add-provider`). Inline popups (mentions, palette, pickers that + * stack) keep their stacking contracts — suspending one would strand its + * owner, the CL-6698 mention-refresh stall — so a gate arriving behind them + * stays queued. */ -const GATE_PREEMPTABLE_SURFACE_KINDS: ReadonlySet = new Set([ - "help", - "settings", - "permissions", - "plugins", - "hooks", - "mcp", - "models", - "add-provider", -]); +const GATE_PREEMPTABLE_SURFACE_KINDS: ReadonlySet = new Set( + [ + "help", + "settings", + "permissions", + "plugins", + "hooks", + "mcp", + "model_picker", + "add_provider", + ], +); /** * Suspend the live replaceable command surface so a decision gate can take * the host; the surface returns after the gate settles (see - * `resumeSuspendedCommandSurface`). Live gates and non-surface popups - * (palette, mentions, pickers) keep their contracts: arrivals behind them + * `resumeSuspendedCommandSurface`). Live gates and stacked popups + * (palette, mentions) keep their contracts: arrivals behind them * stay queued. Never loses a surface: a second suspend is a no-op while one * is held. */ @@ -209,6 +213,14 @@ export function suspendReplaceableOverlay(shell: AppShell): void { const frame = capturePrimaryFrame(shell, bag); if (frame === null) return; bag.suspendedCommandSurface = frame; + // Suspend is not dismiss: keep the captured onCancel/onDispose for restore. + // closeInsetOverlay would otherwise run both — MCP's onDispose unsubscribes + // without a matching onOpened on restore, and remove-confirm onCancel would + // reopen a list onto the empty host and steal it from the arriving gate. + bag.primaryBindings.onCancel = null; + bag.primaryBindings.onDispose = null; + // Restore reuses this SelectRenderable; clearBody would destroy it. + shell.overlayView.detachList(frame.list); // Unsuspended close: idle-notify lets an older queued gate take the host // first (FIFO); the caller opens its gate only if the host is still free. closeInsetOverlay(shell);