diff --git a/src/components/Chat/FileSuggestions.test.tsx b/src/components/Chat/FileSuggestions.test.tsx index 02f6ab1f..d8d36fa4 100644 --- a/src/components/Chat/FileSuggestions.test.tsx +++ b/src/components/Chat/FileSuggestions.test.tsx @@ -130,6 +130,41 @@ describe('FileSuggestions', () => { expect(onSelect).toHaveBeenCalledWith('read src/components/Input.tsx '); }); + it('reports the active suggestion and clears it when no options remain', async () => { + vi.mocked(exec).mockImplementation((_command, _options, callback) => { + callback?.(null, 'src/components/App.tsx\nsrc/utils/tools.ts\n', ''); + return {} as ReturnType; + }); + + const onChange = vi.fn(); + const { stdin, rerender } = render( + , + ); + + await time.tick(20); + expect(onChange).toHaveBeenLastCalledWith(null); + + rerender( + , + ); + await time.tick(); + expect(onChange).toHaveBeenLastCalledWith('src/components/App.tsx '); + + stdin.write(KEY.DOWN); + await time.tick(); + expect(onChange).toHaveBeenLastCalledWith('src/utils/tools.ts '); + + rerender( + , + ); + await time.tick(); + expect(onChange).toHaveBeenLastCalledWith(null); + }); + 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', ''); diff --git a/src/components/Chat/FileSuggestions.tsx b/src/components/Chat/FileSuggestions.tsx index 11b5a81e..b5917843 100644 --- a/src/components/Chat/FileSuggestions.tsx +++ b/src/components/Chat/FileSuggestions.tsx @@ -12,6 +12,7 @@ const RIPGREP_MAX_BUFFER = 10 * 1024 * 1024; interface Props { input: string; isDisabled?: boolean; + onChange?: (nextInput: string | null) => void; onSelect: (nextInput: string) => void; } @@ -111,6 +112,7 @@ async function listProjectFiles(rootDir: string): Promise { export function FileSuggestions({ input, isDisabled = false, + onChange, onSelect, }: Props) { const [filePaths, setFilePaths] = useState([]); @@ -153,6 +155,19 @@ export function FileSuggestions({ ); }, [options]); + useEffect(() => { + if (!onChange) { + return; + } + + if (!mentionMatch || !options.length) { + onChange(null); + return; + } + + onChange(buildNextInput(input, options[focusedIndex])); + }, [focusedIndex, input, mentionMatch, onChange, options]); + useInput((_, key) => { if (isDisabled || !options.length) { return; @@ -170,7 +185,7 @@ export function FileSuggestions({ return; } - if (key.tab) { + if (key.tab || key.return) { onSelect(buildNextInput(input, options[focusedIndex])); } }); diff --git a/src/components/Chat/Input.test.tsx b/src/components/Chat/Input.test.tsx index 26d4909c..22a9e4de 100644 --- a/src/components/Chat/Input.test.tsx +++ b/src/components/Chat/Input.test.tsx @@ -32,6 +32,10 @@ vi.mock('@inkjs/ui', () => ({ }) => { const [value, setValue] = useState(defaultValue ?? ''); const valueRef = useRef(defaultValue ?? ''); + const onChangeRef = useRef(onChange); + const onSubmitRef = useRef(onSubmit); + onChangeRef.current = onChange; + onSubmitRef.current = onSubmit; useInput((input, key) => { if (isDisabled) { @@ -39,14 +43,14 @@ vi.mock('@inkjs/ui', () => ({ } if (key.return) { - onSubmit?.(value); + onSubmitRef.current?.(valueRef.current); return; } if (key.backspace || key.delete) { const nextValue = valueRef.current.slice(0, -1); valueRef.current = nextValue; - onChange?.(nextValue); + onChangeRef.current?.(nextValue); setValue(nextValue); return; } @@ -69,7 +73,7 @@ vi.mock('@inkjs/ui', () => ({ const nextValue = valueRef.current + input; valueRef.current = nextValue; - onChange?.(nextValue); + onChangeRef.current?.(nextValue); setValue(nextValue); }); @@ -134,10 +138,12 @@ vi.mock('./FileSuggestions', () => ({ FileSuggestions: ({ input, isDisabled, + onChange, onSelect, }: { input: string; isDisabled?: boolean; + onChange?: (value: string | null) => void; onSelect: (value: string) => void; }) => { const match = /(^|\s)@(\S+)$/.exec(input); @@ -151,6 +157,11 @@ vi.mock('./FileSuggestions', () => ({ ].filter((value) => value.toLowerCase().includes(match[2].toLowerCase())); const [focusedIndex, setFocusedIndex] = useState(0); + const prefix = input.slice(0, match.index + match[1].length); + const activeSuggestion = options[focusedIndex] + ? `${prefix}${options[focusedIndex]} ` + : null; + onChange?.(activeSuggestion); useInput((_, key) => { if (isDisabled || !options.length) { @@ -168,7 +179,6 @@ vi.mock('./FileSuggestions', () => ({ } if (key.tab) { - const prefix = input.slice(0, match.index + match[1].length); onSelect(`${prefix}${options[focusedIndex]} `); } }); @@ -271,14 +281,15 @@ describe('Input', () => { 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'); + it('inserts the focused file suggestion on Enter with a trailing space', async () => { + const { lastFrame, stdin } = render(); + stdin.write('@'); await time.tick(); + stdin.write('s'); + await time.tick(10); stdin.write(KEY.ENTER); - await time.tick(); - expect(onSubmit).toHaveBeenCalledWith('read @s'); + await time.tick(10); + expect(lastFrame()).toContain('[value:src/components/Chat/Input.tsx ]'); }); it('submits first matching slash command on Enter when list is visible', async () => { @@ -357,6 +368,33 @@ describe('Input', () => { expect(lastFrame()).toContain('src/utils/tools.ts x'); }); + it('inserts the focused file suggestion on Enter after arrow navigation', async () => { + const { lastFrame, stdin } = render(); + stdin.write('@'); + await time.tick(); + stdin.write('s'); + await time.tick(10); + stdin.write(KEY.DOWN); + await time.tick(); + stdin.write(KEY.ENTER); + await time.tick(10); + expect(lastFrame()).toContain('[value:src/utils/tools.ts ]'); + }); + + it('does not submit or change input on Enter when no file suggestion matches', async () => { + const onSubmit = vi.fn(); + const { lastFrame, stdin } = render(); + stdin.write('@'); + await time.tick(); + stdin.write('z'); + await time.tick(10); + stdin.write(KEY.ENTER); + await time.tick(10); + + expect(onSubmit).not.toHaveBeenCalled(); + expect(lastFrame()).toContain('[value:@z]'); + }); + it('does not submit blank input', async () => { const onSubmit = vi.fn(); const { stdin } = render(); diff --git a/src/components/Chat/Input.tsx b/src/components/Chat/Input.tsx index 726b83e2..b456b7bf 100644 --- a/src/components/Chat/Input.tsx +++ b/src/components/Chat/Input.tsx @@ -1,6 +1,6 @@ import { TextInput } from '@inkjs/ui'; import { Box, Text, useApp, useInput } from 'ink'; -import { useCallback, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { COMMAND, UI } from '../../constants'; import { time } from '../../utils'; @@ -12,7 +12,8 @@ interface Props { onSubmit: (value: string) => void; } -function hasActiveMentionQuery(input: string): boolean { +function hasFileSuggestionQuery(input: string): boolean { + // e.g.: @file return /(^|\s)@\S+$/.test(input); } @@ -20,19 +21,26 @@ export function Input({ isDisabled = false, onSubmit }: Props) { const { exit } = useApp(); const [input, setInput] = useState(''); const [inputKey, setInputKey] = useState(0); + const fileSuggestionRef = useRef(null); const remountTextInput = useCallback(() => { setInputKey((key) => key + 1); }, [setInputKey]); - const handleSubmitText = useCallback( - async (input: string) => { - await time.tick(); + const handleSelectFileSuggestion = useCallback( + (nextInput: string) => { + setInput(nextInput); + remountTextInput(); + }, + [remountTextInput], + ); - if (input.startsWith('/')) { - return; - } + const handleFileSuggestionChange = useCallback((nextInput: string | null) => { + fileSuggestionRef.current = nextInput; + }, []); + const submitAndReset = useCallback( + (input: string) => { const trimmedInput = input.trim(); if (!trimmedInput) { return; @@ -40,30 +48,43 @@ export function Input({ isDisabled = false, onSubmit }: Props) { onSubmit(trimmedInput); setInput(''); + fileSuggestionRef.current = null; remountTextInput(); }, [onSubmit, remountTextInput], ); - const handleSubmitCommand = useCallback( - (input: string) => { - if (!COMMAND.LIST.find(({ name }) => name === input)) { + const showCommandMenu = input.startsWith('/'); + const showFileSuggestions = !showCommandMenu && hasFileSuggestionQuery(input); + + const handleSubmitText = useCallback( + async (input: string) => { + await time.tick(); + + if (input.startsWith('/')) { return; } - onSubmit(input); - setInput(''); - remountTextInput(); + if (hasFileSuggestionQuery(input)) { + if (fileSuggestionRef.current) { + handleSelectFileSuggestion(fileSuggestionRef.current); + } + + return; + } + + submitAndReset(input); }, - [onSubmit, remountTextInput], + [handleSelectFileSuggestion, submitAndReset], ); - const handleSelectFileSuggestion = useCallback( - (nextInput: string) => { - setInput(nextInput); - remountTextInput(); + const handleSubmitCommand = useCallback( + (input: string) => { + if (COMMAND.LIST.find(({ name }) => name === input)) { + submitAndReset(input); + } }, - [remountTextInput], + [submitAndReset], ); useInput((_input, key) => { @@ -77,9 +98,6 @@ export function Input({ isDisabled = false, onSubmit }: Props) { } }); - const showCommandMenu = input.startsWith('/'); - const showFileSuggestions = !showCommandMenu && hasActiveMentionQuery(input); - return ( @@ -104,6 +122,7 @@ export function Input({ isDisabled = false, onSubmit }: Props) { )}