Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions src/components/Chat/FileSuggestions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof exec>;
});

const onChange = vi.fn();
const { stdin, rerender } = render(
<FileSuggestions input="hello" onChange={onChange} onSelect={vi.fn()} />,
);

await time.tick(20);
expect(onChange).toHaveBeenLastCalledWith(null);

rerender(
<FileSuggestions input="@src" onChange={onChange} onSelect={vi.fn()} />,
);
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(
<FileSuggestions
input="@missing"
onChange={onChange}
onSelect={vi.fn()}
/>,
);
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', '');
Expand Down
17 changes: 16 additions & 1 deletion src/components/Chat/FileSuggestions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -111,6 +112,7 @@ async function listProjectFiles(rootDir: string): Promise<string[]> {
export function FileSuggestions({
input,
isDisabled = false,
onChange,
onSelect,
}: Props) {
const [filePaths, setFilePaths] = useState<string[]>([]);
Expand Down Expand Up @@ -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;
Expand All @@ -170,7 +185,7 @@ export function FileSuggestions({
return;
}

if (key.tab) {
if (key.tab || key.return) {
onSelect(buildNextInput(input, options[focusedIndex]));
}
});
Expand Down
58 changes: 48 additions & 10 deletions src/components/Chat/Input.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,25 @@ 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) {
return;
}

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;
}
Expand All @@ -69,7 +73,7 @@ vi.mock('@inkjs/ui', () => ({

const nextValue = valueRef.current + input;
valueRef.current = nextValue;
onChange?.(nextValue);
onChangeRef.current?.(nextValue);
setValue(nextValue);
});

Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Expand All @@ -168,7 +179,6 @@ vi.mock('./FileSuggestions', () => ({
}

if (key.tab) {
const prefix = input.slice(0, match.index + match[1].length);
onSelect(`${prefix}${options[focusedIndex]} `);
}
});
Expand Down Expand Up @@ -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(<Input onSubmit={onSubmit} />);
stdin.write('read @s');
it('inserts the focused file suggestion on Enter with a trailing space', async () => {
const { lastFrame, stdin } = render(<Input onSubmit={vi.fn()} />);
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 () => {
Expand Down Expand Up @@ -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(<Input onSubmit={vi.fn()} />);
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(<Input onSubmit={onSubmit} />);
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(<Input onSubmit={onSubmit} />);
Expand Down
65 changes: 42 additions & 23 deletions src/components/Chat/Input.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -12,58 +12,79 @@ interface Props {
onSubmit: (value: string) => void;
}

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

export function Input({ isDisabled = false, onSubmit }: Props) {
const { exit } = useApp();
const [input, setInput] = useState('');
const [inputKey, setInputKey] = useState(0);
const fileSuggestionRef = useRef<string | null>(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;
}

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) => {
Expand All @@ -77,9 +98,6 @@ export function Input({ isDisabled = false, onSubmit }: Props) {
}
});

const showCommandMenu = input.startsWith('/');
const showFileSuggestions = !showCommandMenu && hasActiveMentionQuery(input);

return (
<Box flexDirection="column">
<Box>
Expand All @@ -104,6 +122,7 @@ export function Input({ isDisabled = false, onSubmit }: Props) {
<FileSuggestions
input={input}
isDisabled={isDisabled}
onChange={handleFileSuggestionChange}
onSelect={handleSelectFileSuggestion}
/>
)}
Expand Down
Loading