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);