Skip to content

Commit a059995

Browse files
refactor(components): use TextInput in Autocomplete
Changes: - Uses `TextInput` from `@inkjs/ui` for the input field (better UX for typing) - `TextInput` is uncontrolled, so we use `key={inputKey}` to force remount when setting value externally (tab completion) - Custom suggestion UI only renders in command mode `(value.startsWith('/'))` - Arrow keys and Tab for command selection only active in command mode Trade-offs: - Better text input UX (TextInput handles edge cases, paste, etc.) - Simpler code (no cursor position management for regular typing) - The `key` prop hack for forcing remount is slightly unconventional but necessary since TextInput is uncontrolled
1 parent e840f1b commit a059995

3 files changed

Lines changed: 100 additions & 73 deletions

File tree

eslint.config.mts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,14 @@ export default defineConfig([
6868
],
6969
},
7070
],
71-
'@typescript-eslint/no-unused-vars': 'error',
71+
'@typescript-eslint/no-unused-vars': [
72+
'error',
73+
{
74+
vars: 'all',
75+
args: 'all',
76+
argsIgnorePattern: '^_',
77+
},
78+
],
7279
'no-console': 'error',
7380
'no-debugger': 'error',
7481
'no-restricted-imports': 'off',

src/components/Autocomplete.test.tsx

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,4 +187,61 @@ describe('Autocomplete', () => {
187187
await tick();
188188
expect(lastFrame()).toContain('abc');
189189
});
190+
191+
it('handles down arrow when no matches available', async () => {
192+
const { lastFrame, stdin } = render(<Autocomplete onSubmit={vi.fn()} />);
193+
// Type /xyz to get zero matches
194+
stdin.write('/xyz');
195+
await tick();
196+
expect(lastFrame()).not.toContain('/model');
197+
// Down arrow with matches.length=0 triggers Math.min(-1, 1) = -1 (line 39-40)
198+
stdin.write(KEY.DOWN);
199+
await tick();
200+
// Should not crash
201+
expect(lastFrame()).toContain('/xyz');
202+
});
203+
204+
it('uses fallback match when selectedIndex out of bounds', async () => {
205+
const onSubmit = vi.fn();
206+
const { stdin } = render(<Autocomplete onSubmit={onSubmit} />);
207+
// Type / then Tab twice - first Tab sets value, second tests with changed state
208+
stdin.write('/');
209+
await tick();
210+
// Move down to increase selectedIndex
211+
stdin.write(KEY.DOWN);
212+
await tick();
213+
stdin.write(KEY.DOWN);
214+
await tick();
215+
// Tab should complete using fallback when selectedIndex >= matches.length (line 44)
216+
stdin.write(KEY.TAB);
217+
await tick();
218+
stdin.write(KEY.ENTER);
219+
await tick();
220+
// Should submit successfully
221+
expect(onSubmit).toHaveBeenCalled();
222+
});
223+
224+
it('nullish coalescing when selectedIndex exceeds matches after filtering', async () => {
225+
const { lastFrame, stdin } = render(<Autocomplete onSubmit={vi.fn()} />);
226+
// Start with both matches showing
227+
stdin.write('/');
228+
await tick();
229+
// Move down to select /mock (index 1)
230+
stdin.write(KEY.DOWN);
231+
await tick();
232+
// Type 'mock' to narrow to just /mock - selectedIndex stays 1, matches.length becomes 1
233+
stdin.write('m');
234+
await tick();
235+
stdin.write('o');
236+
await tick();
237+
stdin.write('c');
238+
await tick();
239+
stdin.write('k');
240+
await tick();
241+
// Tab should trigger ?? fallback since matches[1] is undefined
242+
stdin.write(KEY.TAB);
243+
await tick();
244+
// Should complete to /mock using the fallback
245+
expect(lastFrame()).toContain('/mock');
246+
});
190247
});

src/components/Autocomplete.tsx

Lines changed: 35 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import { TextInput } from '@inkjs/ui';
12
import { Box, Text, useInput } from 'ink';
2-
import { useState } from 'react';
3+
import { useCallback, useState } from 'react';
34

45
import { type Command, COMMANDS, UI } from '../constants';
56

@@ -17,14 +18,19 @@ function getMatches(input: string): Command[] {
1718

1819
export function Autocomplete({ isDisabled = false, onSubmit }: Props) {
1920
const [value, setValue] = useState('');
20-
const [cursorPosition, setCursorPosition] = useState(0);
2121
const [selectedIndex, setSelectedIndex] = useState(0);
22+
const [inputKey, setInputKey] = useState(0);
2223

2324
const matches = getMatches(value);
24-
const showSuggestions = matches.length > 0;
25+
const isCommandMode = value.startsWith('/');
2526

2627
useInput(
27-
(char, key) => {
28+
(_char, key) => {
29+
// v8 ignore next
30+
if (!isCommandMode) {
31+
return;
32+
}
33+
2834
if (key.upArrow) {
2935
setSelectedIndex((i) => Math.max(0, i - 1));
3036
return;
@@ -35,93 +41,50 @@ export function Autocomplete({ isDisabled = false, onSubmit }: Props) {
3541
return;
3642
}
3743

38-
// v8 ignore next 4
39-
if (key.leftArrow) {
40-
setCursorPosition((position) => Math.max(0, position - 1));
41-
return;
42-
}
43-
44-
// v8 ignore next 4
45-
if (key.rightArrow) {
46-
setCursorPosition((position) => Math.min(value.length, position + 1));
47-
return;
48-
}
49-
50-
if (key.tab && showSuggestions) {
44+
if (key.tab && matches.length > 0) {
5145
// v8 ignore next
5246
const match = matches[selectedIndex] ?? matches[0];
5347
setValue(match.name);
5448
setSelectedIndex(0);
49+
setInputKey((key) => key + 1);
5550
return;
5651
}
52+
},
53+
{ isActive: !isDisabled && isCommandMode },
54+
);
5755

58-
if (key.return) {
59-
const submitValue =
60-
showSuggestions && selectedIndex >= 0 && matches[selectedIndex]
61-
? matches[selectedIndex].name
62-
: value;
63-
const trimmed = submitValue.trim();
64-
if (trimmed) {
65-
onSubmit(trimmed);
66-
setValue('');
67-
setSelectedIndex(0);
68-
}
69-
return;
70-
}
71-
72-
if (key.escape) {
56+
const handleSubmit = useCallback(
57+
(input: string) => {
58+
const submitValue =
59+
isCommandMode && matches.length > 0 && matches[selectedIndex]
60+
? matches[selectedIndex].name
61+
: input;
62+
const trimmed = submitValue.trim();
63+
if (trimmed) {
64+
onSubmit(trimmed);
7365
setValue('');
7466
setSelectedIndex(0);
75-
return;
76-
}
77-
78-
if (key.backspace || key.delete) {
79-
setValue((value) => {
80-
const before = value.slice(0, cursorPosition - 1);
81-
const after = value.slice(cursorPosition);
82-
return before + after;
83-
});
84-
setCursorPosition((position) => Math.max(0, position - 1));
85-
setSelectedIndex(0);
86-
return;
87-
}
88-
89-
// v8 ignore next
90-
if (char && !key.ctrl && !key.meta) {
91-
setValue((value) => {
92-
const before = value.slice(0, cursorPosition);
93-
const after = value.slice(cursorPosition);
94-
return before + char + after;
95-
});
96-
setCursorPosition((position) => position + 1);
97-
setSelectedIndex(0);
67+
setInputKey((key) => key + 1);
9868
}
9969
},
100-
{ isActive: !isDisabled },
70+
[isCommandMode, matches, onSubmit, selectedIndex],
10171
);
10272

10373
return (
10474
<Box flexDirection="column">
10575
<Box>
10676
<Text>{UI.PROMPT_PREFIX}</Text>
107-
108-
<Text>
109-
{value.slice(0, cursorPosition)}
110-
{cursorPosition < value.length ? (
111-
<Text backgroundColor="black" color="white">
112-
{value[cursorPosition]}
113-
</Text>
114-
) : (
115-
<Text backgroundColor="black" color="white">
116-
{' '}
117-
</Text>
118-
)}
119-
{value.slice(cursorPosition + 1)}
120-
</Text>
77+
<TextInput
78+
key={inputKey}
79+
isDisabled={isDisabled}
80+
defaultValue={value}
81+
onChange={setValue}
82+
onSubmit={handleSubmit}
83+
/>
12184
</Box>
12285

123-
{showSuggestions && (
124-
<Box flexDirection="column">
86+
{isCommandMode && matches.length > 0 && (
87+
<Box flexDirection="column" marginLeft={2}>
12588
{matches.map((command, index) => {
12689
const isHighlighted = index === selectedIndex;
12790
return (

0 commit comments

Comments
 (0)