Skip to content

Commit 52279c4

Browse files
Merge pull request #764 from corbitsdev/cl-6718-gate-mention-accept-on-live-parse-and-clear-stale-accept
Fix stale mention accept during overlay reopen
2 parents 6854c40 + 853839c commit 52279c4

3 files changed

Lines changed: 351 additions & 40 deletions

File tree

docs/TUI.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -548,10 +548,16 @@ heuristic is permanently skipped for the rest of the session
548548
(`shell.ts`, the `sawBracketedPaste` guard).
549549

550550
@-mention path completion opens a popup keyed off the `@token` under the
551-
cursor (`openAtMentionSuggestions`, `shell.ts`); every keystroke re-queries,
551+
cursor (`openAtMentionSuggestions`, `src/tui/shell.ts`); every keystroke re-queries,
552552
and a generation counter discards a slower, stale query's results if a newer
553-
one already landed. Directory picks re-open one level down so the operator
554-
can drill into a path without retyping it.
553+
one already landed. Accept is refused unless that generation is still current
554+
and a live `@` token is under the cursor (the same `@` the lookup started on).
555+
Enter that fails those checks dismisses the popup (same generation bump as Esc)
556+
so an in-flight lookup cannot reopen it. A lookup that finishes after the
557+
cursor has left that token does not open. Dismiss clears mention accept state
558+
and bumps generation.
559+
Directory picks re-open one level down so the operator can drill into a path
560+
without retyping it.
555561

556562
A readline-style kill ring backs Ctrl+K/U/W (kill) and Ctrl+Y/Alt+Y
557563
(yank/yank-pop) on top of the textarea's native delete bindings, which

src/tui/mention-popup.test.ts

Lines changed: 273 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/**
22
* Integration: the `@` path popup narrows as you type, the same contract the
3-
* `/` command popup already honours.
3+
* `/` command popup already honours. Mention accept is gated on a current
4+
* generation and a live `@` token under the cursor.
45
*/
56
import { EventEmitter } from "node:events";
67
import { describe, expect, test } from "bun:test";
@@ -11,6 +12,7 @@ import { wireGates } from "./gate-wire";
1112
import { withTestRenderer } from "./harness";
1213
import {
1314
acceptOverlaySelection,
15+
closeInsetOverlay,
1416
closeMentionPopup,
1517
createAppShell,
1618
handleMentionPopupKey,
@@ -68,12 +70,17 @@ const BACKSPACE = {
6870
option: false,
6971
} as unknown as KeyEvent;
7072

71-
/** Drive one key and let the popup's async re-query settle. */
72-
async function type(shell: AppShell, key: KeyEvent): Promise<boolean> {
73-
const handled = handleMentionPopupKey(shell, key);
73+
/** Flush the three microtask hops `openAtMentionSuggestions` takes after a key. */
74+
async function drainMicrotasks(): Promise<void> {
7475
await Promise.resolve();
7576
await Promise.resolve();
7677
await Promise.resolve();
78+
}
79+
80+
/** Drive one key and let the popup's async re-query settle. */
81+
async function type(shell: AppShell, key: KeyEvent): Promise<boolean> {
82+
const handled = handleMentionPopupKey(shell, key);
83+
await drainMicrotasks();
7784
return handled;
7885
}
7986

@@ -83,6 +90,26 @@ async function openAt(shell: AppShell, value: string): Promise<void> {
8390
await openAtMentionSuggestions(shell);
8491
}
8592

93+
function hangableSource(): {
94+
source: (prefix: string) => Promise<readonly string[]>;
95+
resolveNext: (entries: readonly string[]) => void;
96+
} {
97+
const pending: ((entries: readonly string[]) => void)[] = [];
98+
return {
99+
source: (_prefix) =>
100+
new Promise<readonly string[]>((resolve) => {
101+
pending.push(resolve);
102+
}),
103+
resolveNext: (entries) => {
104+
const resolve = pending.shift();
105+
if (resolve === undefined) throw new Error("no pending mention lookup");
106+
resolve(entries);
107+
},
108+
};
109+
}
110+
111+
const ROOT = TREE[""]!;
112+
86113
describe("@ popup narrows as you type", () => {
87114
test("printable keys filter the list and land in the prompt", async () => {
88115
await withShell(async (shell) => {
@@ -126,22 +153,16 @@ describe("@ popup narrows as you type", () => {
126153
wireKeys: false,
127154
run: "idle",
128155
});
129-
let resolveLookup: (entries: readonly string[]) => void = () => {};
130-
setMentionSuggestionSource(
131-
shell,
132-
() =>
133-
new Promise<readonly string[]>((resolve) => {
134-
resolveLookup = resolve;
135-
}),
136-
);
156+
const { source, resolveNext } = hangableSource();
157+
setMentionSuggestionSource(shell, source);
137158

138159
shell.prompt.value = "read @";
139160
shell.prompt.cursorOffset = shell.prompt.value.length;
140161
const pending = openAtMentionSuggestions(shell);
141162

142163
// The operator quits before the filesystem lookup answers.
143164
shell.dispose();
144-
resolveLookup(["AGENTS.md", "README.md"]);
165+
resolveNext(["AGENTS.md", "README.md"]);
145166

146167
await expect(pending).resolves.toBe(false);
147168
expect(shell.overlayKind).toBeNull();
@@ -198,9 +219,7 @@ describe("@ popup narrows as you type", () => {
198219

199220
acceptOverlaySelection(shell);
200221
// The accept splices `src/` and re-opens; let the re-query settle.
201-
await Promise.resolve();
202-
await Promise.resolve();
203-
await Promise.resolve();
222+
await drainMicrotasks();
204223
expect(shell.prompt.value).toBe("@src/");
205224
expect(shell.overlayKind).toBe("mentions");
206225
expect(shell.overlayItems).toEqual([
@@ -275,4 +294,242 @@ describe("@ popup narrows as you type", () => {
275294
}
276295
});
277296
});
297+
298+
test("a permission gate that opened during lookup keeps mentions closed", async () => {
299+
await withShell(async (shell) => {
300+
const emitter = new EventEmitter();
301+
const dispose = wireGates(emitter, shell);
302+
const { source, resolveNext } = hangableSource();
303+
setMentionSuggestionSource(shell, source);
304+
try {
305+
shell.prompt.value = "read @";
306+
shell.prompt.cursorOffset = shell.prompt.value.length;
307+
const pending = openAtMentionSuggestions(shell);
308+
309+
emitter.emit("permission.gate", {
310+
request: {
311+
tool: "run_shell",
312+
action: "Run shell command",
313+
subject: "bun test",
314+
scopes: [],
315+
},
316+
resolve: () => {},
317+
});
318+
expect(shell.overlayKind).toBe("permissions");
319+
320+
resolveNext(ROOT);
321+
expect(await pending).toBe(false);
322+
expect(isMentionPopupOpen(shell)).toBe(false);
323+
expect(shell.overlayKind).toBe("permissions");
324+
expect(shell.prompt.value).toBe("read @");
325+
} finally {
326+
dispose();
327+
}
328+
});
329+
});
330+
});
331+
332+
describe("mention accept requires a live @token", () => {
333+
test("accept after the lookup resolves splices the live token", async () => {
334+
await withShell(async (shell) => {
335+
const { source, resolveNext } = hangableSource();
336+
setMentionSuggestionSource(shell, source);
337+
338+
shell.prompt.value = "read @";
339+
shell.prompt.cursorOffset = shell.prompt.value.length;
340+
const pending = openAtMentionSuggestions(shell);
341+
resolveNext(ROOT);
342+
expect(await pending).toBe(true);
343+
expect(isMentionPopupOpen(shell)).toBe(true);
344+
const first = shell.overlayItems[0];
345+
expect(first).toBeDefined();
346+
347+
acceptOverlaySelection(shell);
348+
expect(shell.prompt.value).toBe(`read @${first}`);
349+
expect(isMentionPopupOpen(shell)).toBe(false);
350+
expect(shell.overlayKind).toBeNull();
351+
});
352+
});
353+
354+
test("accept during an in-flight re-query does not splice", async () => {
355+
await withShell(async (shell) => {
356+
const { source, resolveNext } = hangableSource();
357+
setMentionSuggestionSource(shell, source);
358+
359+
shell.prompt.value = "read @";
360+
shell.prompt.cursorOffset = shell.prompt.value.length;
361+
const first = openAtMentionSuggestions(shell);
362+
resolveNext(ROOT);
363+
expect(await first).toBe(true);
364+
expect(isMentionPopupOpen(shell)).toBe(true);
365+
366+
expect(handleMentionPopupKey(shell, printable("s"))).toBe(true);
367+
expect(shell.prompt.value).toBe("read @s");
368+
// Second lookup is in flight; do not resolve it.
369+
370+
acceptOverlaySelection(shell);
371+
expect(shell.prompt.value).toBe("read @s");
372+
expect(isMentionPopupOpen(shell)).toBe(false);
373+
expect(shell.overlayKind).toBeNull();
374+
375+
resolveNext(ROOT);
376+
await drainMicrotasks();
377+
378+
expect(isMentionPopupOpen(shell)).toBe(false);
379+
expect(shell.overlayKind).toBeNull();
380+
expect(shell.prompt.value).toBe("read @s");
381+
});
382+
});
383+
384+
test("accept during an in-flight no-match re-query does not splice", async () => {
385+
await withShell(async (shell) => {
386+
const { source, resolveNext } = hangableSource();
387+
setMentionSuggestionSource(shell, source);
388+
389+
shell.prompt.value = "read @";
390+
shell.prompt.cursorOffset = shell.prompt.value.length;
391+
const first = openAtMentionSuggestions(shell);
392+
resolveNext(ROOT);
393+
expect(await first).toBe(true);
394+
expect(isMentionPopupOpen(shell)).toBe(true);
395+
396+
expect(handleMentionPopupKey(shell, printable("z"))).toBe(true);
397+
expect(shell.prompt.value).toBe("read @z");
398+
399+
acceptOverlaySelection(shell);
400+
expect(shell.prompt.value).toBe("read @z");
401+
expect(isMentionPopupOpen(shell)).toBe(false);
402+
expect(shell.overlayKind).toBeNull();
403+
404+
resolveNext([]);
405+
await drainMicrotasks();
406+
407+
expect(isMentionPopupOpen(shell)).toBe(false);
408+
expect(shell.overlayKind).toBeNull();
409+
expect(shell.prompt.value).toBe("read @z");
410+
});
411+
});
412+
413+
test("accept with cursor off the @token does not splice", async () => {
414+
await withShell(async (shell) => {
415+
await openAt(shell, "read @");
416+
expect(isMentionPopupOpen(shell)).toBe(true);
417+
418+
shell.prompt.cursorOffset = 0;
419+
acceptOverlaySelection(shell);
420+
421+
expect(isMentionPopupOpen(shell)).toBe(false);
422+
expect(shell.overlayKind).toBeNull();
423+
expect(shell.prompt.value).toBe("read @");
424+
});
425+
});
426+
427+
test("accept with cursor on a different @token does not splice", async () => {
428+
await withShell(async (shell) => {
429+
const value = "see @a and @b";
430+
shell.prompt.value = value;
431+
shell.prompt.cursorOffset = "see @a".length;
432+
expect(await openAtMentionSuggestions(shell)).toBe(true);
433+
expect(isMentionPopupOpen(shell)).toBe(true);
434+
435+
shell.prompt.cursorOffset = value.length;
436+
acceptOverlaySelection(shell);
437+
438+
expect(isMentionPopupOpen(shell)).toBe(false);
439+
expect(shell.overlayKind).toBeNull();
440+
expect(shell.prompt.value).toBe(value);
441+
});
442+
});
443+
444+
test("a lookup whose cursor has left the token does not open", async () => {
445+
await withShell(async (shell) => {
446+
const { source, resolveNext } = hangableSource();
447+
setMentionSuggestionSource(shell, source);
448+
449+
shell.prompt.value = "read @";
450+
shell.prompt.cursorOffset = shell.prompt.value.length;
451+
const pending = openAtMentionSuggestions(shell);
452+
shell.prompt.cursorOffset = 0;
453+
resolveNext(ROOT);
454+
455+
expect(await pending).toBe(false);
456+
expect(isMentionPopupOpen(shell)).toBe(false);
457+
expect(shell.overlayKind).toBeNull();
458+
});
459+
});
460+
461+
test("a lookup whose cursor moved onto a different @token does not open", async () => {
462+
await withShell(async (shell) => {
463+
const { source, resolveNext } = hangableSource();
464+
setMentionSuggestionSource(shell, source);
465+
466+
const value = "see @a and @b";
467+
shell.prompt.value = value;
468+
shell.prompt.cursorOffset = "see @a".length;
469+
const pending = openAtMentionSuggestions(shell);
470+
shell.prompt.cursorOffset = value.length;
471+
resolveNext(ROOT);
472+
473+
expect(await pending).toBe(false);
474+
expect(isMentionPopupOpen(shell)).toBe(false);
475+
expect(shell.overlayKind).toBeNull();
476+
});
477+
});
478+
479+
test("closeMentionPopup during an in-flight lookup does not reopen", async () => {
480+
await withShell(async (shell) => {
481+
const { source, resolveNext } = hangableSource();
482+
setMentionSuggestionSource(shell, source);
483+
484+
shell.prompt.value = "read @";
485+
shell.prompt.cursorOffset = shell.prompt.value.length;
486+
const first = openAtMentionSuggestions(shell);
487+
resolveNext(ROOT);
488+
expect(await first).toBe(true);
489+
expect(isMentionPopupOpen(shell)).toBe(true);
490+
491+
expect(handleMentionPopupKey(shell, printable("s"))).toBe(true);
492+
expect(shell.prompt.value).toBe("read @s");
493+
494+
closeMentionPopup(shell);
495+
expect(isMentionPopupOpen(shell)).toBe(false);
496+
expect(shell.overlayList).toBeNull();
497+
expect(shell.overlayKind).toBeNull();
498+
499+
resolveNext(ROOT);
500+
await drainMicrotasks();
501+
502+
expect(isMentionPopupOpen(shell)).toBe(false);
503+
expect(shell.overlayKind).toBeNull();
504+
expect(shell.prompt.value).toBe("read @s");
505+
});
506+
});
507+
508+
test("closeInsetOverlay during an in-flight lookup does not reopen", async () => {
509+
await withShell(async (shell) => {
510+
const { source, resolveNext } = hangableSource();
511+
setMentionSuggestionSource(shell, source);
512+
513+
shell.prompt.value = "read @";
514+
shell.prompt.cursorOffset = shell.prompt.value.length;
515+
const first = openAtMentionSuggestions(shell);
516+
resolveNext(ROOT);
517+
expect(await first).toBe(true);
518+
expect(isMentionPopupOpen(shell)).toBe(true);
519+
520+
expect(handleMentionPopupKey(shell, printable("s"))).toBe(true);
521+
expect(shell.prompt.value).toBe("read @s");
522+
523+
closeInsetOverlay(shell);
524+
expect(isMentionPopupOpen(shell)).toBe(false);
525+
expect(shell.overlayKind).toBeNull();
526+
527+
resolveNext(ROOT);
528+
await drainMicrotasks();
529+
530+
expect(isMentionPopupOpen(shell)).toBe(false);
531+
expect(shell.overlayKind).toBeNull();
532+
expect(shell.prompt.value).toBe("read @s");
533+
});
534+
});
278535
});

0 commit comments

Comments
 (0)