Skip to content

Commit 9238893

Browse files
feat(components): add slash command Autocomplete
Add a reusable `Autocomplete` component that wraps the chat input with slash-command suggestion/completion, keeping `Chat.tsx` clean. Approach: `@inkjs/ui`'s `TextInput` doesn't support suggestion overlays. Build a new `Autocomplete` component using `ink`'s `useInput` hook for a controlled input, with a filtered suggestion list rendered below the prompt. No new npm dependency needed — `ink` already exposes `useInput` and `useStdin`. Slash commands: Start with `/model`. Commands live in a `COMMANDS` constant (new `src/constants/commands.ts`) as `{ name, description }` objects so adding more is trivial. ```ts { name: '/model', description: 'Switch the active model' } ``` UX: - User types `/` → filtered suggestion list appears below the input - Each row: **`/command`** `description` (description dimmed), highlighted row is visually distinct (e.g. cyan/bold) - Continuing to type narrows the list - **↑ / ↓** moves a highlight through the suggestions - **Tab** completes the highlighted (or top) match into the input - **Enter** submits as normal (if a suggestion is highlighted, fills it in first) - Non-`/` input or **Esc** dismisses suggestions and resets highlight Files changed: 1. **`src/constants/commands.ts`** — `COMMANDS` array (`['/model']`) 2. **`src/constants/index.ts`** — re-export `COMMANDS` 3. **`src/components/Autocomplete.tsx`** — new component: controlled input + suggestion list via `useInput` 4. **`src/components/Autocomplete.test.tsx`** — tests: renders input, shows suggestions on `/`, Tab completes, Enter submits, non-`/` hides suggestions 5. **`src/components/Chat.tsx`** — replace `@inkjs/ui TextInput` usage with `<Autocomplete>` 6. **`src/components/index.ts`** — export `Autocomplete` Component API: ```tsx interface Props { isDisabled?: boolean; onSubmit: (value: string) => void; } ``` Risks / notes: - `TextInput` mock in `Chat.test.tsx` will need updating to mock `Autocomplete` instead - `useInput` requires raw mode stdin; `ink-testing-library` handles this automatically
1 parent 09188ff commit 9238893

11 files changed

Lines changed: 322 additions & 33 deletions

File tree

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { render } from 'ink-testing-library';
2+
3+
import { tick } from '../utils/test';
4+
5+
vi.mock('../constants', async (importOriginal) => {
6+
const actual = await importOriginal<typeof import('../constants')>();
7+
return {
8+
...actual,
9+
COMMANDS: [
10+
{ name: '/model', description: 'Switch the active model' },
11+
{ name: '/mock', description: 'Mock command' },
12+
],
13+
};
14+
});
15+
16+
import { KEY } from '../constants';
17+
import { Autocomplete } from './Autocomplete';
18+
19+
describe('Autocomplete', () => {
20+
it('renders input prompt', () => {
21+
const { lastFrame } = render(<Autocomplete onSubmit={vi.fn()} />);
22+
expect(lastFrame()).toContain('>');
23+
});
24+
25+
it('does not show suggestions on non-slash input', async () => {
26+
const { lastFrame, stdin } = render(<Autocomplete onSubmit={vi.fn()} />);
27+
stdin.write('h');
28+
await tick();
29+
expect(lastFrame()).not.toContain('/model');
30+
});
31+
32+
it('shows suggestions when typing /', async () => {
33+
const { lastFrame, stdin } = render(<Autocomplete onSubmit={vi.fn()} />);
34+
stdin.write('/');
35+
await tick();
36+
expect(lastFrame()).toContain('/model');
37+
expect(lastFrame()).toContain('Switch the active model');
38+
});
39+
40+
it('filters suggestions as input narrows', async () => {
41+
const { lastFrame, stdin } = render(<Autocomplete onSubmit={vi.fn()} />);
42+
stdin.write('/');
43+
await tick();
44+
stdin.write('m');
45+
await tick();
46+
expect(lastFrame()).toContain('/model');
47+
stdin.write('x');
48+
await tick();
49+
expect(lastFrame()).not.toContain('/model');
50+
});
51+
52+
it('completes suggestion on Tab', async () => {
53+
const { lastFrame, stdin } = render(<Autocomplete onSubmit={vi.fn()} />);
54+
stdin.write('/');
55+
await tick();
56+
stdin.write(KEY.TAB);
57+
await tick();
58+
expect(lastFrame()).toContain('/model');
59+
});
60+
61+
it('submits completed value on Enter after Tab', async () => {
62+
const onSubmit = vi.fn();
63+
const { stdin } = render(<Autocomplete onSubmit={onSubmit} />);
64+
stdin.write('/');
65+
await tick();
66+
stdin.write(KEY.TAB);
67+
await tick();
68+
stdin.write(KEY.ENTER);
69+
await tick();
70+
expect(onSubmit).toHaveBeenCalledWith('/model');
71+
});
72+
73+
it('submits typed text on Enter without suggestion selected', async () => {
74+
const onSubmit = vi.fn();
75+
const { stdin } = render(<Autocomplete onSubmit={onSubmit} />);
76+
stdin.write('h');
77+
await tick();
78+
stdin.write('i');
79+
await tick();
80+
stdin.write(KEY.ENTER);
81+
await tick();
82+
expect(onSubmit).toHaveBeenCalledWith('hi');
83+
});
84+
85+
it('does not submit blank input', async () => {
86+
const onSubmit = vi.fn();
87+
const { stdin } = render(<Autocomplete onSubmit={onSubmit} />);
88+
stdin.write(KEY.ENTER);
89+
await tick();
90+
expect(onSubmit).not.toHaveBeenCalled();
91+
});
92+
93+
it('clears input after submit', async () => {
94+
const { lastFrame, stdin } = render(<Autocomplete onSubmit={vi.fn()} />);
95+
stdin.write('h');
96+
await tick();
97+
stdin.write('i');
98+
await tick();
99+
stdin.write(KEY.ENTER);
100+
await tick();
101+
expect(lastFrame()).not.toContain('hi');
102+
});
103+
104+
it('clears input and suggestions on Escape', async () => {
105+
const { lastFrame, stdin } = render(<Autocomplete onSubmit={vi.fn()} />);
106+
stdin.write('/');
107+
await tick();
108+
expect(lastFrame()).toContain('/model');
109+
stdin.write(KEY.ESCAPE);
110+
await tick(50);
111+
expect(lastFrame()).not.toContain('/model');
112+
});
113+
114+
it('deletes last character on backspace', async () => {
115+
const { lastFrame, stdin } = render(<Autocomplete onSubmit={vi.fn()} />);
116+
stdin.write('/');
117+
await tick();
118+
stdin.write('m');
119+
await tick();
120+
stdin.write(KEY.BACKSPACE);
121+
await tick();
122+
expect(lastFrame()).toContain('/model');
123+
stdin.write(KEY.BACKSPACE);
124+
await tick();
125+
expect(lastFrame()).not.toContain('/model');
126+
});
127+
128+
it('does not accept input when disabled', async () => {
129+
const onSubmit = vi.fn();
130+
const { lastFrame, stdin } = render(
131+
<Autocomplete isDisabled onSubmit={onSubmit} />,
132+
);
133+
stdin.write('h');
134+
await tick();
135+
expect(lastFrame()).not.toContain('h');
136+
stdin.write(KEY.ENTER);
137+
await tick();
138+
expect(onSubmit).not.toHaveBeenCalled();
139+
});
140+
141+
it('moves highlight down with arrow keys', async () => {
142+
const { stdin } = render(<Autocomplete onSubmit={vi.fn()} />);
143+
stdin.write('/');
144+
await tick();
145+
stdin.write(KEY.DOWN);
146+
await tick();
147+
stdin.write(KEY.UP);
148+
await tick();
149+
});
150+
151+
it('submits highlighted suggestion on Enter', async () => {
152+
const onSubmit = vi.fn();
153+
const { stdin } = render(<Autocomplete onSubmit={onSubmit} />);
154+
stdin.write('/');
155+
await tick();
156+
stdin.write(KEY.ENTER);
157+
await tick();
158+
expect(onSubmit).toHaveBeenCalledWith('/model');
159+
});
160+
161+
it('shows non-highlighted suggestions when arrow down moves selection', async () => {
162+
const { lastFrame, stdin } = render(<Autocomplete onSubmit={vi.fn()} />);
163+
stdin.write('/');
164+
await tick();
165+
expect(lastFrame()).toContain('/model');
166+
expect(lastFrame()).toContain('/mock');
167+
stdin.write(KEY.DOWN);
168+
await tick();
169+
expect(lastFrame()).toContain('/mock');
170+
});
171+
});

src/components/Autocomplete.tsx

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { Box, Text, useInput } from 'ink';
2+
import { useState } from 'react';
3+
4+
import { type Command, COMMANDS, UI } from '../constants';
5+
6+
interface Props {
7+
isDisabled?: boolean;
8+
onSubmit: (value: string) => void;
9+
}
10+
11+
function getMatches(input: string): Command[] {
12+
if (!input.startsWith('/')) return [];
13+
return COMMANDS.filter((cmd) => cmd.name.startsWith(input));
14+
}
15+
16+
export function Autocomplete({ isDisabled = false, onSubmit }: Props) {
17+
const [value, setValue] = useState('');
18+
const [selectedIndex, setSelectedIndex] = useState(0);
19+
20+
const matches = getMatches(value);
21+
const showSuggestions = matches.length > 0;
22+
23+
useInput(
24+
(char, key) => {
25+
if (key.upArrow) {
26+
setSelectedIndex((i) => Math.max(0, i - 1));
27+
return;
28+
}
29+
30+
if (key.downArrow) {
31+
setSelectedIndex((i) => Math.min(matches.length - 1, i + 1));
32+
return;
33+
}
34+
35+
if (key.tab && showSuggestions) {
36+
// v8 ignore next
37+
const match = matches[selectedIndex] ?? matches[0];
38+
setValue(match.name);
39+
setSelectedIndex(0);
40+
return;
41+
}
42+
43+
if (key.return) {
44+
const submitValue =
45+
showSuggestions && selectedIndex >= 0 && matches[selectedIndex]
46+
? matches[selectedIndex].name
47+
: value;
48+
const trimmed = submitValue.trim();
49+
if (trimmed) {
50+
onSubmit(trimmed);
51+
setValue('');
52+
setSelectedIndex(0);
53+
}
54+
return;
55+
}
56+
57+
if (key.escape) {
58+
setValue('');
59+
setSelectedIndex(0);
60+
return;
61+
}
62+
63+
if (key.backspace || key.delete) {
64+
setValue((v) => v.slice(0, -1));
65+
setSelectedIndex(0);
66+
return;
67+
}
68+
69+
// v8 ignore next
70+
if (char && !key.ctrl && !key.meta) {
71+
setValue((v) => {
72+
const next = v + char;
73+
setSelectedIndex(0);
74+
return next;
75+
});
76+
}
77+
},
78+
{ isActive: !isDisabled },
79+
);
80+
81+
return (
82+
<Box flexDirection="column">
83+
<Box>
84+
<Text>{UI.PROMPT_PREFIX}</Text>
85+
<Text>{value}</Text>
86+
</Box>
87+
88+
{showSuggestions && (
89+
<Box flexDirection="column">
90+
{matches.map((cmd, index) => {
91+
const isHighlighted = index === selectedIndex;
92+
return (
93+
<Box key={cmd.name} gap={1}>
94+
<Text
95+
color={isHighlighted ? 'cyan' : undefined}
96+
bold={isHighlighted}
97+
>
98+
{cmd.name}
99+
</Text>
100+
<Text dimColor>{cmd.description}</Text>
101+
</Box>
102+
);
103+
})}
104+
</Box>
105+
)}
106+
</Box>
107+
);
108+
}

src/components/Chat.test.tsx

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,13 @@ const mockState = vi.hoisted(() => ({
1616

1717
vi.mock('@inkjs/ui', () => ({
1818
Spinner: ({ label }: { label?: string }) => <Text>{`⏳${label ?? ''}`}</Text>,
19-
TextInput: (props: {
19+
}));
20+
21+
vi.mock('./Autocomplete', () => ({
22+
Autocomplete: (props: {
2023
onSubmit?: (value: string) => void;
2124
isDisabled?: boolean;
22-
defaultValue?: string;
2325
}) => {
24-
// Register handler
2526
if (props.onSubmit) {
2627
mockState.handlers.push(props.onSubmit);
2728
}
@@ -30,16 +31,9 @@ vi.mock('@inkjs/ui', () => ({
3031
return null;
3132
}
3233

33-
// Determine display value based on state
34-
let displayValue: string;
35-
if (mockState.shouldReset) {
36-
displayValue = props.defaultValue ?? '';
37-
mockState.shouldReset = false;
38-
} else if (mockState.testInput) {
39-
displayValue = mockState.testInput;
40-
} else {
41-
displayValue = props.defaultValue ?? '';
42-
}
34+
const displayValue = mockState.shouldReset
35+
? ((mockState.shouldReset = false), '')
36+
: mockState.testInput;
4337

4438
return (
4539
<Text>

src/components/Chat.tsx

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
import { Spinner, TextInput } from '@inkjs/ui';
1+
import { Spinner } from '@inkjs/ui';
22
import { Box, Text } from 'ink';
33
import { useCallback, useState } from 'react';
44

5-
import { ROLE } from '../constants';
5+
import { ROLE, UI } from '../constants';
66
import { ollama } from '../utils';
7-
8-
const PROMPT_PREFIX = '> ';
7+
import { Autocomplete } from './Autocomplete';
98

109
interface Props {
1110
model: string;
@@ -75,7 +74,7 @@ export function Chat({ model, onCommand }: Props) {
7574
key={index}
7675
color={message.role === ROLE.USER ? 'green' : 'blue'}
7776
>
78-
{message.role === ROLE.USER ? PROMPT_PREFIX : ''}
77+
{message.role === ROLE.USER ? UI.PROMPT_PREFIX : ''}
7978
{message.content}
8079
</Text>
8180
))}
@@ -85,17 +84,13 @@ export function Chat({ model, onCommand }: Props) {
8584
)}
8685
</Box>
8786

88-
<Box>
89-
<Text>{PROMPT_PREFIX}</Text>
90-
<TextInput
91-
key={submitKey}
92-
defaultValue=""
93-
onSubmit={(value) => {
94-
void handleSubmit(value);
95-
}}
96-
isDisabled={isLoading}
97-
/>
98-
</Box>
87+
<Autocomplete
88+
key={submitKey}
89+
isDisabled={isLoading}
90+
onSubmit={(value) => {
91+
void handleSubmit(value);
92+
}}
93+
/>
9994
</Box>
10095
);
10196
}

src/components/ModelPicker.test.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { render } from 'ink-testing-library';
22

3+
import { KEY } from '../constants';
34
import { tick } from '../utils/test';
45

56
const { mockListModels, mockOnChange } = vi.hoisted(() => ({
@@ -39,8 +40,6 @@ vi.mock('../utils', () => ({
3940

4041
import { ModelPicker } from './ModelPicker';
4142

42-
const ESCAPE = '\x1B\x1B';
43-
4443
describe('ModelPicker', () => {
4544
beforeEach(() => {
4645
mockListModels.mockResolvedValue(['gemma4', 'llama3', 'codellama']);
@@ -109,7 +108,7 @@ describe('ModelPicker', () => {
109108
/>,
110109
);
111110
await tick(10);
112-
stdin.write(ESCAPE);
111+
stdin.write(KEY.ESCAPE);
113112
await tick(50);
114113
expect(onCancel).toHaveBeenCalled();
115114
});

src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { App } from './App';
2+
export { Autocomplete } from './Autocomplete';
23
export { Chat } from './Chat';
34
export { ModelPicker } from './ModelPicker';

0 commit comments

Comments
 (0)