Skip to content

Commit afce776

Browse files
Merge pull request #773 from corbitsdev/cl-6711-defer-queued-gates-until-slash-or-palette-command-dispatch
Defer overlay idle until slash command dispatch settles
2 parents 49121f5 + bf483e9 commit afce776

9 files changed

Lines changed: 1909 additions & 581 deletions

File tree

docs/TUI.md

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -259,10 +259,21 @@ for `/status` or an operator question mid-run.
259259
## How pop-ups should feel
260260

261261
A blocking surface (permissions, an operator question, the model/provider
262-
accounting (`src/tui/geometry/resolve.ts`,
263-
`src/tui/shell.ts:openListOverlay`). Opening a second surface either
264-
replaces the one that was open or stacks over it; either way Escape always
265-
walks back along a single path to the prompt.
262+
picker, help) occupies the shell's **single overlay host**
263+
(`src/tui/geometry/resolve.ts`,
264+
`src/tui/shell.ts:openListOverlay`). A second command surface replaces a
265+
non-gate list on that host, or waits with a system line while a live
266+
gate holds it. Palette may stack over a primary; Escape always walks
267+
back along a single path to the prompt.
268+
269+
Accepting a `/` command or palette row keeps that host until dispatch
270+
settles: the list closes without advertising idle, the command runs, and
271+
the host advertises idle only when no list, deferred slot, or reservation
272+
remains. A command surface (`/help`, `/model`) requested while a live
273+
gate still holds the host does not steal it and does not silently no-op
274+
— it waits until that gate closes, with a system line so the wait is
275+
visible. A gate that was only queued (never shown) stays queued while
276+
the command surface is up; its display timeout does not run.
266277

267278
An open overlay reserves a real minimum for its own border, title, and at
268279
least one content row before anything else — including the transcript floor
@@ -346,11 +357,18 @@ showing (`repaintPalette`).
346357

347358
`/` at an empty prompt opens the command list, narrowed by name prefix as
348359
more is typed (`cmd.id` in `openSlashCommands`); Tab completes the name so
349-
arguments can be typed, Enter runs it. The query lives in the prompt — list
350-
chrome is in How selectors should work above. When the prefix matches
351-
nothing, the overlay closes and the prompt is left as typed: `/` then `z`
352-
with no `z…` command vanishes the list and leaves `/z`. Slash never paints
353-
a `(no matches)` row. Every entry is backed by the live command registry
360+
arguments can be typed, Enter runs it. Enter on a matching command keeps
361+
the overlay host until dispatch settles, so a queued permission gate cannot
362+
open in the gap between closing the list and opening `/help` or `/model`.
363+
If the command list is stacked over a live permission or operator prompt,
364+
that prompt stays; the command surface waits until it closes, and a system
365+
line says so rather than dropping the selection. Tab, Escape, and Enter
366+
with no matches are genuine dismisses and still release the host. The query
367+
lives in the prompt — list chrome is in How selectors should work above.
368+
When the prefix matches nothing, the list stays open and paints a
369+
`(no matches)` row (CL-6699: a close-and-reopen refresh would drain a
370+
queued gate); Enter then dismisses and leaves the prompt as typed (`/z`).
371+
Every entry is backed by the live command registry
354372
(`src/tui/command-catalog.ts:commandItemsFromRegistry`) — there is no
355373
separate palette overlay and no shell-owned action outside the registry. The
356374
overlay this reuses is still internally called `"palette"` (`shell.ts`'s

src/tui/command-surfaces.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -972,6 +972,39 @@ describe("mcp surface", () => {
972972
});
973973
});
974974

975+
test("does not subscribe until the MCP overlay actually takes the host", async () => {
976+
await withShell(async (shell) => {
977+
openListOverlay(shell, {
978+
kind: "permissions",
979+
items: ["Deny"],
980+
onCancel: () => undefined,
981+
isGate: true,
982+
});
983+
const listeners = new Set<() => void>();
984+
openCommandSurface(shell, "mcp", {
985+
notify: () => {},
986+
mcp: {
987+
list: () => entries,
988+
openAuthURL: () => {},
989+
subscribe: (listener) => {
990+
listeners.add(listener);
991+
return () => listeners.delete(listener);
992+
},
993+
},
994+
});
995+
expect(shell.overlayKind).toBe("permissions");
996+
expect(listeners.size).toBe(0);
997+
998+
closeInsetOverlay(shell);
999+
await Promise.resolve();
1000+
expect(shell.overlayKind).toBe("mcp");
1001+
expect(listeners.size).toBe(1);
1002+
1003+
closeInsetOverlay(shell);
1004+
expect(listeners.size).toBe(0);
1005+
});
1006+
});
1007+
9751008
test("disposing an open MCP surface releases its status subscription exactly once", async () => {
9761009
await withShell((shell) => {
9771010
const listeners = new Set<() => void>();

0 commit comments

Comments
 (0)