Skip to content

Commit 2979393

Browse files
committed
Defer overlay idle until slash command dispatch settles
Enter closed the command list before the selected command could claim the host, so a queued permission gate opened in the gap and the command vanished. Idle-notify now waits until dispatch settles, and a live gate is not closed to make room for the command surface.
1 parent bd880c6 commit 2979393

4 files changed

Lines changed: 244 additions & 60 deletions

File tree

docs/TUI.md

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -256,11 +256,21 @@ for `/status` or an operator question mid-run.
256256
## How pop-ups should feel
257257

258258
A blocking surface (permissions, an operator question, the model/provider
259-
accounting (`src/tui/geometry/resolve.ts`,
259+
picker, help) occupies the shell's **single overlay host**
260+
(`src/tui/geometry/resolve.ts`,
260261
`src/tui/shell.ts:openListOverlay`). Opening a second surface either
261262
replaces the one that was open or stacks over it; either way Escape always
262263
walks back along a single path to the prompt.
263264

265+
Accepting a `/` command or palette row keeps that host until dispatch
266+
settles: the list closes without advertising idle, the command runs, and
267+
`notifyOverlayClosed` fires only if the host is still empty. A command
268+
surface (`/help`, `/model`) requested while a live gate still holds the
269+
host does not steal it and does not silently no-op — it waits until that
270+
gate closes, with a system line so the wait is visible. A gate that was
271+
only queued (never shown) stays queued while the command surface is up;
272+
its display timeout does not run.
273+
264274
An open overlay reserves a real minimum for its own border, title, and at
265275
least one content row before anything else — including the transcript floor
266276
— is allowed to starve it further. That minimum is `OVERLAY_MIN_ROWS = 3`
@@ -343,11 +353,18 @@ showing (`repaintPalette`).
343353

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

src/tui/command-surfaces.ts

Lines changed: 67 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@ import { residualIdFromSelection, type ResidualCatalogEntry } from "./residuals.
1515
import {
1616
captureOverlayContinuation,
1717
closeInsetOverlay,
18+
closeReplaceableOverlay,
1819
isOverlayContinuationCurrent,
1920
openHelpOverlay,
2021
openListOverlay,
2122
openSettingsOverlay,
23+
reserveOverlayHost,
2224
setOwnedOverlayItems,
2325
setStatusFlash,
2426
type AppShell,
@@ -435,8 +437,8 @@ function renderSettingsMenu(
435437
// against the just-written value; closing first forces a real reopen (a
436438
// second open of the same primary kind while one is showing is a no-op)
437439
// while the captured index keeps the cursor where the operator left it.
438-
const activeIndex = shell.overlayList?.activeIndex ?? 0;
439-
closeInsetOverlay(shell);
440+
const activeIndex = shell.overlayKind === "settings" ? (shell.overlayList?.activeIndex ?? 0) : 0;
441+
closeReplaceableOverlay(shell);
440442
const snapshot = settings.read();
441443
const cycleRows = settingsCycleRows(snapshot, settings);
442444
const byId = new Map<string, SettingsCycleRow>(cycleRows.map((r) => [r.id, r]));
@@ -506,12 +508,26 @@ export function openSettingsSurface(shell: AppShell, deps: CommandSurfaceDeps):
506508
renderSettingsMenu(shell, deps, settings, settingsSyncNavRows(deps));
507509
return;
508510
}
509-
void deps.permissions.list().then((entries) => {
510-
renderSettingsMenu(shell, deps, settings, [
511-
permissionsNavRow(entries.length),
512-
...settingsSyncNavRows(deps),
513-
]);
514-
});
511+
const release = reserveOverlayHost(shell);
512+
void deps.permissions.list().then(
513+
(entries) => {
514+
try {
515+
renderSettingsMenu(shell, deps, settings, [
516+
permissionsNavRow(entries.length),
517+
...settingsSyncNavRows(deps),
518+
]);
519+
} finally {
520+
release();
521+
}
522+
},
523+
(err: unknown) => {
524+
try {
525+
deps.notify(`Could not read remembered approvals: ${errorText(err)}`);
526+
} finally {
527+
release();
528+
}
529+
},
530+
);
515531
}
516532

517533
/** Remembered approvals; Enter revokes the highlighted grant. */
@@ -521,40 +537,51 @@ export function openPermissionsSurface(shell: AppShell, deps: CommandSurfaceDeps
521537
deps.notify("Permission administration is not available in this session.");
522538
return;
523539
}
540+
const release = reserveOverlayHost(shell);
524541
void permissions.list().then(
525542
(entries) => {
526-
closeInsetOverlay(shell);
527-
const rows: ResidualCatalogEntry[] = entries.map((e) => ({
528-
id: e.id,
529-
label: grantRowLabel(e),
530-
}));
531-
if (rows.length === 0) {
532-
rows.push({
533-
id: CLOSE_ID,
534-
label: "No remembered approvals — grants you accept appear here",
543+
try {
544+
closeReplaceableOverlay(shell);
545+
const rows: ResidualCatalogEntry[] = entries.map((e) => ({
546+
id: e.id,
547+
label: grantRowLabel(e),
548+
}));
549+
if (rows.length === 0) {
550+
rows.push({
551+
id: CLOSE_ID,
552+
label: "No remembered approvals — grants you accept appear here",
553+
});
554+
}
555+
rows.push({ id: BACK_ID, label: "Back to settings" });
556+
openListOverlay(shell, {
557+
kind: "permissions",
558+
title: "permissions · Enter revokes",
559+
frameId: "overlay-permissions",
560+
...payload(rows),
561+
onAccept: (selection) => {
562+
const id = selectedId(selection, rows);
563+
if (id === undefined || id === CLOSE_ID) return;
564+
if (id === BACK_ID) {
565+
openSettingsSurface(shell, deps);
566+
return;
567+
}
568+
void permissions.revoke(id).then(
569+
() => openPermissionsSurface(shell, deps),
570+
(err: unknown) => deps.notify(`Revoke failed: ${errorText(err)}`),
571+
);
572+
},
535573
});
574+
} finally {
575+
release();
576+
}
577+
},
578+
(err: unknown) => {
579+
try {
580+
deps.notify(`Could not read remembered approvals: ${errorText(err)}`);
581+
} finally {
582+
release();
536583
}
537-
rows.push({ id: BACK_ID, label: "Back to settings" });
538-
openListOverlay(shell, {
539-
kind: "permissions",
540-
title: "permissions · Enter revokes",
541-
frameId: "overlay-permissions",
542-
...payload(rows),
543-
onAccept: (selection) => {
544-
const id = selectedId(selection, rows);
545-
if (id === undefined || id === CLOSE_ID) return;
546-
if (id === BACK_ID) {
547-
openSettingsSurface(shell, deps);
548-
return;
549-
}
550-
void permissions.revoke(id).then(
551-
() => openPermissionsSurface(shell, deps),
552-
(err: unknown) => deps.notify(`Revoke failed: ${errorText(err)}`),
553-
);
554-
},
555-
});
556584
},
557-
(err: unknown) => deps.notify(`Could not read remembered approvals: ${errorText(err)}`),
558585
);
559586
}
560587

@@ -786,7 +813,7 @@ export function openPluginsSurface(shell: AppShell, deps: CommandSurfaceDeps): v
786813
deps.notify("Plugin administration is not available in this session.");
787814
return;
788815
}
789-
closeInsetOverlay(shell);
816+
closeReplaceableOverlay(shell);
790817
const entries = plugins.list();
791818
const rows: ResidualCatalogEntry[] = entries.map((e) => ({
792819
id: e.id,
@@ -901,7 +928,7 @@ export function openHooksSurface(shell: AppShell, deps: CommandSurfaceDeps): voi
901928
deps.notify("Hook administration is not available in this session.");
902929
return;
903930
}
904-
closeInsetOverlay(shell);
931+
closeReplaceableOverlay(shell);
905932
const entries = hooks.list();
906933
const rows: ResidualCatalogEntry[] = entries.map((e) => ({ id: e.id, label: hookRowLabel(e) }));
907934
if (rows.length === 0) {
@@ -1075,7 +1102,7 @@ export function openMcpSurface(
10751102
deps.notify("MCP administration is not available in this session.");
10761103
return;
10771104
}
1078-
closeInsetOverlay(shell);
1105+
closeReplaceableOverlay(shell);
10791106
const entries = mcp.list();
10801107
const canAdd = canAddMCPServer(mcp);
10811108
const rows: ResidualCatalogEntry[] = mcpSurfaceRows(entries, canAdd);

src/tui/landing.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -553,7 +553,7 @@ describe("landing screen", () => {
553553
try {
554554
await settle(h);
555555
const frame = h.captureCharFrame();
556-
for (const gone of ["BUSY", "IDLE", "FOLLOW", "queue", "lines", "focus"]) {
556+
for (const gone of ["BUSY", "IDLE", "FOLLOW", "follow-up", "lines", "focus"]) {
557557
expect(frame).not.toContain(gone);
558558
}
559559
// The old header blue and status green are gone as fills.

0 commit comments

Comments
 (0)