diff --git a/src/components/Chat/FileSuggestions.test.tsx b/src/components/Chat/FileSuggestions.test.tsx new file mode 100644 index 00000000..5d037cf4 --- /dev/null +++ b/src/components/Chat/FileSuggestions.test.tsx @@ -0,0 +1,215 @@ +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('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..11b5a81e --- /dev/null +++ b/src/components/Chat/FileSuggestions.tsx @@ -0,0 +1,205 @@ +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+)$/; +const RIPGREP_MAX_BUFFER = 10 * 1024 * 1024; + +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: RIPGREP_MAX_BUFFER }, + (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(() => { + async function loadProjectFiles() { + const nextFilePaths = await listProjectFiles(process.cwd()); + setFilePaths(nextFilePaths); + } + + void loadProjectFiles(); + }, []); + + 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) { + setFocusedIndex(0); + return; + } + + setFocusedIndex((currentIndex) => + Math.min(currentIndex, options.length - 1), + ); + }, [options]); + + useInput((_, key) => { + if (isDisabled || !options.length) { + 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) { + 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 ( + + {option} + + ); + })} + + ); +} diff --git a/src/components/Chat/Input.test.tsx b/src/components/Chat/Input.test.tsx index e44d7be7..dc0e7f94 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) { + 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 && ( + + )} ); } 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' }; }