Skip to content

Commit 8f07da8

Browse files
fix(Chat): make Enter key behave the same as Tab key for FileSuggestions
1 parent d77bcbf commit 8f07da8

4 files changed

Lines changed: 130 additions & 25 deletions

File tree

src/components/Chat/FileSuggestions.test.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,41 @@ describe('FileSuggestions', () => {
130130
expect(onSelect).toHaveBeenCalledWith('read src/components/Input.tsx ');
131131
});
132132

133+
it('reports the active suggestion and clears it when no options remain', async () => {
134+
vi.mocked(exec).mockImplementation((_command, _options, callback) => {
135+
callback?.(null, 'src/components/App.tsx\nsrc/utils/tools.ts\n', '');
136+
return {} as ReturnType<typeof exec>;
137+
});
138+
139+
const onChange = vi.fn();
140+
const { stdin, rerender } = render(
141+
<FileSuggestions input="hello" onChange={onChange} onSelect={vi.fn()} />,
142+
);
143+
144+
await time.tick(20);
145+
expect(onChange).toHaveBeenLastCalledWith(null);
146+
147+
rerender(
148+
<FileSuggestions input="@src" onChange={onChange} onSelect={vi.fn()} />,
149+
);
150+
await time.tick();
151+
expect(onChange).toHaveBeenLastCalledWith('src/components/App.tsx ');
152+
153+
stdin.write(KEY.DOWN);
154+
await time.tick();
155+
expect(onChange).toHaveBeenLastCalledWith('src/utils/tools.ts ');
156+
157+
rerender(
158+
<FileSuggestions
159+
input="@missing"
160+
onChange={onChange}
161+
onSelect={vi.fn()}
162+
/>,
163+
);
164+
await time.tick();
165+
expect(onChange).toHaveBeenLastCalledWith(null);
166+
});
167+
133168
it('ignores keyboard interactions when disabled', async () => {
134169
vi.mocked(exec).mockImplementation((_command, _options, callback) => {
135170
callback?.(null, 'src/components/App.tsx\nsrc/utils/tools.ts\n', '');

src/components/Chat/FileSuggestions.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const RIPGREP_MAX_BUFFER = 10 * 1024 * 1024;
1212
interface Props {
1313
input: string;
1414
isDisabled?: boolean;
15+
onChange?: (nextInput: string | null) => void;
1516
onSelect: (nextInput: string) => void;
1617
}
1718

@@ -111,6 +112,7 @@ async function listProjectFiles(rootDir: string): Promise<string[]> {
111112
export function FileSuggestions({
112113
input,
113114
isDisabled = false,
115+
onChange,
114116
onSelect,
115117
}: Props) {
116118
const [filePaths, setFilePaths] = useState<string[]>([]);
@@ -153,6 +155,19 @@ export function FileSuggestions({
153155
);
154156
}, [options]);
155157

158+
useEffect(() => {
159+
if (!onChange) {
160+
return;
161+
}
162+
163+
if (!mentionMatch || !options.length) {
164+
onChange(null);
165+
return;
166+
}
167+
168+
onChange(buildNextInput(input, options[focusedIndex]));
169+
}, [focusedIndex, input, mentionMatch, onChange, options]);
170+
156171
useInput((_, key) => {
157172
if (isDisabled || !options.length) {
158173
return;
@@ -170,7 +185,7 @@ export function FileSuggestions({
170185
return;
171186
}
172187

173-
if (key.tab) {
188+
if (key.tab || key.return) {
174189
onSelect(buildNextInput(input, options[focusedIndex]));
175190
}
176191
});

src/components/Chat/Input.test.tsx

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,21 +32,25 @@ vi.mock('@inkjs/ui', () => ({
3232
}) => {
3333
const [value, setValue] = useState(defaultValue ?? '');
3434
const valueRef = useRef(defaultValue ?? '');
35+
const onChangeRef = useRef(onChange);
36+
const onSubmitRef = useRef(onSubmit);
37+
onChangeRef.current = onChange;
38+
onSubmitRef.current = onSubmit;
3539

3640
useInput((input, key) => {
3741
if (isDisabled) {
3842
return;
3943
}
4044

4145
if (key.return) {
42-
onSubmit?.(value);
46+
onSubmitRef.current?.(valueRef.current);
4347
return;
4448
}
4549

4650
if (key.backspace || key.delete) {
4751
const nextValue = valueRef.current.slice(0, -1);
4852
valueRef.current = nextValue;
49-
onChange?.(nextValue);
53+
onChangeRef.current?.(nextValue);
5054
setValue(nextValue);
5155
return;
5256
}
@@ -69,7 +73,7 @@ vi.mock('@inkjs/ui', () => ({
6973

7074
const nextValue = valueRef.current + input;
7175
valueRef.current = nextValue;
72-
onChange?.(nextValue);
76+
onChangeRef.current?.(nextValue);
7377
setValue(nextValue);
7478
});
7579

@@ -134,10 +138,12 @@ vi.mock('./FileSuggestions', () => ({
134138
FileSuggestions: ({
135139
input,
136140
isDisabled,
141+
onChange,
137142
onSelect,
138143
}: {
139144
input: string;
140145
isDisabled?: boolean;
146+
onChange?: (value: string | null) => void;
141147
onSelect: (value: string) => void;
142148
}) => {
143149
const match = /(^|\s)@(\S+)$/.exec(input);
@@ -151,6 +157,11 @@ vi.mock('./FileSuggestions', () => ({
151157
].filter((value) => value.toLowerCase().includes(match[2].toLowerCase()));
152158

153159
const [focusedIndex, setFocusedIndex] = useState(0);
160+
const prefix = input.slice(0, match.index + match[1].length);
161+
const activeSuggestion = options[focusedIndex]
162+
? `${prefix}${options[focusedIndex]} `
163+
: null;
164+
onChange?.(activeSuggestion);
154165

155166
useInput((_, key) => {
156167
if (isDisabled || !options.length) {
@@ -168,7 +179,6 @@ vi.mock('./FileSuggestions', () => ({
168179
}
169180

170181
if (key.tab) {
171-
const prefix = input.slice(0, match.index + match[1].length);
172182
onSelect(`${prefix}${options[focusedIndex]} `);
173183
}
174184
});
@@ -271,14 +281,15 @@ describe('Input', () => {
271281
expect(onSubmit).toHaveBeenCalledWith('hi');
272282
});
273283

274-
it('submits typed text on Enter while file suggestions are visible', async () => {
275-
const onSubmit = vi.fn();
276-
const { stdin } = render(<Input onSubmit={onSubmit} />);
277-
stdin.write('read @s');
284+
it('inserts the focused file suggestion on Enter with a trailing space', async () => {
285+
const { lastFrame, stdin } = render(<Input onSubmit={vi.fn()} />);
286+
stdin.write('@');
278287
await time.tick();
288+
stdin.write('s');
289+
await time.tick(10);
279290
stdin.write(KEY.ENTER);
280-
await time.tick();
281-
expect(onSubmit).toHaveBeenCalledWith('read @s');
291+
await time.tick(10);
292+
expect(lastFrame()).toContain('[value:src/components/Chat/Input.tsx ]');
282293
});
283294

284295
it('submits first matching slash command on Enter when list is visible', async () => {
@@ -357,6 +368,33 @@ describe('Input', () => {
357368
expect(lastFrame()).toContain('src/utils/tools.ts x');
358369
});
359370

371+
it('inserts the focused file suggestion on Enter after arrow navigation', async () => {
372+
const { lastFrame, stdin } = render(<Input onSubmit={vi.fn()} />);
373+
stdin.write('@');
374+
await time.tick();
375+
stdin.write('s');
376+
await time.tick(10);
377+
stdin.write(KEY.DOWN);
378+
await time.tick();
379+
stdin.write(KEY.ENTER);
380+
await time.tick(10);
381+
expect(lastFrame()).toContain('[value:src/utils/tools.ts ]');
382+
});
383+
384+
it('does not submit or change input on Enter when no file suggestion matches', async () => {
385+
const onSubmit = vi.fn();
386+
const { lastFrame, stdin } = render(<Input onSubmit={onSubmit} />);
387+
stdin.write('@');
388+
await time.tick();
389+
stdin.write('z');
390+
await time.tick(10);
391+
stdin.write(KEY.ENTER);
392+
await time.tick(10);
393+
394+
expect(onSubmit).not.toHaveBeenCalled();
395+
expect(lastFrame()).toContain('[value:@z]');
396+
});
397+
360398
it('does not submit blank input', async () => {
361399
const onSubmit = vi.fn();
362400
const { stdin } = render(<Input onSubmit={onSubmit} />);

src/components/Chat/Input.tsx

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { TextInput } from '@inkjs/ui';
22
import { Box, Text, useApp, useInput } from 'ink';
3-
import { useCallback, useState } from 'react';
3+
import { useCallback, useRef, useState } from 'react';
44

55
import { COMMAND, UI } from '../../constants';
66
import { time } from '../../utils';
@@ -12,19 +12,36 @@ interface Props {
1212
onSubmit: (value: string) => void;
1313
}
1414

15-
function hasActiveMentionQuery(input: string): boolean {
15+
function hasFileSuggestionQuery(input: string): boolean {
16+
// e.g.: @file
1617
return /(^|\s)@\S+$/.test(input);
1718
}
1819

1920
export function Input({ isDisabled = false, onSubmit }: Props) {
2021
const { exit } = useApp();
2122
const [input, setInput] = useState('');
2223
const [inputKey, setInputKey] = useState(0);
24+
const fileSuggestionRef = useRef<string | null>(null);
2325

2426
const remountTextInput = useCallback(() => {
2527
setInputKey((key) => key + 1);
2628
}, [setInputKey]);
2729

30+
const handleSelectFileSuggestion = useCallback(
31+
(nextInput: string) => {
32+
setInput(nextInput);
33+
remountTextInput();
34+
},
35+
[remountTextInput],
36+
);
37+
38+
const handleFileSuggestionChange = useCallback((nextInput: string | null) => {
39+
fileSuggestionRef.current = nextInput;
40+
}, []);
41+
42+
const showCommandMenu = input.startsWith('/');
43+
const showFileSuggestions = !showCommandMenu && hasFileSuggestionQuery(input);
44+
2845
const handleSubmitText = useCallback(
2946
async (input: string) => {
3047
await time.tick();
@@ -33,16 +50,25 @@ export function Input({ isDisabled = false, onSubmit }: Props) {
3350
return;
3451
}
3552

53+
if (hasFileSuggestionQuery(input)) {
54+
if (fileSuggestionRef.current) {
55+
handleSelectFileSuggestion(fileSuggestionRef.current);
56+
}
57+
58+
return;
59+
}
60+
3661
const trimmedInput = input.trim();
3762
if (!trimmedInput) {
3863
return;
3964
}
4065

4166
onSubmit(trimmedInput);
4267
setInput('');
68+
fileSuggestionRef.current = null;
4369
remountTextInput();
4470
},
45-
[onSubmit, remountTextInput],
71+
[handleSelectFileSuggestion, onSubmit, remountTextInput],
4672
);
4773

4874
const handleSubmitCommand = useCallback(
@@ -53,19 +79,12 @@ export function Input({ isDisabled = false, onSubmit }: Props) {
5379

5480
onSubmit(input);
5581
setInput('');
82+
fileSuggestionRef.current = null;
5683
remountTextInput();
5784
},
5885
[onSubmit, remountTextInput],
5986
);
6087

61-
const handleSelectFileSuggestion = useCallback(
62-
(nextInput: string) => {
63-
setInput(nextInput);
64-
remountTextInput();
65-
},
66-
[remountTextInput],
67-
);
68-
6988
useInput((_input, key) => {
7089
if (key.ctrl && _input === 'c') {
7190
if (input) {
@@ -77,9 +96,6 @@ export function Input({ isDisabled = false, onSubmit }: Props) {
7796
}
7897
});
7998

80-
const showCommandMenu = input.startsWith('/');
81-
const showFileSuggestions = !showCommandMenu && hasActiveMentionQuery(input);
82-
8399
return (
84100
<Box flexDirection="column">
85101
<Box>
@@ -104,6 +120,7 @@ export function Input({ isDisabled = false, onSubmit }: Props) {
104120
<FileSuggestions
105121
input={input}
106122
isDisabled={isDisabled}
123+
onChange={handleFileSuggestionChange}
107124
onSelect={handleSelectFileSuggestion}
108125
/>
109126
)}

0 commit comments

Comments
 (0)