Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions src/tui/mention-popup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
}
})
})
})
31 changes: 27 additions & 4 deletions src/tui/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5052,16 +5052,33 @@ export async function openAtMentionSuggestions(shell: AppShell): Promise<boolean
return false
}

// The onAccept closure reads through this ref rather than closing over
// `suggestions`/`at`/`cursor` directly, so a same-session refresh can
// update what accept splices without re-binding the callback.
mentionAcceptState.set(shell, { suggestions, atStart: at.atStart, cursor })

// Every keystroke lands here while the popup is already open. Closing and
// reopening the overlay released the host between the two calls — long
// enough for a queued permission/operator gate to open on it — and left the
// gate's overlay on screen while `mentionPopups` still claimed ownership.
// Refreshing the open list in place never releases the host, so a queued
// gate has nothing to drain into.
if (isMentionPopupOpen(shell)) {
setOverlayItems(shell, [...suggestions])
return true
}

closeMentionPopup(shell)
openMentionsOverlay(shell, {
items: [...suggestions],
onAccept: (selection) => {
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
Expand All @@ -5076,6 +5093,12 @@ export async function openAtMentionSuggestions(shell: AppShell): Promise<boolean

const mentionPopups = new WeakSet<AppShell>()
const mentionGenerations = new WeakMap<AppShell, number>()
type MentionAcceptState = {
readonly suggestions: readonly string[]
readonly atStart: number
readonly cursor: number
}
const mentionAcceptState = new WeakMap<AppShell, MentionAcceptState>()

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