diff --git a/src/components/Chat/CommandMenu.test.tsx b/src/components/Chat/CommandMenu.test.tsx
new file mode 100644
index 00000000..3e9ca1fc
--- /dev/null
+++ b/src/components/Chat/CommandMenu.test.tsx
@@ -0,0 +1,78 @@
+import { Text } from 'ink';
+import { render } from 'ink-testing-library';
+
+interface MockSelectPromptProps {
+ highlightText: string;
+ onChange: (value: string) => void;
+ options: { label: string; value: string }[];
+}
+
+const { mockSelectPrompt } = vi.hoisted(() => ({
+ mockSelectPrompt: vi.fn<(props: MockSelectPromptProps) => void>(),
+}));
+
+vi.mock('../SelectPrompt', () => ({
+ SelectPrompt: (props: MockSelectPromptProps) => {
+ mockSelectPrompt(props);
+ return (
+ <>
+ {props.options.map(({ label, value }) => (
+ {label}
+ ))}
+ >
+ );
+ },
+}));
+
+import { CommandMenu } from './CommandMenu';
+
+describe('CommandMenu', () => {
+ beforeEach(() => {
+ mockSelectPrompt.mockReset();
+ });
+
+ it('returns null when input does not start with a slash', () => {
+ const onSubmit = vi.fn();
+ const { lastFrame } = render(
+ ,
+ );
+
+ expect(lastFrame()).toBe('');
+ expect(mockSelectPrompt).not.toHaveBeenCalled();
+ });
+
+ it('returns null when no commands match the slash input', () => {
+ const onSubmit = vi.fn();
+ const { lastFrame } = render(
+ ,
+ );
+
+ expect(lastFrame()).toBe('');
+ expect(mockSelectPrompt).not.toHaveBeenCalled();
+ });
+
+ it('renders matching commands and forwards selection', () => {
+ const onSubmit = vi.fn();
+ const { lastFrame } = render(
+ ,
+ );
+
+ expect(lastFrame()).toContain('/model - switch the model');
+ expect(lastFrame()).not.toContain('/clear - clear the current session');
+ expect(mockSelectPrompt).toHaveBeenCalledTimes(1);
+
+ const [firstCall] = mockSelectPrompt.mock.calls;
+ expect(firstCall).toBeDefined();
+ const [props] = firstCall;
+ expect(props.highlightText).toBe('/m');
+ expect(props.options).toEqual([
+ {
+ label: '/model - switch the model',
+ value: '/model',
+ },
+ ]);
+ const { onChange } = props;
+ onChange('/model');
+ expect(onSubmit).toHaveBeenCalledWith('/model');
+ });
+});
diff --git a/src/components/Chat/CommandMenu.tsx b/src/components/Chat/CommandMenu.tsx
new file mode 100644
index 00000000..4ae4afcb
--- /dev/null
+++ b/src/components/Chat/CommandMenu.tsx
@@ -0,0 +1,39 @@
+import { useMemo } from 'react';
+
+import { COMMAND } from '../../constants';
+import { SelectPrompt } from '../SelectPrompt';
+
+interface Props {
+ input: string;
+ onSubmit: (value: string) => void;
+}
+
+function getMatchingCommands(input: string) {
+ const normalizedInput = input.trim().toLowerCase();
+ if (!normalizedInput.startsWith('/')) {
+ return [];
+ }
+
+ return COMMAND.LIST.filter(({ name }) =>
+ name.toLowerCase().startsWith(normalizedInput),
+ ).map(({ name, description }) => ({
+ label: `${name} - ${description}`,
+ value: name,
+ }));
+}
+
+export function CommandMenu({ input, onSubmit }: Props) {
+ const commandOptions = useMemo(() => getMatchingCommands(input), [input]);
+
+ if (!commandOptions.length) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/Chat/Input.test.tsx b/src/components/Chat/Input.test.tsx
index 41142011..e44d7be7 100644
--- a/src/components/Chat/Input.test.tsx
+++ b/src/components/Chat/Input.test.tsx
@@ -1,7 +1,99 @@
+import { Text, useInput } from 'ink';
import { render } from 'ink-testing-library';
+import { useRef, useState } from 'react';
-import { KEY } from '../../constants';
+import { COMMAND, KEY } from '../../constants';
import { tick } from '../../utils/test';
+
+vi.mock('@inkjs/ui', () => ({
+ TextInput: ({
+ isDisabled,
+ onChange,
+ onSubmit,
+ }: {
+ isDisabled?: boolean;
+ onChange?: (value: string) => void;
+ onSubmit?: (value: string) => void;
+ }) => {
+ const [value, setValue] = useState('');
+ const valueRef = useRef('');
+
+ useInput((input, key) => {
+ if (isDisabled) {
+ return;
+ }
+
+ if (key.return) {
+ onSubmit?.(value);
+ return;
+ }
+
+ if (key.backspace || key.delete) {
+ const nextValue = valueRef.current.slice(0, -1);
+ valueRef.current = nextValue;
+ onChange?.(nextValue);
+ setValue(nextValue);
+ return;
+ }
+
+ if (!input) {
+ return;
+ }
+
+ const nextValue = valueRef.current + input;
+ valueRef.current = nextValue;
+ onChange?.(nextValue);
+ setValue(nextValue);
+ });
+
+ return {value};
+ },
+}));
+
+vi.mock('./CommandMenu', () => ({
+ CommandMenu: ({
+ input,
+ onSubmit,
+ }: {
+ input: string;
+ onSubmit: (value: string) => void;
+ }) => {
+ const normalizedInput = input.trim().toLowerCase();
+ const options =
+ normalizedInput === '/unknown'
+ ? [
+ {
+ label: '/unknown - invalid command',
+ value: '/unknown',
+ },
+ ]
+ : COMMAND.LIST.filter(({ name }) =>
+ name.toLowerCase().startsWith(normalizedInput),
+ ).map(({ name, description }) => ({
+ label: `${name} - ${description}`,
+ value: name,
+ }));
+
+ useInput((_, key) => {
+ if (key.return && options[0]) {
+ onSubmit(options[0].value);
+ }
+ });
+
+ if (!options.length) {
+ return null;
+ }
+
+ return (
+ <>
+ {options.map(({ label, value }) => (
+ {label}
+ ))}
+ >
+ );
+ },
+}));
+
import { Input } from './Input';
describe('Input', () => {
@@ -17,11 +109,24 @@ describe('Input', () => {
expect(lastFrame()).not.toContain('/model');
});
- it('shows inline command suggestion when typing /', async () => {
+ it('shows command list below the input when typing /', async () => {
const { lastFrame, stdin } = render();
stdin.write('/');
await tick();
+ expect(lastFrame()).toContain('/clear - clear the current session');
expect(lastFrame()).toContain('/clear');
+ expect(lastFrame()).toContain('/model - switch the model');
+ });
+
+ it('filters the command list to matching slash commands', async () => {
+ const { lastFrame, stdin } = render();
+ stdin.write('/');
+ await tick();
+ stdin.write('m');
+ await tick();
+
+ expect(lastFrame()).toContain('/model - switch the model');
+ expect(lastFrame()).not.toContain('/clear - clear the current session');
});
it('submits typed text on Enter', async () => {
@@ -36,7 +141,7 @@ describe('Input', () => {
expect(onSubmit).toHaveBeenCalledWith('hi');
});
- it('submits completed slash command on Enter when suggestion is visible', async () => {
+ it('submits first matching slash command on Enter when list is visible', async () => {
const onSubmit = vi.fn();
const { stdin } = render();
stdin.write('/');
@@ -46,6 +151,30 @@ describe('Input', () => {
expect(onSubmit).toHaveBeenCalledWith('/clear');
});
+ it('ignores slash command submissions that are not in the command list', async () => {
+ const onSubmit = vi.fn();
+ const { stdin } = render();
+ stdin.write('/');
+ await tick();
+ stdin.write('u');
+ await tick();
+ stdin.write('n');
+ await tick();
+ stdin.write('k');
+ await tick();
+ stdin.write('n');
+ await tick();
+ stdin.write('o');
+ await tick();
+ stdin.write('w');
+ await tick();
+ stdin.write('n');
+ await tick();
+ stdin.write(KEY.ENTER);
+ await tick();
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
it('does not submit blank input', async () => {
const onSubmit = vi.fn();
const { stdin } = render();
@@ -61,7 +190,7 @@ describe('Input', () => {
stdin.write('i');
await tick();
stdin.write(KEY.ENTER);
- await tick();
+ await tick(10);
expect(lastFrame()).not.toContain('hi');
});
@@ -73,10 +202,10 @@ describe('Input', () => {
await tick();
stdin.write(KEY.BACKSPACE);
await tick();
- expect(lastFrame()).toContain('/clear');
+ expect(lastFrame()).toContain('/clear - clear the current session');
stdin.write(KEY.BACKSPACE);
await tick();
- expect(lastFrame()).not.toContain('/clear');
+ expect(lastFrame()).not.toContain('/clear - clear the current session');
});
it('does not accept input when disabled', async () => {
diff --git a/src/components/Chat/Input.tsx b/src/components/Chat/Input.tsx
index fae6b009..3d12d98a 100644
--- a/src/components/Chat/Input.tsx
+++ b/src/components/Chat/Input.tsx
@@ -3,6 +3,7 @@ import { Box, Text } from 'ink';
import { useCallback, useState } from 'react';
import { COMMAND, UI } from '../../constants';
+import { CommandMenu } from './CommandMenu';
interface Props {
isDisabled?: boolean;
@@ -10,31 +11,58 @@ interface Props {
}
export function Input({ isDisabled = false, onSubmit }: Props) {
+ const [input, setInput] = useState('');
const [resetKey, setResetKey] = useState(0);
- const handleSubmit = useCallback(
+ const handleSubmitText = useCallback(
(input: string) => {
- const trimmed = input.trim();
- if (!trimmed) {
+ setTimeout(() => {
+ if (input.startsWith('/')) {
+ return;
+ }
+
+ const trimmedInput = input.trim();
+ if (!trimmedInput) {
+ return;
+ }
+
+ onSubmit(trimmedInput);
+ setInput('');
+ setResetKey((key) => key + 1);
+ });
+ },
+ [onSubmit],
+ );
+
+ const handleSubmitCommand = useCallback(
+ (input: string) => {
+ if (!COMMAND.LIST.find(({ name }) => name === input)) {
return;
}
- onSubmit(trimmed);
+ onSubmit(input);
+ setInput('');
setResetKey((key) => key + 1);
},
[onSubmit],
);
return (
-
- {UI.PROMPT_PREFIX}
-
-
+
+
+ {UI.PROMPT_PREFIX}
+
+
+
+
+ {input.startsWith('/') && (
+
+ )}
);
}
diff --git a/src/constants/command.ts b/src/constants/command.ts
index ee1445cb..ed23f1a2 100644
--- a/src/constants/command.ts
+++ b/src/constants/command.ts
@@ -7,5 +7,3 @@ export const LIST: CommandList[] = [
{ name: '/clear', description: 'clear the current session' },
{ name: '/model', description: 'switch the model' },
] as const;
-
-export const NAMES = LIST.map(({ name }) => name);