Skip to content

feat(cli): @ file selector for the interactive REPL#43

Merged
agjs merged 2 commits into
mainfrom
feat/at-file-selector
Jun 22, 2026
Merged

feat(cli): @ file selector for the interactive REPL#43
agjs merged 2 commits into
mainfrom
feat/at-file-selector

Conversation

@agjs

@agjs agjs commented Jun 22, 2026

Copy link
Copy Markdown
Owner

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 @path mentions 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, via IPickerView — no alternate screen), shouldOpenAtPicker boundary guard
  • render/status-bar: setOverlay/clearOverlay + pure buildOverlayFrame paint a transient popup above the input row
  • loop/prompt/at-mention: parseAtPaths/resolveAtMentions/composeMessage; boundary-anchored so ag@host / mid-word @ don't match
  • cli: @ trigger beside / (gated on the input row); runSend expands mentions

History: the first cut was a full-screen alternate-screen dump and was reworked to this inline dropdown.

Verification

Full bun run validate green: 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.

agjs added 2 commits June 22, 2026 08:48
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.
@agjs agjs merged commit 020d451 into main Jun 22, 2026
7 checks passed
@agjs agjs deleted the feat/at-file-selector branch June 22, 2026 08:48

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +9 to +50
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 };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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 };
}

Comment thread packages/core/src/cli.ts
const rows = formatCompletionRows(
items,
selected,
process.stdout.columns,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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,

Comment thread packages/core/src/cli.ts
Comment on lines +10 to +15
import {
pickFileInline,
formatCompletionRows,
shouldOpenAtPicker,
type IPickerView,
} from "./render/file-menu";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Import BACKSPACE_CLOSE from file-menu to handle deleting the @ character when the picker is closed via backspace.

import {
  pickFileInline,
  formatCompletionRows,
  shouldOpenAtPicker,
  BACKSPACE_CLOSE,
  type IPickerView,
} from "./render/file-menu";

Comment thread packages/core/src/cli.ts
Comment on lines +1582 to +1588
try {
const files = await listWorkspaceFiles(args.dir);
const picked = await pickFileInline(files, view);

if (picked !== null) {
rl.write(`${picked} `); // append after the already-typed `@`
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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 `@`
        }

Comment on lines +132 to +135
export function pickFileInline(
files: readonly string[],
view: IPickerView
): Promise<string | null> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Export a BACKSPACE_CLOSE symbol and update the return type of pickFileInline to allow returning it when the picker is closed via backspace.

Suggested change
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> {

Comment on lines +193 to +195
if (query.length === 0) {
finish(null); // backspace past the `@` closes the picker
} else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Return BACKSPACE_CLOSE instead of null when backspacing past the empty query to signal to the caller that the @ character should be deleted.

Suggested change
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

Comment on lines +163 to +167
for (const l of saved) {
stdin.on("keypress", (...args: unknown[]) => {
Reflect.apply(l, stdin, args);
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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);
      }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant