Skip to content

Commit cbae96a

Browse files
Merge pull request #515 from corbitsdev/cl-6698-stop-mention-popup-close-reopen-from-draining-queued-gates
Stop mention popup refresh from draining queued gates
2 parents 8e90d98 + c592ae3 commit cbae96a

2 files changed

Lines changed: 90 additions & 4 deletions

File tree

‎src/tui/mention-popup.test.ts‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22
* Integration: the `@` path popup narrows as you type, the same contract the
33
* `/` command popup already honours.
44
*/
5+
import { EventEmitter } from "node:events"
56
import { describe, expect, test } from "bun:test"
67

78
import type { KeyEvent } from "@opentui/core"
89

10+
import { wireGates } from "./gate-wire"
911
import { withTestRenderer } from "./harness"
1012
import {
1113
acceptOverlaySelection,
14+
closeMentionPopup,
1215
createAppShell,
1316
handleMentionPopupKey,
1417
isMentionPopupOpen,
@@ -224,4 +227,64 @@ describe("@ popup narrows as you type", () => {
224227
])
225228
})
226229
})
230+
231+
// CL-6698: a queued permission/operator gate must not open onto the host
232+
// in the middle of a mention filter session. The old close-then-reopen
233+
// refresh released the host between the two calls, and a gate queued
234+
// behind the popup drained into that gap — leaving the gate's overlay on
235+
// screen while `mentionPopups` still (wrongly) claimed ownership, so
236+
// further keystrokes went nowhere.
237+
test("a queued gate stays queued across a mention filter refresh", async () => {
238+
await withShell(async (shell) => {
239+
const emitter = new EventEmitter()
240+
const dispose = wireGates(emitter, shell)
241+
try {
242+
await openAt(shell, "@")
243+
expect(isMentionPopupOpen(shell)).toBe(true)
244+
245+
let resolved: unknown
246+
emitter.emit("permission.gate", {
247+
request: {
248+
tool: "run_shell",
249+
action: "Run shell command",
250+
subject: "bun test",
251+
scopes: [],
252+
},
253+
resolve: (outcome: unknown) => {
254+
resolved = outcome
255+
},
256+
})
257+
258+
// Queued, not opened — the mention popup still owns the host.
259+
expect(shell.overlayKind).toBe("mentions")
260+
expect(resolved).toBeUndefined()
261+
262+
// Refreshing the filter must not release the host to the queued gate.
263+
expect(await type(shell, printable("s"))).toBe(true)
264+
expect(shell.prompt.value).toBe("@s")
265+
expect(shell.overlayKind).toBe("mentions")
266+
expect(isMentionPopupOpen(shell)).toBe(true)
267+
expect(shell.overlayItems).toEqual([
268+
"session-notes.md",
269+
"src/",
270+
"AGENTS.md",
271+
])
272+
expect(resolved).toBeUndefined()
273+
274+
// Mention filtering keeps working after the refresh.
275+
await type(shell, printable("e"))
276+
expect(shell.prompt.value).toBe("@se")
277+
expect(shell.overlayItems).toEqual(["session-notes.md"])
278+
expect(isMentionPopupOpen(shell)).toBe(true)
279+
expect(resolved).toBeUndefined()
280+
281+
// A true dismiss still drains the queue as before.
282+
closeMentionPopup(shell)
283+
expect(shell.overlayKind).toBe("permissions")
284+
expect(resolved).toBeUndefined()
285+
} finally {
286+
dispose()
287+
}
288+
})
289+
})
227290
})

‎src/tui/shell.ts‎

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5052,16 +5052,33 @@ export async function openAtMentionSuggestions(shell: AppShell): Promise<boolean
50525052
return false
50535053
}
50545054

5055+
// The onAccept closure reads through this ref rather than closing over
5056+
// `suggestions`/`at`/`cursor` directly, so a same-session refresh can
5057+
// update what accept splices without re-binding the callback.
5058+
mentionAcceptState.set(shell, { suggestions, atStart: at.atStart, cursor })
5059+
5060+
// Every keystroke lands here while the popup is already open. Closing and
5061+
// reopening the overlay released the host between the two calls — long
5062+
// enough for a queued permission/operator gate to open on it — and left the
5063+
// gate's overlay on screen while `mentionPopups` still claimed ownership.
5064+
// Refreshing the open list in place never releases the host, so a queued
5065+
// gate has nothing to drain into.
5066+
if (isMentionPopupOpen(shell)) {
5067+
setOverlayItems(shell, [...suggestions])
5068+
return true
5069+
}
5070+
50555071
closeMentionPopup(shell)
50565072
openMentionsOverlay(shell, {
50575073
items: [...suggestions],
50585074
onAccept: (selection) => {
5059-
const completion = suggestions[selection.index]
5060-
if (completion === undefined) return
5075+
const state = mentionAcceptState.get(shell)
5076+
const completion = state?.suggestions[selection.index]
5077+
if (completion === undefined || state === undefined) return
50615078
const spliced = spliceMentionCompletion(
50625079
shell.prompt.value,
5063-
at.atStart,
5064-
cursor,
5080+
state.atStart,
5081+
state.cursor,
50655082
completion,
50665083
)
50675084
shell.prompt.value = spliced.value
@@ -5076,6 +5093,12 @@ export async function openAtMentionSuggestions(shell: AppShell): Promise<boolean
50765093

50775094
const mentionPopups = new WeakSet<AppShell>()
50785095
const mentionGenerations = new WeakMap<AppShell, number>()
5096+
type MentionAcceptState = {
5097+
readonly suggestions: readonly string[]
5098+
readonly atStart: number
5099+
readonly cursor: number
5100+
}
5101+
const mentionAcceptState = new WeakMap<AppShell, MentionAcceptState>()
50795102

50805103
/** True while the `@` path popup owns typed characters. */
50815104
export function isMentionPopupOpen(shell: AppShell): boolean {

0 commit comments

Comments
 (0)