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 */
56import { EventEmitter } from "node:events" ;
67import { describe , expect , test } from "bun:test" ;
@@ -11,6 +12,7 @@ import { wireGates } from "./gate-wire";
1112import { withTestRenderer } from "./harness" ;
1213import {
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+
86113describe ( "@ 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