Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions src/components/Chat/CommandMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<Text key={value}>{label}</Text>
))}
</>
);
},
}));

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(
<CommandMenu input="hello" onSubmit={onSubmit} />,
);

expect(lastFrame()).toBe('');
expect(mockSelectPrompt).not.toHaveBeenCalled();
});

it('returns null when no commands match the slash input', () => {
const onSubmit = vi.fn();
const { lastFrame } = render(
<CommandMenu input="/x" onSubmit={onSubmit} />,
);

expect(lastFrame()).toBe('');
expect(mockSelectPrompt).not.toHaveBeenCalled();
});

it('renders matching commands and forwards selection', () => {
const onSubmit = vi.fn();
const { lastFrame } = render(
<CommandMenu input="/m" onSubmit={onSubmit} />,
);

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');
});
});
39 changes: 39 additions & 0 deletions src/components/Chat/CommandMenu.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<SelectPrompt
highlightText={input}
onChange={onSubmit}
options={commandOptions}
/>
);
}
141 changes: 135 additions & 6 deletions src/components/Chat/Input.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <Text>{value}</Text>;
},
}));

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 }) => (
<Text key={value}>{label}</Text>
))}
</>
);
},
}));

import { Input } from './Input';

describe('Input', () => {
Expand All @@ -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(<Input onSubmit={vi.fn()} />);
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(<Input onSubmit={vi.fn()} />);
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 () => {
Expand All @@ -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(<Input onSubmit={onSubmit} />);
stdin.write('/');
Expand All @@ -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(<Input onSubmit={onSubmit} />);
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(<Input onSubmit={onSubmit} />);
Expand All @@ -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');
});

Expand All @@ -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 () => {
Expand Down
54 changes: 41 additions & 13 deletions src/components/Chat/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,38 +3,66 @@ import { Box, Text } from 'ink';
import { useCallback, useState } from 'react';

import { COMMAND, UI } from '../../constants';
import { CommandMenu } from './CommandMenu';

interface Props {
isDisabled?: boolean;
onSubmit: (value: string) => void;
}

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 (
<Box>
<Text>{UI.PROMPT_PREFIX}</Text>

<TextInput
isDisabled={isDisabled}
key={resetKey}
suggestions={COMMAND.NAMES}
onSubmit={handleSubmit}
/>
<Box flexDirection="column">
<Box>
<Text>{UI.PROMPT_PREFIX}</Text>

<TextInput
isDisabled={isDisabled}
key={resetKey}
onChange={setInput}
onSubmit={handleSubmitText}
/>
</Box>

{input.startsWith('/') && (
<CommandMenu input={input} onSubmit={handleSubmitCommand} />
)}
</Box>
);
}
2 changes: 0 additions & 2 deletions src/constants/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Loading