diff --git a/src/tui/mention-popup.test.ts b/src/tui/mention-popup.test.ts index b903e9dcc..ee7ba5ae7 100644 --- a/src/tui/mention-popup.test.ts +++ b/src/tui/mention-popup.test.ts @@ -2,13 +2,16 @@ * Integration: the `@` path popup narrows as you type, the same contract the * `/` command popup already honours. */ +import { EventEmitter } from "node:events" import { describe, expect, test } from "bun:test" import type { KeyEvent } from "@opentui/core" +import { wireGates } from "./gate-wire" import { withTestRenderer } from "./harness" import { acceptOverlaySelection, + closeMentionPopup, createAppShell, handleMentionPopupKey, isMentionPopupOpen, @@ -224,4 +227,64 @@ describe("@ popup narrows as you type", () => { ]) }) }) + + // CL-6698: a queued permission/operator gate must not open onto the host + // in the middle of a mention filter session. The old close-then-reopen + // refresh released the host between the two calls, and a gate queued + // behind the popup drained into that gap — leaving the gate's overlay on + // screen while `mentionPopups` still (wrongly) claimed ownership, so + // further keystrokes went nowhere. + test("a queued gate stays queued across a mention filter refresh", async () => { + await withShell(async (shell) => { + const emitter = new EventEmitter() + const dispose = wireGates(emitter, shell) + try { + await openAt(shell, "@") + expect(isMentionPopupOpen(shell)).toBe(true) + + let resolved: unknown + emitter.emit("permission.gate", { + request: { + tool: "run_shell", + action: "Run shell command", + subject: "bun test", + scopes: [], + }, + resolve: (outcome: unknown) => { + resolved = outcome + }, + }) + + // Queued, not opened — the mention popup still owns the host. + expect(shell.overlayKind).toBe("mentions") + expect(resolved).toBeUndefined() + + // Refreshing the filter must not release the host to the queued gate. + expect(await type(shell, printable("s"))).toBe(true) + expect(shell.prompt.value).toBe("@s") + expect(shell.overlayKind).toBe("mentions") + expect(isMentionPopupOpen(shell)).toBe(true) + expect(shell.overlayItems).toEqual([ + "session-notes.md", + "src/", + "AGENTS.md", + ]) + expect(resolved).toBeUndefined() + + // Mention filtering keeps working after the refresh. + await type(shell, printable("e")) + expect(shell.prompt.value).toBe("@se") + expect(shell.overlayItems).toEqual(["session-notes.md"]) + expect(isMentionPopupOpen(shell)).toBe(true) + expect(resolved).toBeUndefined() + + // A true dismiss still drains the queue as before. + closeMentionPopup(shell) + expect(shell.overlayKind).toBe("permissions") + expect(resolved).toBeUndefined() + } finally { + dispose() + } + }) + }) }) diff --git a/src/tui/shell.ts b/src/tui/shell.ts index 89c6360ad..cbe8e1451 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -5052,16 +5052,33 @@ export async function openAtMentionSuggestions(shell: AppShell): Promise { - const completion = suggestions[selection.index] - if (completion === undefined) return + const state = mentionAcceptState.get(shell) + const completion = state?.suggestions[selection.index] + if (completion === undefined || state === undefined) return const spliced = spliceMentionCompletion( shell.prompt.value, - at.atStart, - cursor, + state.atStart, + state.cursor, completion, ) shell.prompt.value = spliced.value @@ -5076,6 +5093,12 @@ export async function openAtMentionSuggestions(shell: AppShell): Promise() const mentionGenerations = new WeakMap() +type MentionAcceptState = { + readonly suggestions: readonly string[] + readonly atStart: number + readonly cursor: number +} +const mentionAcceptState = new WeakMap() /** True while the `@` path popup owns typed characters. */ export function isMentionPopupOpen(shell: AppShell): boolean {