Skip to content

Commit 0468753

Browse files
Merge pull request #35 from ai-action/fix/mention
2 parents d77bcbf + 32c4914 commit 0468753

4 files changed

Lines changed: 141 additions & 34 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: 42 additions & 23 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,58 +12,79 @@ 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

28-
const handleSubmitText = useCallback(
29-
async (input: string) => {
30-
await time.tick();
30+
const handleSelectFileSuggestion = useCallback(
31+
(nextInput: string) => {
32+
setInput(nextInput);
33+
remountTextInput();
34+
},
35+
[remountTextInput],
36+
);
3137

32-
if (input.startsWith('/')) {
33-
return;
34-
}
38+
const handleFileSuggestionChange = useCallback((nextInput: string | null) => {
39+
fileSuggestionRef.current = nextInput;
40+
}, []);
3541

42+
const submitAndReset = useCallback(
43+
(input: string) => {
3644
const trimmedInput = input.trim();
3745
if (!trimmedInput) {
3846
return;
3947
}
4048

4149
onSubmit(trimmedInput);
4250
setInput('');
51+
fileSuggestionRef.current = null;
4352
remountTextInput();
4453
},
4554
[onSubmit, remountTextInput],
4655
);
4756

48-
const handleSubmitCommand = useCallback(
49-
(input: string) => {
50-
if (!COMMAND.LIST.find(({ name }) => name === input)) {
57+
const showCommandMenu = input.startsWith('/');
58+
const showFileSuggestions = !showCommandMenu && hasFileSuggestionQuery(input);
59+
60+
const handleSubmitText = useCallback(
61+
async (input: string) => {
62+
await time.tick();
63+
64+
if (input.startsWith('/')) {
5165
return;
5266
}
5367

54-
onSubmit(input);
55-
setInput('');
56-
remountTextInput();
68+
if (hasFileSuggestionQuery(input)) {
69+
if (fileSuggestionRef.current) {
70+
handleSelectFileSuggestion(fileSuggestionRef.current);
71+
}
72+
73+
return;
74+
}
75+
76+
submitAndReset(input);
5777
},
58-
[onSubmit, remountTextInput],
78+
[handleSelectFileSuggestion, submitAndReset],
5979
);
6080

61-
const handleSelectFileSuggestion = useCallback(
62-
(nextInput: string) => {
63-
setInput(nextInput);
64-
remountTextInput();
81+
const handleSubmitCommand = useCallback(
82+
(input: string) => {
83+
if (COMMAND.LIST.find(({ name }) => name === input)) {
84+
submitAndReset(input);
85+
}
6586
},
66-
[remountTextInput],
87+
[submitAndReset],
6788
);
6889

6990
useInput((_input, key) => {
@@ -77,9 +98,6 @@ export function Input({ isDisabled = false, onSubmit }: Props) {
7798
}
7899
});
79100

80-
const showCommandMenu = input.startsWith('/');
81-
const showFileSuggestions = !showCommandMenu && hasActiveMentionQuery(input);
82-
83101
return (
84102
<Box flexDirection="column">
85103
<Box>
@@ -104,6 +122,7 @@ export function Input({ isDisabled = false, onSubmit }: Props) {
104122
<FileSuggestions
105123
input={input}
106124
isDisabled={isDisabled}
125+
onChange={handleFileSuggestionChange}
107126
onSelect={handleSelectFileSuggestion}
108127
/>
109128
)}

0 commit comments

Comments
 (0)