From ae35cec79b239737dd453cd8b68a310776cdcde8 Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 7 May 2026 21:35:56 -0400 Subject: [PATCH 1/4] feat(Chat): add FileSuggestions to Input with `@` mention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File Suggestions For `@` Mentions In Chat Input Summary: Add file-path suggestions to the chat input when the active token matches `@` followed by at least one non-whitespace character. Show suggestions below the input, allow `↑`/`↓` navigation, and on `Tab` replace the active `@query` token with the selected file path plus one trailing space. Use `ripgrep` first to gather project files, with a Node.js recursive fallback similar to `grepSearch()`. Key Changes: - Update `src/components/Chat/Input.tsx` to: - keep owning the input string and reset/remount behavior for `TextInput` - detect file suggestion mode from the active token using `(^|\s)@\S+` - render `FileSuggestions` only when slash-command mode is not active - apply the callback result from `FileSuggestions` by replacing the input value and remounting `TextInput` - Add `src/components/Chat/FileSuggestions.tsx` to own: - extraction of the active `@query` from the provided input value - project file discovery - filtering and focused-index navigation - `Tab` confirmation - rendering the suggestion list below the input with at most `5` visible options - Keep slash command behavior unchanged: - if input starts with `/`, continue rendering `CommandMenu` - file suggestions do not appear while slash-command mode is active - `Enter` continues to submit the input normally; it does not confirm file suggestions - Implement file discovery inside `FileSuggestions.tsx` using: - first choice: `rg --files` from `process.cwd()` - fallback: recursive `readdirSync(..., { withFileTypes: true })` - normalization to relative paths with `/` separators - include hidden files/directories in suggestions - exclude only `.git` from the fallback traversal - Keep matching simple and case-insensitive against the path text after `@`. - Have `FileSuggestions` call `onSelect(nextInput: string)` so it returns the fully rewritten input string, including the trailing space, instead of only the raw file path. - Do not support `Esc` dismissal for file suggestions; the list should open and close solely based on the current input text and normal editing. Public Interfaces / Behavior: - No CLI/API changes. - New TUI behavior: - typing `@` followed by any non-whitespace character in the active token opens file suggestions below the input - `↑`/`↓` move focus through suggestions - `Tab` inserts the focused file path into the input and appends one space - `Enter` still submits the current input and does not confirm the suggestion list - suggestions disappear automatically when the active token no longer matches `@\S+` or when no files match Test Plan: - Add `Input` tests for: - no file suggestions for plain text or bare `@` - file suggestions appear for `@s`, `@1`, `@_`, or `@foo-bar` - slash command suggestions still win for leading `/` - `Tab` inserts the focused file path and appends one trailing space - insertion replaces only the active trailing `@query` token and preserves surrounding text - `Enter` still submits while file suggestions are visible - `Backspace` updates or closes file suggestions correctly - disabled input ignores file suggestion interactions - Add `FileSuggestions` tests for: - `rg --files` success path - fallback to Node.js traversal when `rg` fails - fallback excludes `.git` but includes other hidden paths - case-insensitive filtering - focused-index movement and `Tab` selection behavior - max visible option count is `5` Assumptions: - Suggestion source is project file paths relative to the current working directory. - `ripgrep` is optional; the feature must work without it via the Node.js fallback. - `FileSuggestions` will contain both its own logic and rendering; no separate helper module is introduced initially. - The input remains synchronized by remounting `TextInput` with a refreshed `defaultValue` after a selection is inserted. --- src/components/Chat/FileSuggestions.test.tsx | 236 ++++++++++++++++++ src/components/Chat/FileSuggestions.tsx | 215 +++++++++++++++++ src/components/Chat/Input.test.tsx | 237 ++++++++++++++++--- src/components/Chat/Input.tsx | 24 +- 4 files changed, 680 insertions(+), 32 deletions(-) create mode 100644 src/components/Chat/FileSuggestions.test.tsx create mode 100644 src/components/Chat/FileSuggestions.tsx diff --git a/src/components/Chat/FileSuggestions.test.tsx b/src/components/Chat/FileSuggestions.test.tsx new file mode 100644 index 00000000..87c88064 --- /dev/null +++ b/src/components/Chat/FileSuggestions.test.tsx @@ -0,0 +1,236 @@ +import { exec } from 'node:child_process'; +import type { Dirent } from 'node:fs'; +import { readdirSync } from 'node:fs'; + +import { render } from 'ink-testing-library'; + +import { KEY } from '../../constants'; +import { tick } from '../../utils/test'; + +vi.mock('node:child_process', () => ({ + exec: vi.fn(), +})); + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs'); + return { + ...actual, + readdirSync: vi.fn(), + }; +}); + +import { FileSuggestions } from './FileSuggestions'; + +function createDirent( + name: string, + type: 'directory' | 'file' | 'other', +): Dirent { + return { + name, + isBlockDevice: () => false, + isCharacterDevice: () => false, + isDirectory: () => type === 'directory', + isFIFO: () => false, + isFile: () => type === 'file', + isSocket: () => false, + isSymbolicLink: () => false, + } as Dirent; +} + +describe('FileSuggestions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('loads file suggestions with ripgrep', async () => { + vi.mocked(exec).mockImplementation((_command, _options, callback) => { + callback?.(null, 'src/app.ts\nsrc/utils/tools.ts\nREADME.md\n', ''); + return {} as ReturnType; + }); + + const { lastFrame } = render( + , + ); + + await tick(20); + + expect(lastFrame()).toContain('src/app.ts'); + expect(lastFrame()).toContain('src/utils/tools.ts'); + expect(lastFrame()).not.toContain('README.md'); + }); + + it('falls back to Node.js traversal when ripgrep fails', async () => { + vi.mocked(exec).mockImplementation((_command, _options, callback) => { + callback?.(new Error('rg missing'), '', ''); + return {} as ReturnType; + }); + + vi.mocked(readdirSync).mockImplementation((path) => { + const currentPath = String(path); + + if (currentPath.endsWith('/src')) { + return [createDirent('feature.ts', 'file')] as unknown as ReturnType< + typeof readdirSync + >; + } + + if (currentPath.endsWith('/.github')) { + return [ + createDirent('workflows', 'directory'), + ] as unknown as ReturnType; + } + + if (currentPath.endsWith('/workflows')) { + return [createDirent('test.yml', 'file')] as unknown as ReturnType< + typeof readdirSync + >; + } + + return [ + createDirent('.git', 'directory'), + createDirent('.github', 'directory'), + createDirent('src', 'directory'), + createDirent('.gitignore', 'file'), + createDirent('socket', 'other'), + ] as unknown as ReturnType; + }); + + const { lastFrame } = render( + , + ); + + await tick(20); + + expect(lastFrame()).toContain('.gitignore'); + expect(lastFrame()).not.toContain('.git/'); + expect(lastFrame()).not.toContain('HEAD'); + }); + + it('selects the focused file on Tab', async () => { + vi.mocked(exec).mockImplementation((_command, _options, callback) => { + callback?.( + null, + 'src/components/App.tsx\nsrc/utils/tools.ts\nsrc/components/Input.tsx\n', + '', + ); + return {} as ReturnType; + }); + + const onSelect = vi.fn(); + const { stdin } = render( + , + ); + + await tick(20); + stdin.write(KEY.DOWN); + await tick(); + stdin.write(KEY.TAB); + await tick(); + + expect(onSelect).toHaveBeenCalledWith('read src/components/Input.tsx '); + }); + + it('ignores keyboard interactions when disabled', async () => { + vi.mocked(exec).mockImplementation((_command, _options, callback) => { + callback?.(null, 'src/components/App.tsx\nsrc/utils/tools.ts\n', ''); + return {} as ReturnType; + }); + + const onSelect = vi.fn(); + const { stdin } = render( + , + ); + + await tick(20); + stdin.write(KEY.TAB); + await tick(); + + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('ignores non-navigation key presses when suggestions are visible', async () => { + vi.mocked(exec).mockImplementation((_command, _options, callback) => { + callback?.(null, 'src/components/App.tsx\nsrc/utils/tools.ts\n', ''); + return {} as ReturnType; + }); + + const onSelect = vi.fn(); + const { stdin } = render( + , + ); + + await tick(20); + stdin.write('x'); + await tick(); + + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('does not update state after unmounting before file load completes', async () => { + let callback: + | ((error: Error | null, stdout: string, stderr: string) => void) + | undefined; + + vi.mocked(exec).mockImplementation((_command, _options, nextCallback) => { + callback = nextCallback; + return {} as ReturnType; + }); + + const { unmount } = render( + , + ); + + await tick(); + unmount(); + + callback?.(null, 'src/components/App.tsx\n', ''); + await tick(); + }); + + it('ignores non-mention input and keeps the first option focused on Up', async () => { + vi.mocked(exec).mockImplementation((_command, _options, callback) => { + callback?.(null, 'src/components/App.tsx\nsrc/utils/tools.ts\n', ''); + return {} as ReturnType; + }); + + const onSelect = vi.fn(); + const { lastFrame, stdin, rerender } = render( + , + ); + + await tick(20); + + expect(lastFrame()).toBe(''); + + rerender(); + await tick(20); + stdin.write(KEY.UP); + await tick(); + stdin.write(KEY.TAB); + await tick(); + + expect(onSelect).toHaveBeenCalledWith('src/components/App.tsx '); + }); + + it('shows at most five visible options', async () => { + vi.mocked(exec).mockImplementation((_command, _options, callback) => { + callback?.( + null, + 'src/1.ts\nsrc/2.ts\nsrc/3.ts\nsrc/4.ts\nsrc/5.ts\nsrc/6.ts\n', + '', + ); + return {} as ReturnType; + }); + + const { lastFrame } = render( + , + ); + + await tick(20); + + const frame = lastFrame() ?? ''; + expect(frame).toContain('src/1.ts'); + expect(frame).toContain('src/5.ts'); + expect(frame).not.toContain('src/6.ts'); + }); +}); diff --git a/src/components/Chat/FileSuggestions.tsx b/src/components/Chat/FileSuggestions.tsx new file mode 100644 index 00000000..eb88ea0d --- /dev/null +++ b/src/components/Chat/FileSuggestions.tsx @@ -0,0 +1,215 @@ +import { exec } from 'node:child_process'; +import { readdirSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +import { Box, Text, useInput } from 'ink'; +import { useEffect, useMemo, useState } from 'react'; + +const MAX_VISIBLE_OPTIONS = 5; +const MENTION_PATTERN = /(^|\s)@(\S+)$/; + +interface Props { + input: string; + isDisabled?: boolean; + onSelect: (nextInput: string) => void; +} + +interface MentionMatch { + prefix: string; + query: string; +} + +function normalizePath(filePath: string): string { + return filePath.replaceAll('\\', '/'); +} + +function getMentionMatch(input: string): MentionMatch | null { + const match = MENTION_PATTERN.exec(input); + if (!match) { + return null; + } + + const prefix = input.slice(0, match.index + match[1].length); + return { + prefix, + query: match[2], + }; +} + +function buildNextInput(input: string, filePath: string): string { + const mentionMatch = getMentionMatch(input); + // v8 ignore next 3 + if (!mentionMatch) { + return input; + } + + return `${mentionMatch.prefix}${filePath} `; +} + +function listProjectFilesFallback(rootDir: string): string[] { + const filePaths: string[] = []; + + function walk(currentPath: string) { + const entries = readdirSync(currentPath, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.name === '.git') { + continue; + } + + const fullPath = join(currentPath, entry.name); + + if (entry.isDirectory()) { + walk(fullPath); + continue; + } + + if (entry.isFile()) { + filePaths.push(normalizePath(relative(rootDir, fullPath))); + } + } + } + + walk(rootDir); + + return filePaths.sort((left, right) => left.localeCompare(right)); +} + +function listProjectFilesWithRipgrep(rootDir: string): Promise { + return new Promise((resolve, reject) => { + exec( + 'rg --files --hidden -g "!**/.git/**"', + { cwd: rootDir, maxBuffer: 10 * 1024 * 1024 }, + (error, stdout) => { + if (error) { + reject(error); + return; + } + + const filePaths = stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map(normalizePath) + .sort((left, right) => left.localeCompare(right)); + + resolve(filePaths); + }, + ); + }); +} + +async function listProjectFiles(rootDir: string): Promise { + try { + return await listProjectFilesWithRipgrep(rootDir); + } catch { + return listProjectFilesFallback(rootDir); + } +} + +export function FileSuggestions({ + input, + isDisabled = false, + onSelect, +}: Props) { + const [filePaths, setFilePaths] = useState([]); + const [focusedIndex, setFocusedIndex] = useState(0); + + useEffect(() => { + let isMounted = true; + + async function loadProjectFiles() { + const nextFilePaths = await listProjectFiles(process.cwd()); + if (isMounted) { + setFilePaths(nextFilePaths); + } + } + + void loadProjectFiles(); + + return () => { + isMounted = false; + }; + }, []); + + const mentionMatch = getMentionMatch(input); + + const options = useMemo(() => { + if (!mentionMatch) { + return []; + } + + const normalizedQuery = mentionMatch.query.toLowerCase(); + return filePaths.filter((filePath) => + filePath.toLowerCase().includes(normalizedQuery), + ); + }, [filePaths, mentionMatch]); + + useEffect(() => { + setFocusedIndex(0); + }, [input]); + + useEffect(() => { + if (options.length === 0) { + setFocusedIndex(0); + return; + } + + setFocusedIndex((currentIndex) => + Math.min(currentIndex, options.length - 1), + ); + }, [options]); + + useInput((_, key) => { + if (isDisabled || options.length === 0) { + return; + } + + if (key.downArrow) { + setFocusedIndex((currentIndex) => + Math.min(currentIndex + 1, options.length - 1), + ); + return; + } + + if (key.upArrow) { + setFocusedIndex((currentIndex) => Math.max(currentIndex - 1, 0)); + return; + } + + if (key.tab) { + onSelect(buildNextInput(input, options[focusedIndex])); + } + }); + + if (!mentionMatch || options.length === 0) { + return null; + } + + const visibleStart = Math.min( + Math.max(0, focusedIndex - MAX_VISIBLE_OPTIONS + 1), + Math.max(0, options.length - MAX_VISIBLE_OPTIONS), + ); + const visibleOptions = options.slice( + visibleStart, + visibleStart + MAX_VISIBLE_OPTIONS, + ); + + return ( + + {visibleOptions.map((option, index) => { + const optionIndex = visibleStart + index; + const isFocused = optionIndex === focusedIndex; + + return ( + + + {isFocused ? '>' : ' '} + {' '} + {option} + + ); + })} + + ); +} diff --git a/src/components/Chat/Input.test.tsx b/src/components/Chat/Input.test.tsx index e44d7be7..48ff2eb0 100644 --- a/src/components/Chat/Input.test.tsx +++ b/src/components/Chat/Input.test.tsx @@ -3,20 +3,22 @@ import { render } from 'ink-testing-library'; import { useRef, useState } from 'react'; import { COMMAND, KEY } from '../../constants'; -import { tick } from '../../utils/test'; +import { test } from '../../utils'; vi.mock('@inkjs/ui', () => ({ TextInput: ({ + defaultValue, isDisabled, onChange, onSubmit, }: { + defaultValue?: string; isDisabled?: boolean; onChange?: (value: string) => void; onSubmit?: (value: string) => void; }) => { - const [value, setValue] = useState(''); - const valueRef = useRef(''); + const [value, setValue] = useState(defaultValue ?? ''); + const valueRef = useRef(defaultValue ?? ''); useInput((input, key) => { if (isDisabled) { @@ -36,6 +38,14 @@ vi.mock('@inkjs/ui', () => ({ return; } + if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) { + return; + } + + if (key.tab) { + return; + } + if (!input) { return; } @@ -94,6 +104,65 @@ vi.mock('./CommandMenu', () => ({ }, })); +vi.mock('./FileSuggestions', () => ({ + FileSuggestions: ({ + input, + isDisabled, + onSelect, + }: { + input: string; + isDisabled?: boolean; + onSelect: (value: string) => void; + }) => { + const match = /(^|\s)@(\S+)$/.exec(input); + if (!match) { + return null; + } + + const options = [ + 'src/components/Chat/Input.tsx', + 'src/utils/tools.ts', + ].filter((value) => value.toLowerCase().includes(match[2].toLowerCase())); + + const [focusedIndex, setFocusedIndex] = useState(0); + + useInput((_, key) => { + if (isDisabled || options.length === 0) { + return; + } + + if (key.downArrow) { + setFocusedIndex((index) => Math.min(index + 1, options.length - 1)); + return; + } + + if (key.upArrow) { + setFocusedIndex((index) => Math.max(index - 1, 0)); + return; + } + + if (key.tab) { + const prefix = input.slice(0, match.index + match[1].length); + onSelect(`${prefix}${options[focusedIndex]} `); + } + }); + + if (!options.length) { + return null; + } + + return ( + <> + {options.map((option, index) => ( + + {index === focusedIndex ? '>' : ' '} {option} + + ))} + + ); + }, +})); + import { Input } from './Input'; describe('Input', () => { @@ -105,49 +174,86 @@ describe('Input', () => { it('does not show command suggestion on non-slash input', async () => { const { lastFrame, stdin } = render(); stdin.write('h'); - await tick(); + await test.tick(); expect(lastFrame()).not.toContain('/model'); }); it('shows command list below the input when typing /', async () => { const { lastFrame, stdin } = render(); stdin.write('/'); - await tick(); + await test.tick(); expect(lastFrame()).toContain('/clear - clear the current session'); expect(lastFrame()).toContain('/clear'); expect(lastFrame()).toContain('/model - switch the model'); }); + it('does not show file suggestions for a bare @', async () => { + const { lastFrame, stdin } = render(); + stdin.write('@'); + await test.tick(); + expect(lastFrame()).not.toContain('src/components/Chat/Input.tsx'); + }); + + it('shows file suggestions for @ followed by non-whitespace characters', async () => { + const { lastFrame, stdin } = render(); + stdin.write('@'); + await test.tick(); + stdin.write('s'); + await test.tick(); + expect(lastFrame()).toContain('src/components/Chat/Input.tsx'); + expect(lastFrame()).toContain('src/utils/tools.ts'); + }); + it('filters the command list to matching slash commands', async () => { const { lastFrame, stdin } = render(); stdin.write('/'); - await tick(); + await test.tick(); stdin.write('m'); - await tick(); + await test.tick(); expect(lastFrame()).toContain('/model - switch the model'); expect(lastFrame()).not.toContain('/clear - clear the current session'); }); + it('prefers slash command suggestions over file suggestions', async () => { + const { lastFrame, stdin } = render(); + stdin.write('/'); + await test.tick(); + stdin.write('m'); + await test.tick(); + expect(lastFrame()).toContain('/model - switch the model'); + expect(lastFrame()).not.toContain('src/components/Chat/Input.tsx'); + }); + it('submits typed text on Enter', async () => { const onSubmit = vi.fn(); const { stdin } = render(); stdin.write('h'); - await tick(); + await test.tick(); stdin.write('i'); - await tick(); + await test.tick(); stdin.write(KEY.ENTER); - await tick(); + await test.tick(); expect(onSubmit).toHaveBeenCalledWith('hi'); }); + it('submits typed text on Enter while file suggestions are visible', async () => { + const onSubmit = vi.fn(); + const { stdin } = render(); + stdin.write('read @s'); + await test.tick(); + stdin.write(KEY.ENTER); + await test.tick(); + expect(onSubmit).toHaveBeenCalledWith('read @s'); + }); + it('submits first matching slash command on Enter when list is visible', async () => { const onSubmit = vi.fn(); const { stdin } = render(); stdin.write('/'); - await tick(); + await test.tick(); stdin.write(KEY.ENTER); - await tick(); + await test.tick(); expect(onSubmit).toHaveBeenCalledWith('/clear'); }); @@ -155,69 +261,138 @@ describe('Input', () => { const onSubmit = vi.fn(); const { stdin } = render(); stdin.write('/'); - await tick(); + await test.tick(); stdin.write('u'); - await tick(); + await test.tick(); stdin.write('n'); - await tick(); + await test.tick(); stdin.write('k'); - await tick(); + await test.tick(); stdin.write('n'); - await tick(); + await test.tick(); stdin.write('o'); - await tick(); + await test.tick(); stdin.write('w'); - await tick(); + await test.tick(); stdin.write('n'); - await tick(); + await test.tick(); stdin.write(KEY.ENTER); - await tick(); + await test.tick(); expect(onSubmit).not.toHaveBeenCalled(); }); + it('inserts the focused file suggestion on Tab with a trailing space', async () => { + const { lastFrame, stdin } = render(); + stdin.write('@'); + await test.tick(); + stdin.write('s'); + await test.tick(); + stdin.write(KEY.TAB); + await test.tick(); + stdin.write('x'); + await test.tick(); + expect(lastFrame()).toContain('src/components/Chat/Input.tsx x'); + }); + + it('replaces only the active mention token when inserting a file suggestion', async () => { + const { lastFrame, stdin } = render(); + for (const character of 'read @s') { + stdin.write(character); + await test.tick(); + } + + stdin.write(KEY.TAB); + await test.tick(); + stdin.write('x'); + await test.tick(); + expect(lastFrame()).toContain('read src/components/Chat/Input.tsx x'); + }); + + it('moves focus through file suggestions with arrow keys', async () => { + const { lastFrame, stdin } = render(); + stdin.write('@'); + await test.tick(); + stdin.write('s'); + await test.tick(); + stdin.write(KEY.DOWN); + await test.tick(); + stdin.write(KEY.TAB); + await test.tick(); + stdin.write('x'); + await test.tick(); + expect(lastFrame()).toContain('src/utils/tools.ts x'); + }); + it('does not submit blank input', async () => { const onSubmit = vi.fn(); const { stdin } = render(); stdin.write(KEY.ENTER); - await tick(); + await test.tick(); expect(onSubmit).not.toHaveBeenCalled(); }); it('clears input after submit', async () => { const { lastFrame, stdin } = render(); stdin.write('h'); - await tick(); + await test.tick(); stdin.write('i'); - await tick(); + await test.tick(); stdin.write(KEY.ENTER); - await tick(10); + await test.tick(10); expect(lastFrame()).not.toContain('hi'); }); it('deletes last character on backspace', async () => { const { lastFrame, stdin } = render(); stdin.write('/'); - await tick(); + await test.tick(); stdin.write('c'); - await tick(); + await test.tick(); stdin.write(KEY.BACKSPACE); - await tick(); + await test.tick(); expect(lastFrame()).toContain('/clear - clear the current session'); stdin.write(KEY.BACKSPACE); - await tick(); + await test.tick(); expect(lastFrame()).not.toContain('/clear - clear the current session'); }); + it('closes file suggestions when backspace removes the active mention query', async () => { + const { lastFrame, stdin } = render(); + stdin.write('@'); + await test.tick(); + stdin.write('s'); + await test.tick(); + expect(lastFrame()).toContain('src/components/Chat/Input.tsx'); + stdin.write(KEY.BACKSPACE); + await test.tick(); + expect(lastFrame()).not.toContain('src/components/Chat/Input.tsx'); + }); + it('does not accept input when disabled', async () => { const onSubmit = vi.fn(); const { lastFrame, stdin } = render( , ); stdin.write('h'); - await tick(); + await test.tick(); expect(lastFrame()).not.toContain('h'); stdin.write(KEY.ENTER); - await tick(); + await test.tick(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('ignores file suggestion interactions when disabled', async () => { + const onSubmit = vi.fn(); + const { lastFrame, stdin } = render( + , + ); + stdin.write('@'); + await test.tick(); + stdin.write('s'); + await test.tick(); + stdin.write(KEY.TAB); + await test.tick(); + expect(lastFrame()).not.toContain('src/components/Chat/Input.tsx'); expect(onSubmit).not.toHaveBeenCalled(); }); }); diff --git a/src/components/Chat/Input.tsx b/src/components/Chat/Input.tsx index 3d12d98a..72957205 100644 --- a/src/components/Chat/Input.tsx +++ b/src/components/Chat/Input.tsx @@ -4,12 +4,17 @@ import { useCallback, useState } from 'react'; import { COMMAND, UI } from '../../constants'; import { CommandMenu } from './CommandMenu'; +import { FileSuggestions } from './FileSuggestions'; interface Props { isDisabled?: boolean; onSubmit: (value: string) => void; } +function hasActiveMentionQuery(input: string): boolean { + return /(^|\s)@\S+$/.test(input); +} + export function Input({ isDisabled = false, onSubmit }: Props) { const [input, setInput] = useState(''); const [resetKey, setResetKey] = useState(0); @@ -47,12 +52,21 @@ export function Input({ isDisabled = false, onSubmit }: Props) { [onSubmit], ); + const handleSelectFileSuggestion = useCallback((nextInput: string) => { + setInput(nextInput); + setResetKey((key) => key + 1); + }, []); + + const showCommandMenu = input.startsWith('/'); + const showFileSuggestions = !showCommandMenu && hasActiveMentionQuery(input); + return ( {UI.PROMPT_PREFIX} - {input.startsWith('/') && ( + {showCommandMenu && ( )} + + {showFileSuggestions && ( + + )} ); } From 1a6714b5a12fd37407555ea63f6752f1f4fa1dba Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 7 May 2026 21:43:38 -0400 Subject: [PATCH 2/4] refactor(FileSuggestions): replace `> ` with 2 spaces --- src/components/Chat/FileSuggestions.tsx | 13 +++++-------- src/components/Chat/Input.test.tsx | 2 +- src/utils/tools.ts | 2 +- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/components/Chat/FileSuggestions.tsx b/src/components/Chat/FileSuggestions.tsx index eb88ea0d..bfee6585 100644 --- a/src/components/Chat/FileSuggestions.tsx +++ b/src/components/Chat/FileSuggestions.tsx @@ -150,7 +150,7 @@ export function FileSuggestions({ }, [input]); useEffect(() => { - if (options.length === 0) { + if (!options.length) { setFocusedIndex(0); return; } @@ -161,7 +161,7 @@ export function FileSuggestions({ }, [options]); useInput((_, key) => { - if (isDisabled || options.length === 0) { + if (isDisabled || !options.length) { return; } @@ -182,7 +182,7 @@ export function FileSuggestions({ } }); - if (!mentionMatch || options.length === 0) { + if (!mentionMatch || !options.length) { return null; } @@ -202,12 +202,9 @@ export function FileSuggestions({ const isFocused = optionIndex === focusedIndex; return ( - - - {isFocused ? '>' : ' '} - {' '} + {option} - + ); })} diff --git a/src/components/Chat/Input.test.tsx b/src/components/Chat/Input.test.tsx index 48ff2eb0..dc0e7f94 100644 --- a/src/components/Chat/Input.test.tsx +++ b/src/components/Chat/Input.test.tsx @@ -127,7 +127,7 @@ vi.mock('./FileSuggestions', () => ({ const [focusedIndex, setFocusedIndex] = useState(0); useInput((_, key) => { - if (isDisabled || options.length === 0) { + if (isDisabled || !options.length) { return; } diff --git a/src/utils/tools.ts b/src/utils/tools.ts index 8635f26b..47aa1dde 100644 --- a/src/utils/tools.ts +++ b/src/utils/tools.ts @@ -379,7 +379,7 @@ async function grepSearch( searchDirectory(dirPath); - if (results.length === 0) { + if (!results.length) { return { content: 'No matches found' }; } From f87cfd33e7230cc2527f90ce2c759680dd28ba2b Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 7 May 2026 21:54:03 -0400 Subject: [PATCH 3/4] refactor(FileSuggestions): create constant RIPGREP_MAX_BUFFER --- src/components/Chat/FileSuggestions.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/Chat/FileSuggestions.tsx b/src/components/Chat/FileSuggestions.tsx index bfee6585..9c93fae5 100644 --- a/src/components/Chat/FileSuggestions.tsx +++ b/src/components/Chat/FileSuggestions.tsx @@ -7,6 +7,7 @@ import { useEffect, useMemo, useState } from 'react'; const MAX_VISIBLE_OPTIONS = 5; const MENTION_PATTERN = /(^|\s)@(\S+)$/; +const RIPGREP_MAX_BUFFER = 10 * 1024 * 1024; interface Props { input: string; @@ -79,7 +80,7 @@ function listProjectFilesWithRipgrep(rootDir: string): Promise { return new Promise((resolve, reject) => { exec( 'rg --files --hidden -g "!**/.git/**"', - { cwd: rootDir, maxBuffer: 10 * 1024 * 1024 }, + { cwd: rootDir, maxBuffer: RIPGREP_MAX_BUFFER }, (error, stdout) => { if (error) { reject(error); From 12311cb84bc50c3785bea8fb1d523677a249ee5c Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 7 May 2026 21:58:25 -0400 Subject: [PATCH 4/4] refactor(FileSuggestions): remove unnecessary isMounted defensive guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In `FileSuggestions.tsx`, `isMounted` only prevents this sequence: 1. component starts async file loading 2. component unmounts 3. async load resolves 4. `setFilePaths(...)` runs after unmount In modern React, that usually won’t break correctness here, but it can still be noisy and it is unnecessary work. Since this effect is a one-shot async read, the guard is a simple way to avoid updating dead state. That said, for this specific component: - there is no subscription to clean up - there is no retry loop - there is no race between multiple concurrent requests - the only real concern is post-unmount setState So if you want to simplify, removing `isMounted` is reasonable. The behavior of the feature would still be fine. I’d only keep it if you want to be explicit about ignoring late async results. --- src/components/Chat/FileSuggestions.test.tsx | 21 -------------------- src/components/Chat/FileSuggestions.tsx | 10 +--------- 2 files changed, 1 insertion(+), 30 deletions(-) diff --git a/src/components/Chat/FileSuggestions.test.tsx b/src/components/Chat/FileSuggestions.test.tsx index 87c88064..5d037cf4 100644 --- a/src/components/Chat/FileSuggestions.test.tsx +++ b/src/components/Chat/FileSuggestions.test.tsx @@ -166,27 +166,6 @@ describe('FileSuggestions', () => { expect(onSelect).not.toHaveBeenCalled(); }); - it('does not update state after unmounting before file load completes', async () => { - let callback: - | ((error: Error | null, stdout: string, stderr: string) => void) - | undefined; - - vi.mocked(exec).mockImplementation((_command, _options, nextCallback) => { - callback = nextCallback; - return {} as ReturnType; - }); - - const { unmount } = render( - , - ); - - await tick(); - unmount(); - - callback?.(null, 'src/components/App.tsx\n', ''); - await tick(); - }); - it('ignores non-mention input and keeps the first option focused on Up', async () => { vi.mocked(exec).mockImplementation((_command, _options, callback) => { callback?.(null, 'src/components/App.tsx\nsrc/utils/tools.ts\n', ''); diff --git a/src/components/Chat/FileSuggestions.tsx b/src/components/Chat/FileSuggestions.tsx index 9c93fae5..11b5a81e 100644 --- a/src/components/Chat/FileSuggestions.tsx +++ b/src/components/Chat/FileSuggestions.tsx @@ -117,20 +117,12 @@ export function FileSuggestions({ const [focusedIndex, setFocusedIndex] = useState(0); useEffect(() => { - let isMounted = true; - async function loadProjectFiles() { const nextFilePaths = await listProjectFiles(process.cwd()); - if (isMounted) { - setFilePaths(nextFilePaths); - } + setFilePaths(nextFilePaths); } void loadProjectFiles(); - - return () => { - isMounted = false; - }; }, []); const mentionMatch = getMentionMatch(input);