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
12 changes: 9 additions & 3 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -545,10 +545,16 @@ heuristic is permanently skipped for the rest of the session
(`shell.ts`, the `sawBracketedPaste` guard).

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

A readline-style kill ring backs Ctrl+K/U/W (kill) and Ctrl+Y/Alt+Y
(yank/yank-pop) on top of the textarea's native delete bindings, which
Expand Down
289 changes: 273 additions & 16 deletions src/tui/mention-popup.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/**
* Integration: the `@` path popup narrows as you type, the same contract the
* `/` command popup already honours.
* `/` command popup already honours. Mention accept is gated on a current
* generation and a live `@` token under the cursor.
*/
import { EventEmitter } from "node:events";
import { describe, expect, test } from "bun:test";
Expand All @@ -11,6 +12,7 @@ import { wireGates } from "./gate-wire";
import { withTestRenderer } from "./harness";
import {
acceptOverlaySelection,
closeInsetOverlay,
closeMentionPopup,
createAppShell,
handleMentionPopupKey,
Expand Down Expand Up @@ -68,12 +70,17 @@ const BACKSPACE = {
option: false,
} as unknown as KeyEvent;

/** Drive one key and let the popup's async re-query settle. */
async function type(shell: AppShell, key: KeyEvent): Promise<boolean> {
const handled = handleMentionPopupKey(shell, key);
/** Flush the three microtask hops `openAtMentionSuggestions` takes after a key. */
async function drainMicrotasks(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
}

/** Drive one key and let the popup's async re-query settle. */
async function type(shell: AppShell, key: KeyEvent): Promise<boolean> {
const handled = handleMentionPopupKey(shell, key);
await drainMicrotasks();
return handled;
}

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

function hangableSource(): {
source: (prefix: string) => Promise<readonly string[]>;
resolveNext: (entries: readonly string[]) => void;
} {
const pending: ((entries: readonly string[]) => void)[] = [];
return {
source: (_prefix) =>
new Promise<readonly string[]>((resolve) => {
pending.push(resolve);
}),
resolveNext: (entries) => {
const resolve = pending.shift();
if (resolve === undefined) throw new Error("no pending mention lookup");
resolve(entries);
},
};
}

const ROOT = TREE[""]!;

describe("@ popup narrows as you type", () => {
test("printable keys filter the list and land in the prompt", async () => {
await withShell(async (shell) => {
Expand Down Expand Up @@ -126,22 +153,16 @@ describe("@ popup narrows as you type", () => {
wireKeys: false,
run: "idle",
});
let resolveLookup: (entries: readonly string[]) => void = () => {};
setMentionSuggestionSource(
shell,
() =>
new Promise<readonly string[]>((resolve) => {
resolveLookup = resolve;
}),
);
const { source, resolveNext } = hangableSource();
setMentionSuggestionSource(shell, source);

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

// The operator quits before the filesystem lookup answers.
shell.dispose();
resolveLookup(["AGENTS.md", "README.md"]);
resolveNext(["AGENTS.md", "README.md"]);

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

acceptOverlaySelection(shell);
// The accept splices `src/` and re-opens; let the re-query settle.
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await drainMicrotasks();
expect(shell.prompt.value).toBe("@src/");
expect(shell.overlayKind).toBe("mentions");
expect(shell.overlayItems).toEqual([
Expand Down Expand Up @@ -275,4 +294,242 @@ describe("@ popup narrows as you type", () => {
}
});
});

test("a permission gate that opened during lookup keeps mentions closed", async () => {
await withShell(async (shell) => {
const emitter = new EventEmitter();
const dispose = wireGates(emitter, shell);
const { source, resolveNext } = hangableSource();
setMentionSuggestionSource(shell, source);
try {
shell.prompt.value = "read @";
shell.prompt.cursorOffset = shell.prompt.value.length;
const pending = openAtMentionSuggestions(shell);

emitter.emit("permission.gate", {
request: {
tool: "run_shell",
action: "Run shell command",
subject: "bun test",
scopes: [],
},
resolve: () => {},
});
expect(shell.overlayKind).toBe("permissions");

resolveNext(ROOT);
expect(await pending).toBe(false);
expect(isMentionPopupOpen(shell)).toBe(false);
expect(shell.overlayKind).toBe("permissions");
expect(shell.prompt.value).toBe("read @");
} finally {
dispose();
}
});
});
});

describe("mention accept requires a live @token", () => {
test("accept after the lookup resolves splices the live token", async () => {
await withShell(async (shell) => {
const { source, resolveNext } = hangableSource();
setMentionSuggestionSource(shell, source);

shell.prompt.value = "read @";
shell.prompt.cursorOffset = shell.prompt.value.length;
const pending = openAtMentionSuggestions(shell);
resolveNext(ROOT);
expect(await pending).toBe(true);
expect(isMentionPopupOpen(shell)).toBe(true);
const first = shell.overlayItems[0];
expect(first).toBeDefined();

acceptOverlaySelection(shell);
expect(shell.prompt.value).toBe(`read @${first}`);
expect(isMentionPopupOpen(shell)).toBe(false);
expect(shell.overlayKind).toBeNull();
});
});

test("accept during an in-flight re-query does not splice", async () => {
await withShell(async (shell) => {
const { source, resolveNext } = hangableSource();
setMentionSuggestionSource(shell, source);

shell.prompt.value = "read @";
shell.prompt.cursorOffset = shell.prompt.value.length;
const first = openAtMentionSuggestions(shell);
resolveNext(ROOT);
expect(await first).toBe(true);
expect(isMentionPopupOpen(shell)).toBe(true);

expect(handleMentionPopupKey(shell, printable("s"))).toBe(true);
expect(shell.prompt.value).toBe("read @s");
// Second lookup is in flight; do not resolve it.

acceptOverlaySelection(shell);
expect(shell.prompt.value).toBe("read @s");
expect(isMentionPopupOpen(shell)).toBe(false);
expect(shell.overlayKind).toBeNull();

resolveNext(ROOT);
await drainMicrotasks();

expect(isMentionPopupOpen(shell)).toBe(false);
expect(shell.overlayKind).toBeNull();
expect(shell.prompt.value).toBe("read @s");
});
});

test("accept during an in-flight no-match re-query does not splice", async () => {
await withShell(async (shell) => {
const { source, resolveNext } = hangableSource();
setMentionSuggestionSource(shell, source);

shell.prompt.value = "read @";
shell.prompt.cursorOffset = shell.prompt.value.length;
const first = openAtMentionSuggestions(shell);
resolveNext(ROOT);
expect(await first).toBe(true);
expect(isMentionPopupOpen(shell)).toBe(true);

expect(handleMentionPopupKey(shell, printable("z"))).toBe(true);
expect(shell.prompt.value).toBe("read @z");

acceptOverlaySelection(shell);
expect(shell.prompt.value).toBe("read @z");
expect(isMentionPopupOpen(shell)).toBe(false);
expect(shell.overlayKind).toBeNull();

resolveNext([]);
await drainMicrotasks();

expect(isMentionPopupOpen(shell)).toBe(false);
expect(shell.overlayKind).toBeNull();
expect(shell.prompt.value).toBe("read @z");
});
});

test("accept with cursor off the @token does not splice", async () => {
await withShell(async (shell) => {
await openAt(shell, "read @");
expect(isMentionPopupOpen(shell)).toBe(true);

shell.prompt.cursorOffset = 0;
acceptOverlaySelection(shell);

expect(isMentionPopupOpen(shell)).toBe(false);
expect(shell.overlayKind).toBeNull();
expect(shell.prompt.value).toBe("read @");
});
});

test("accept with cursor on a different @token does not splice", async () => {
await withShell(async (shell) => {
const value = "see @a and @b";
shell.prompt.value = value;
shell.prompt.cursorOffset = "see @a".length;
expect(await openAtMentionSuggestions(shell)).toBe(true);
expect(isMentionPopupOpen(shell)).toBe(true);

shell.prompt.cursorOffset = value.length;
acceptOverlaySelection(shell);

expect(isMentionPopupOpen(shell)).toBe(false);
expect(shell.overlayKind).toBeNull();
expect(shell.prompt.value).toBe(value);
});
});

test("a lookup whose cursor has left the token does not open", async () => {
await withShell(async (shell) => {
const { source, resolveNext } = hangableSource();
setMentionSuggestionSource(shell, source);

shell.prompt.value = "read @";
shell.prompt.cursorOffset = shell.prompt.value.length;
const pending = openAtMentionSuggestions(shell);
shell.prompt.cursorOffset = 0;
resolveNext(ROOT);

expect(await pending).toBe(false);
expect(isMentionPopupOpen(shell)).toBe(false);
expect(shell.overlayKind).toBeNull();
});
});

test("a lookup whose cursor moved onto a different @token does not open", async () => {
await withShell(async (shell) => {
const { source, resolveNext } = hangableSource();
setMentionSuggestionSource(shell, source);

const value = "see @a and @b";
shell.prompt.value = value;
shell.prompt.cursorOffset = "see @a".length;
const pending = openAtMentionSuggestions(shell);
shell.prompt.cursorOffset = value.length;
resolveNext(ROOT);

expect(await pending).toBe(false);
expect(isMentionPopupOpen(shell)).toBe(false);
expect(shell.overlayKind).toBeNull();
});
});

test("closeMentionPopup during an in-flight lookup does not reopen", async () => {
await withShell(async (shell) => {
const { source, resolveNext } = hangableSource();
setMentionSuggestionSource(shell, source);

shell.prompt.value = "read @";
shell.prompt.cursorOffset = shell.prompt.value.length;
const first = openAtMentionSuggestions(shell);
resolveNext(ROOT);
expect(await first).toBe(true);
expect(isMentionPopupOpen(shell)).toBe(true);

expect(handleMentionPopupKey(shell, printable("s"))).toBe(true);
expect(shell.prompt.value).toBe("read @s");

closeMentionPopup(shell);
expect(isMentionPopupOpen(shell)).toBe(false);
expect(shell.overlayList).toBeNull();
expect(shell.overlayKind).toBeNull();

resolveNext(ROOT);
await drainMicrotasks();

expect(isMentionPopupOpen(shell)).toBe(false);
expect(shell.overlayKind).toBeNull();
expect(shell.prompt.value).toBe("read @s");
});
});

test("closeInsetOverlay during an in-flight lookup does not reopen", async () => {
await withShell(async (shell) => {
const { source, resolveNext } = hangableSource();
setMentionSuggestionSource(shell, source);

shell.prompt.value = "read @";
shell.prompt.cursorOffset = shell.prompt.value.length;
const first = openAtMentionSuggestions(shell);
resolveNext(ROOT);
expect(await first).toBe(true);
expect(isMentionPopupOpen(shell)).toBe(true);

expect(handleMentionPopupKey(shell, printable("s"))).toBe(true);
expect(shell.prompt.value).toBe("read @s");

closeInsetOverlay(shell);
expect(isMentionPopupOpen(shell)).toBe(false);
expect(shell.overlayKind).toBeNull();

resolveNext(ROOT);
await drainMicrotasks();

expect(isMentionPopupOpen(shell)).toBe(false);
expect(shell.overlayKind).toBeNull();
expect(shell.prompt.value).toBe("read @s");
});
});
});
Loading
Loading