Skip to content

Commit f76c6d8

Browse files
feat(Chat): show CommandMenu below Input when slash command is typed
1 parent d5014cf commit f76c6d8

5 files changed

Lines changed: 293 additions & 21 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { Text } from 'ink';
2+
import { render } from 'ink-testing-library';
3+
4+
interface MockSelectPromptProps {
5+
highlightText: string;
6+
onChange: (value: string) => void;
7+
options: { label: string; value: string }[];
8+
}
9+
10+
const { mockSelectPrompt } = vi.hoisted(() => ({
11+
mockSelectPrompt: vi.fn<(props: MockSelectPromptProps) => void>(),
12+
}));
13+
14+
vi.mock('../SelectPrompt', () => ({
15+
SelectPrompt: (props: MockSelectPromptProps) => {
16+
mockSelectPrompt(props);
17+
return (
18+
<>
19+
{props.options.map(({ label, value }) => (
20+
<Text key={value}>{label}</Text>
21+
))}
22+
</>
23+
);
24+
},
25+
}));
26+
27+
import { CommandMenu } from './CommandMenu';
28+
29+
describe('CommandMenu', () => {
30+
beforeEach(() => {
31+
mockSelectPrompt.mockReset();
32+
});
33+
34+
it('returns null when input does not start with a slash', () => {
35+
const onSubmit = vi.fn();
36+
const { lastFrame } = render(
37+
<CommandMenu input="hello" onSubmit={onSubmit} />,
38+
);
39+
40+
expect(lastFrame()).toBe('');
41+
expect(mockSelectPrompt).not.toHaveBeenCalled();
42+
});
43+
44+
it('returns null when no commands match the slash input', () => {
45+
const onSubmit = vi.fn();
46+
const { lastFrame } = render(
47+
<CommandMenu input="/x" onSubmit={onSubmit} />,
48+
);
49+
50+
expect(lastFrame()).toBe('');
51+
expect(mockSelectPrompt).not.toHaveBeenCalled();
52+
});
53+
54+
it('renders matching commands and forwards selection', () => {
55+
const onSubmit = vi.fn();
56+
const { lastFrame } = render(
57+
<CommandMenu input="/m" onSubmit={onSubmit} />,
58+
);
59+
60+
expect(lastFrame()).toContain('/model - switch the model');
61+
expect(lastFrame()).not.toContain('/clear - clear the current session');
62+
expect(mockSelectPrompt).toHaveBeenCalledTimes(1);
63+
64+
const [firstCall] = mockSelectPrompt.mock.calls;
65+
expect(firstCall).toBeDefined();
66+
const [props] = firstCall;
67+
expect(props.highlightText).toBe('/m');
68+
expect(props.options).toEqual([
69+
{
70+
label: '/model - switch the model',
71+
value: '/model',
72+
},
73+
]);
74+
const { onChange } = props;
75+
onChange('/model');
76+
expect(onSubmit).toHaveBeenCalledWith('/model');
77+
});
78+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { useMemo } from 'react';
2+
3+
import { COMMAND } from '../../constants';
4+
import { SelectPrompt } from '../SelectPrompt';
5+
6+
interface Props {
7+
input: string;
8+
onSubmit: (value: string) => void;
9+
}
10+
11+
function getMatchingCommands(input: string) {
12+
const normalizedInput = input.trim().toLowerCase();
13+
if (!normalizedInput.startsWith('/')) {
14+
return [];
15+
}
16+
17+
return COMMAND.LIST.filter(({ name }) =>
18+
name.toLowerCase().startsWith(normalizedInput),
19+
).map(({ name, description }) => ({
20+
label: `${name} - ${description}`,
21+
value: name,
22+
}));
23+
}
24+
25+
export function CommandMenu({ input, onSubmit }: Props) {
26+
const commandOptions = useMemo(() => getMatchingCommands(input), [input]);
27+
28+
if (!commandOptions.length) {
29+
return null;
30+
}
31+
32+
return (
33+
<SelectPrompt
34+
highlightText={input}
35+
onChange={onSubmit}
36+
options={commandOptions}
37+
/>
38+
);
39+
}

src/components/Chat/Input.test.tsx

Lines changed: 135 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,99 @@
1+
import { Text, useInput } from 'ink';
12
import { render } from 'ink-testing-library';
3+
import { useRef, useState } from 'react';
24

3-
import { KEY } from '../../constants';
5+
import { COMMAND, KEY } from '../../constants';
46
import { tick } from '../../utils/test';
7+
8+
vi.mock('@inkjs/ui', () => ({
9+
TextInput: ({
10+
isDisabled,
11+
onChange,
12+
onSubmit,
13+
}: {
14+
isDisabled?: boolean;
15+
onChange?: (value: string) => void;
16+
onSubmit?: (value: string) => void;
17+
}) => {
18+
const [value, setValue] = useState('');
19+
const valueRef = useRef('');
20+
21+
useInput((input, key) => {
22+
if (isDisabled) {
23+
return;
24+
}
25+
26+
if (key.return) {
27+
onSubmit?.(value);
28+
return;
29+
}
30+
31+
if (key.backspace || key.delete) {
32+
const nextValue = valueRef.current.slice(0, -1);
33+
valueRef.current = nextValue;
34+
onChange?.(nextValue);
35+
setValue(nextValue);
36+
return;
37+
}
38+
39+
if (!input) {
40+
return;
41+
}
42+
43+
const nextValue = valueRef.current + input;
44+
valueRef.current = nextValue;
45+
onChange?.(nextValue);
46+
setValue(nextValue);
47+
});
48+
49+
return <Text>{value}</Text>;
50+
},
51+
}));
52+
53+
vi.mock('./CommandMenu', () => ({
54+
CommandMenu: ({
55+
input,
56+
onSubmit,
57+
}: {
58+
input: string;
59+
onSubmit: (value: string) => void;
60+
}) => {
61+
const normalizedInput = input.trim().toLowerCase();
62+
const options =
63+
normalizedInput === '/unknown'
64+
? [
65+
{
66+
label: '/unknown - invalid command',
67+
value: '/unknown',
68+
},
69+
]
70+
: COMMAND.LIST.filter(({ name }) =>
71+
name.toLowerCase().startsWith(normalizedInput),
72+
).map(({ name, description }) => ({
73+
label: `${name} - ${description}`,
74+
value: name,
75+
}));
76+
77+
useInput((_, key) => {
78+
if (key.return && options[0]) {
79+
onSubmit(options[0].value);
80+
}
81+
});
82+
83+
if (!options.length) {
84+
return null;
85+
}
86+
87+
return (
88+
<>
89+
{options.map(({ label, value }) => (
90+
<Text key={value}>{label}</Text>
91+
))}
92+
</>
93+
);
94+
},
95+
}));
96+
597
import { Input } from './Input';
698

799
describe('Input', () => {
@@ -17,11 +109,24 @@ describe('Input', () => {
17109
expect(lastFrame()).not.toContain('/model');
18110
});
19111

20-
it('shows inline command suggestion when typing /', async () => {
112+
it('shows command list below the input when typing /', async () => {
21113
const { lastFrame, stdin } = render(<Input onSubmit={vi.fn()} />);
22114
stdin.write('/');
23115
await tick();
116+
expect(lastFrame()).toContain('/clear - clear the current session');
24117
expect(lastFrame()).toContain('/clear');
118+
expect(lastFrame()).toContain('/model - switch the model');
119+
});
120+
121+
it('filters the command list to matching slash commands', async () => {
122+
const { lastFrame, stdin } = render(<Input onSubmit={vi.fn()} />);
123+
stdin.write('/');
124+
await tick();
125+
stdin.write('m');
126+
await tick();
127+
128+
expect(lastFrame()).toContain('/model - switch the model');
129+
expect(lastFrame()).not.toContain('/clear - clear the current session');
25130
});
26131

27132
it('submits typed text on Enter', async () => {
@@ -36,7 +141,7 @@ describe('Input', () => {
36141
expect(onSubmit).toHaveBeenCalledWith('hi');
37142
});
38143

39-
it('submits completed slash command on Enter when suggestion is visible', async () => {
144+
it('submits first matching slash command on Enter when list is visible', async () => {
40145
const onSubmit = vi.fn();
41146
const { stdin } = render(<Input onSubmit={onSubmit} />);
42147
stdin.write('/');
@@ -46,6 +151,30 @@ describe('Input', () => {
46151
expect(onSubmit).toHaveBeenCalledWith('/clear');
47152
});
48153

154+
it('ignores slash command submissions that are not in the command list', async () => {
155+
const onSubmit = vi.fn();
156+
const { stdin } = render(<Input onSubmit={onSubmit} />);
157+
stdin.write('/');
158+
await tick();
159+
stdin.write('u');
160+
await tick();
161+
stdin.write('n');
162+
await tick();
163+
stdin.write('k');
164+
await tick();
165+
stdin.write('n');
166+
await tick();
167+
stdin.write('o');
168+
await tick();
169+
stdin.write('w');
170+
await tick();
171+
stdin.write('n');
172+
await tick();
173+
stdin.write(KEY.ENTER);
174+
await tick();
175+
expect(onSubmit).not.toHaveBeenCalled();
176+
});
177+
49178
it('does not submit blank input', async () => {
50179
const onSubmit = vi.fn();
51180
const { stdin } = render(<Input onSubmit={onSubmit} />);
@@ -61,7 +190,7 @@ describe('Input', () => {
61190
stdin.write('i');
62191
await tick();
63192
stdin.write(KEY.ENTER);
64-
await tick();
193+
await tick(10);
65194
expect(lastFrame()).not.toContain('hi');
66195
});
67196

@@ -73,10 +202,10 @@ describe('Input', () => {
73202
await tick();
74203
stdin.write(KEY.BACKSPACE);
75204
await tick();
76-
expect(lastFrame()).toContain('/clear');
205+
expect(lastFrame()).toContain('/clear - clear the current session');
77206
stdin.write(KEY.BACKSPACE);
78207
await tick();
79-
expect(lastFrame()).not.toContain('/clear');
208+
expect(lastFrame()).not.toContain('/clear - clear the current session');
80209
});
81210

82211
it('does not accept input when disabled', async () => {

src/components/Chat/Input.tsx

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,38 +3,66 @@ import { Box, Text } from 'ink';
33
import { useCallback, useState } from 'react';
44

55
import { COMMAND, UI } from '../../constants';
6+
import { CommandMenu } from './CommandMenu';
67

78
interface Props {
89
isDisabled?: boolean;
910
onSubmit: (value: string) => void;
1011
}
1112

1213
export function Input({ isDisabled = false, onSubmit }: Props) {
14+
const [input, setInput] = useState('');
1315
const [resetKey, setResetKey] = useState(0);
1416

15-
const handleSubmit = useCallback(
17+
const handleSubmitText = useCallback(
1618
(input: string) => {
17-
const trimmed = input.trim();
18-
if (!trimmed) {
19+
setTimeout(() => {
20+
if (input.startsWith('/')) {
21+
return;
22+
}
23+
24+
const trimmedInput = input.trim();
25+
if (!trimmedInput) {
26+
return;
27+
}
28+
29+
onSubmit(trimmedInput);
30+
setInput('');
31+
setResetKey((key) => key + 1);
32+
});
33+
},
34+
[onSubmit],
35+
);
36+
37+
const handleSubmitCommand = useCallback(
38+
(input: string) => {
39+
if (!COMMAND.LIST.find(({ name }) => name === input)) {
1940
return;
2041
}
2142

22-
onSubmit(trimmed);
43+
onSubmit(input);
44+
setInput('');
2345
setResetKey((key) => key + 1);
2446
},
2547
[onSubmit],
2648
);
2749

2850
return (
29-
<Box>
30-
<Text>{UI.PROMPT_PREFIX}</Text>
31-
32-
<TextInput
33-
isDisabled={isDisabled}
34-
key={resetKey}
35-
suggestions={COMMAND.NAMES}
36-
onSubmit={handleSubmit}
37-
/>
51+
<Box flexDirection="column">
52+
<Box>
53+
<Text>{UI.PROMPT_PREFIX}</Text>
54+
55+
<TextInput
56+
isDisabled={isDisabled}
57+
key={resetKey}
58+
onChange={setInput}
59+
onSubmit={handleSubmitText}
60+
/>
61+
</Box>
62+
63+
{input.startsWith('/') && (
64+
<CommandMenu input={input} onSubmit={handleSubmitCommand} />
65+
)}
3866
</Box>
3967
);
4068
}

src/constants/command.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,3 @@ export const LIST: CommandList[] = [
77
{ name: '/clear', description: 'clear the current session' },
88
{ name: '/model', description: 'switch the model' },
99
] as const;
10-
11-
export const NAMES = LIST.map(({ name }) => name);

0 commit comments

Comments
 (0)