|
2 | 2 | * Integration: the `@` path popup narrows as you type, the same contract the |
3 | 3 | * `/` command popup already honours. |
4 | 4 | */ |
| 5 | +import { EventEmitter } from "node:events" |
5 | 6 | import { describe, expect, test } from "bun:test" |
6 | 7 |
|
7 | 8 | import type { KeyEvent } from "@opentui/core" |
8 | 9 |
|
| 10 | +import { wireGates } from "./gate-wire" |
9 | 11 | import { withTestRenderer } from "./harness" |
10 | 12 | import { |
11 | 13 | acceptOverlaySelection, |
| 14 | + closeMentionPopup, |
12 | 15 | createAppShell, |
13 | 16 | handleMentionPopupKey, |
14 | 17 | isMentionPopupOpen, |
@@ -224,4 +227,64 @@ describe("@ popup narrows as you type", () => { |
224 | 227 | ]) |
225 | 228 | }) |
226 | 229 | }) |
| 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 | + }) |
227 | 290 | }) |
0 commit comments