Skip to content

Commit a5e558c

Browse files
committed
Refresh slash popup in place instead of close+reopen
openSlashCommands closed and reopened the palette overlay on every filter keystroke, and closeSlashPopup routes through closeInsetOverlay which fires notifyOverlayClosed — releasing the host long enough for a queued permission/operator gate to drain onto it mid-typing. Mirror the @-mention popup fix (PR #515): when the slash popup is already open, refresh its items via setOverlayItems instead, leaving priorOverlay stacking untouched.
1 parent 026a0f6 commit a5e558c

2 files changed

Lines changed: 148 additions & 0 deletions

File tree

src/tui/shell.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5199,6 +5199,39 @@ export function openSlashCommands(shell: AppShell): boolean {
51995199
closeSlashPopup(shell)
52005200
return false
52015201
}
5202+
5203+
// Every keystroke lands here while the popup is already open. Closing and
5204+
// reopening released the overlay host between the two calls (closeSlashPopup
5205+
// routes through closeInsetOverlay, which fires notifyOverlayClosed) — long
5206+
// enough for a queued permission/operator gate to drain onto it. Refreshing
5207+
// the open palette in place never releases the host, so a queued gate has
5208+
// nothing to drain into. priorOverlay stacking is untouched here (it is only
5209+
// ever written by openListOverlay's stack-on-open path), so a palette
5210+
// stacked over a prior overlay keeps that snapshot across the refresh.
5211+
if (isSlashPopupOpen(shell) && shell.overlayKind === "palette") {
5212+
shell.paletteCommands = matches
5213+
const bag = internals.get(shell)
5214+
if (bag) {
5215+
bag.paletteFilter = {
5216+
query: bag.paletteFilter?.query ?? "",
5217+
title: "commands · /",
5218+
catalog: matches,
5219+
typeToFilter: false,
5220+
}
5221+
bag.overlayDescribe = (id) => {
5222+
const cmd = matches.find((c) => c.id === id)
5223+
const what = cmd?.description?.trim()
5224+
return what ? { what } : null
5225+
}
5226+
}
5227+
setOverlayItems(
5228+
shell,
5229+
paletteLabels(matches),
5230+
matches.map((c) => c.id),
5231+
)
5232+
return true
5233+
}
5234+
52025235
closeSlashPopup(shell)
52035236
openPalette(shell, { catalog: matches, title: "commands · /" })
52045237
slashPopups.add(shell)

src/tui/slash-popup-gate.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* CL-6699: a queued permission/operator gate must not open onto the host in
3+
* the middle of a `/` command filter session. The old close-then-reopen
4+
* refresh (closeSlashPopup -> closeInsetOverlay -> notifyOverlayClosed)
5+
* released the host between the two calls, and a gate queued behind the
6+
* popup drained into that gap.
7+
*/
8+
import { EventEmitter } from "node:events"
9+
import { describe, expect, test } from "bun:test"
10+
11+
import { withTestRenderer } from "./harness"
12+
import type { PaletteCommand } from "./command-catalog"
13+
import { wireGates } from "./gate-wire"
14+
import {
15+
createAppShell,
16+
isSlashPopupOpen,
17+
type AppShell,
18+
} from "./shell"
19+
20+
const CATALOG: readonly PaletteCommand[] = [
21+
{
22+
id: "model",
23+
label: "/model",
24+
description: "Open model picker",
25+
keywords: ["model", "Open model picker", "slash", "command"],
26+
},
27+
{ id: "mcp", label: "/mcp" },
28+
{ id: "compact", label: "/compact" },
29+
]
30+
31+
type Ctx = {
32+
readonly shell: AppShell
33+
readonly press: (key: string) => void
34+
readonly render: () => Promise<void>
35+
}
36+
37+
function withShell(fn: (ctx: Ctx) => Promise<void>): Promise<void> {
38+
return withTestRenderer(
39+
async (h) => {
40+
const shell = createAppShell(h.renderer, {
41+
terminal: { columns: 80, rows: 24 },
42+
wireKeys: true,
43+
run: "idle",
44+
paletteCatalog: CATALOG,
45+
})
46+
try {
47+
await fn({
48+
shell,
49+
press: (key) => h.pressKey(key as Parameters<typeof h.pressKey>[0]),
50+
render: h.renderOnce,
51+
})
52+
} finally {
53+
shell.dispose()
54+
}
55+
},
56+
{ width: 80, height: 24 },
57+
)
58+
}
59+
60+
describe("/ popup keeps a queued gate queued across a filter refresh", () => {
61+
test("filter keystroke while a gate is queued", async () => {
62+
await withShell(async ({ shell, press }) => {
63+
const emitter = new EventEmitter()
64+
const dispose = wireGates(emitter, shell)
65+
try {
66+
press("/")
67+
expect(isSlashPopupOpen(shell)).toBe(true)
68+
expect(shell.overlayKind).toBe("palette")
69+
70+
let resolved: unknown
71+
emitter.emit("permission.gate", {
72+
request: {
73+
tool: "run_shell",
74+
action: "Run shell command",
75+
subject: "bun test",
76+
scopes: [],
77+
},
78+
resolve: (outcome: unknown) => {
79+
resolved = outcome
80+
},
81+
})
82+
83+
// Queued, not opened — the slash popup still owns the host.
84+
expect(shell.overlayKind).toBe("palette")
85+
expect(resolved).toBeUndefined()
86+
87+
// Refreshing the filter must not release the host to the queued gate.
88+
press("m")
89+
expect(shell.prompt.value).toBe("/m")
90+
expect(shell.overlayKind).toBe("palette")
91+
expect(isSlashPopupOpen(shell)).toBe(true)
92+
expect(shell.paletteCommands.map((c) => c.id)).toEqual([
93+
"model",
94+
"mcp",
95+
])
96+
expect(resolved).toBeUndefined()
97+
98+
// Filtering keeps working after the refresh.
99+
press("o")
100+
expect(shell.prompt.value).toBe("/mo")
101+
expect(shell.paletteCommands.map((c) => c.id)).toEqual(["model"])
102+
expect(isSlashPopupOpen(shell)).toBe(true)
103+
expect(resolved).toBeUndefined()
104+
105+
// A true dismiss still drains the queue as before.
106+
press("Escape")
107+
await Bun.sleep(60)
108+
expect(shell.overlayKind).toBe("permissions")
109+
expect(resolved).toBeUndefined()
110+
} finally {
111+
dispose()
112+
}
113+
})
114+
})
115+
})

0 commit comments

Comments
 (0)