feat(cli): @ file selector for the interactive REPL#43
Conversation
Press @ at a word boundary to open a navigable file picker (same alt-screen UX as the / palette); selecting inserts the path after the @. At send time @-mentioned files are expanded to their contents (or a MAP when large) and prepended to the message, so the model sees the file with no extra round-trip. - render/file-menu.ts: filterFiles/renderFileMenu/shouldOpenAtPicker + pickFile (sibling of pickCommand, reuses clampIndex + alt-screen listener stash/restore) - loop/prompt/at-mention.ts: parseAtPaths/resolveAtMentions/composeMessage, boundary-anchored so emails (ag@host) and mid-word @ don't match - cli.ts: @ keypress trigger beside /, openFilePicker, runSend expands mentions - file source: resolveScopeFiles(cwd, ["**/*"]); inclusion via renderFileSection - tests: file-menu + at-mention (14), flip-and-confirm-fail verified
…o full-screen takeover The first cut took over the whole screen (alternate buffer) and dumped the entire project as wrapped, alphabetical paths — unusable. Rebuild it like the Claude Code @ menu: a tight dropdown rendered INLINE just above the input row (conversation stays visible), recency-ordered, type to fuzzy-filter. - lib/fs: listWorkspaceFiles(cwd) — files ordered most-recently-modified first - render/file-menu: filterFiles capped at 8, stable-sorted (ties keep recency); truncatePath keeps the filename tail; formatCompletionRows = one width-clamped row per file; pickFileInline drives via IPickerView, not the alternate screen - render/status-bar: setOverlay/clearOverlay + pure buildOverlayFrame paint a transient popup above the input row and erase it on close - cli: @ trigger gated on useInputRow; openFilePicker echoes the live @query and renders the dropdown via the status bar - tests: file-menu rewired; status-bar overlay/clamp tests; recency test; flip-and-confirm-fail verified. Full validate green: 1359 pass / 0 fail.
There was a problem hiding this comment.
Code Review
This pull request introduces an interactive @ file picker to the CLI, allowing users to search and select workspace files inline, which are then resolved and prepended to the message. Feedback focuses on improving the robustness and user experience of this feature. Key suggestions include updating the @-mention regex to avoid capturing trailing punctuation, providing a fallback for process.stdout.columns to ensure type safety, introducing a BACKSPACE_CLOSE signal to automatically delete the leading @ when backspacing out of the picker, and restoring keypress listeners directly to preserve their original references.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| const AT_MENTION = /(^|\s)@([^\s@]+)/gu; | ||
|
|
||
| /** The unique candidate paths `@`-mentioned in a line, in first-seen order. */ | ||
| export function parseAtPaths(line: string): string[] { | ||
| const seen = new Set<string>(); | ||
|
|
||
| for (const m of line.matchAll(AT_MENTION)) { | ||
| const path = m[2]; | ||
|
|
||
| if (path !== undefined) { | ||
| seen.add(path); | ||
| } | ||
| } | ||
|
|
||
| return [...seen]; | ||
| } | ||
|
|
||
| /** | ||
| * Resolve the `@`-mentions in a user line to file views. Each `@path` that names a | ||
| * readable workspace file is read; the `@` is stripped from those (recognized) | ||
| * tokens in the returned text so the model reads a clean path, while unrecognized | ||
| * `@`-tokens (typos, decorators, non-files) are left untouched. Returns the cleaned | ||
| * text plus the resolved views (empty when nothing matched). | ||
| */ | ||
| export async function resolveAtMentions( | ||
| cwd: string, | ||
| line: string | ||
| ): Promise<{ text: string; views: IFileView[] }> { | ||
| const paths = parseAtPaths(line); | ||
|
|
||
| if (paths.length === 0) { | ||
| return { text: line, views: [] }; | ||
| } | ||
|
|
||
| const views = await readFiles(cwd, paths); | ||
| const found = new Set(views.map((v) => v.path)); | ||
|
|
||
| const text = line.replace(AT_MENTION, (whole, pre: string, path: string) => | ||
| found.has(path) ? `${pre}${path}` : whole | ||
| ); | ||
|
|
||
| return { text, views }; |
There was a problem hiding this comment.
If a user mentions a file at the end of a sentence or clause (e.g., Please check @src/foo.ts.), the trailing punctuation (like ., ,, ?, !) is captured as part of the filename by the [^\s@]+ regex. This causes the file resolution to fail because no such file exists on disk. We can fix this by updating the regex to capture trailing punctuation separately and appending it back during the replacement phase.
const AT_MENTION = /(^|\s)@([^\s@]*?[^\s@.,;:!?])([.,;:!?]*)(?=\s|$)/gu;
/** The unique candidate paths `@`-mentioned in a line, in first-seen order. */
export function parseAtPaths(line: string): string[] {
const seen = new Set<string>();
for (const m of line.matchAll(AT_MENTION)) {
const path = m[2];
if (path !== undefined) {
seen.add(path);
}
}
return [...seen];
}
/**
* Resolve the `@`-mentions in a user line to file views. Each `@path` that names a
* readable workspace file is read; the `@` is stripped from those (recognized)
* tokens in the returned text so the model reads a clean path, while unrecognized
* `@`-tokens (typos, decorators, non-files) are left untouched. Returns the cleaned
* text plus the resolved views (empty when nothing matched).
*/
export async function resolveAtMentions(
cwd: string,
line: string
): Promise<{ text: string; views: IFileView[] }> {
const paths = parseAtPaths(line);
if (paths.length === 0) {
return { text: line, views: [] };
}
const views = await readFiles(cwd, paths);
const found = new Set(views.map((v) => v.path));
const text = line.replace(AT_MENTION, (whole, pre: string, path: string, post: string) =>
found.has(path) ? `${pre}${path}${post}` : whole
);
return { text, views };
}| const rows = formatCompletionRows( | ||
| items, | ||
| selected, | ||
| process.stdout.columns, |
There was a problem hiding this comment.
process.stdout.columns can be undefined in certain terminal environments or when output is redirected. Since formatCompletionRows expects a strict number for the columns parameter, passing undefined will cause a TypeScript compilation error under strict null checks. Defaulting it to a fallback value (e.g., 80) ensures type safety.
process.stdout.columns ?? 80,| import { | ||
| pickFileInline, | ||
| formatCompletionRows, | ||
| shouldOpenAtPicker, | ||
| type IPickerView, | ||
| } from "./render/file-menu"; |
| try { | ||
| const files = await listWorkspaceFiles(args.dir); | ||
| const picked = await pickFileInline(files, view); | ||
|
|
||
| if (picked !== null) { | ||
| rl.write(`${picked} `); // append after the already-typed `@` | ||
| } |
There was a problem hiding this comment.
When the file picker is closed by backspacing past the empty query, the @ character is left in the readline buffer because the backspace keypress was intercepted. To make this feel natural, we should detect the BACKSPACE_CLOSE signal and trigger a backspace on the readline interface to delete the @.
try {
const files = await listWorkspaceFiles(args.dir);
const picked = await pickFileInline(files, view);
if (picked === BACKSPACE_CLOSE) {
rl.write(null, { name: "backspace" });
} else if (picked !== null) {
rl.write(`${picked} `); // append after the already-typed `@`
}| export function pickFileInline( | ||
| files: readonly string[], | ||
| view: IPickerView | ||
| ): Promise<string | null> { |
There was a problem hiding this comment.
Export a BACKSPACE_CLOSE symbol and update the return type of pickFileInline to allow returning it when the picker is closed via backspace.
| export function pickFileInline( | |
| files: readonly string[], | |
| view: IPickerView | |
| ): Promise<string | null> { | |
| export const BACKSPACE_CLOSE = Symbol("BACKSPACE_CLOSE"); | |
| export function pickFileInline( | |
| files: readonly string[], | |
| view: IPickerView | |
| ): Promise<string | null | typeof BACKSPACE_CLOSE> { |
| if (query.length === 0) { | ||
| finish(null); // backspace past the `@` closes the picker | ||
| } else { |
There was a problem hiding this comment.
Return BACKSPACE_CLOSE instead of null when backspacing past the empty query to signal to the caller that the @ character should be deleted.
| if (query.length === 0) { | |
| finish(null); // backspace past the `@` closes the picker | |
| } else { | |
| } else if (key.name === "backspace") { | |
| if (query.length === 0) { | |
| finish(BACKSPACE_CLOSE); // backspace past the `@` closes the picker |
| for (const l of saved) { | ||
| stdin.on("keypress", (...args: unknown[]) => { | ||
| Reflect.apply(l, stdin, args); | ||
| }); | ||
| } |
There was a problem hiding this comment.
Wrapping the restored keypress listeners in an anonymous function changes their references. If any other part of the application tries to remove their listener later using stdin.removeListener("keypress", originalListener), it will fail because the registered listener is now the anonymous wrapper. Restoring the listeners directly preserves their references and prevents potential memory leaks or broken event cleanups.
for (const l of saved) {
stdin.on("keypress", l as any);
}
Press
@at a word boundary to open a compact inline dropdown above the input row (conversation stays visible, like the Claude Code@menu): recency-ordered workspace files, type to fuzzy-filter, Enter/Tab to select. The picked path is inserted after the@; at send time@pathmentions expand to the file's contents (or a MAP when large) prepended to the message — no extra round-trip.Implementation
lib/fs:listWorkspaceFiles(mtime-desc recency order)render/file-menu:filterFiles(cap 8, stable sort keeps recency on ties),truncatePath,formatCompletionRows,pickFileInline(inline, viaIPickerView— no alternate screen),shouldOpenAtPickerboundary guardrender/status-bar:setOverlay/clearOverlay+ purebuildOverlayFramepaint a transient popup above the input rowloop/prompt/at-mention:parseAtPaths/resolveAtMentions/composeMessage; boundary-anchored soag@host/ mid-word@don't matchcli:@trigger beside/(gated on the input row);runSendexpands mentionsHistory: the first cut was a full-screen alternate-screen dump and was reworked to this inline dropdown.
Verification
Full
bun run validategreen: 1359 pass / 14 skip / 0 fail. Pure-builder + FakeTerm tests for the picker/overlay; flip-and-confirm-fail on overlay alignment, recency,@-strip, and boundary guard. Live-tested in a real terminal.