diff --git a/src/components/Autocomplete.test.tsx b/src/components/Autocomplete.test.tsx deleted file mode 100644 index 777aa584..00000000 --- a/src/components/Autocomplete.test.tsx +++ /dev/null @@ -1,247 +0,0 @@ -import { render } from 'ink-testing-library'; - -import { tick } from '../utils/test'; - -vi.mock('../constants', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - COMMANDS: [ - { name: '/model', description: 'switch the model' }, - { name: '/mock', description: 'mock command' }, - ], - }; -}); - -import { KEY } from '../constants'; -import { Autocomplete } from './Autocomplete'; - -describe('Autocomplete', () => { - it('renders input prompt', () => { - const { lastFrame } = render(); - expect(lastFrame()).toContain('>'); - }); - - it('does not show suggestions on non-slash input', async () => { - const { lastFrame, stdin } = render(); - stdin.write('h'); - await tick(); - expect(lastFrame()).not.toContain('/model'); - }); - - it('shows suggestions when typing /', async () => { - const { lastFrame, stdin } = render(); - stdin.write('/'); - await tick(); - expect(lastFrame()).toContain('/model'); - expect(lastFrame()).toContain('switch the model'); - }); - - it('filters suggestions as input narrows', async () => { - const { lastFrame, stdin } = render(); - stdin.write('/'); - await tick(); - stdin.write('m'); - await tick(); - expect(lastFrame()).toContain('/model'); - stdin.write('x'); - await tick(); - expect(lastFrame()).not.toContain('/model'); - }, 10000); - - it('completes suggestion on Tab', async () => { - const { lastFrame, stdin } = render(); - stdin.write('/'); - await tick(); - stdin.write(KEY.TAB); - await tick(); - expect(lastFrame()).toContain('/model'); - }); - - it('submits completed value on Enter after Tab', async () => { - const onSubmit = vi.fn(); - const { stdin } = render(); - stdin.write('/'); - await tick(); - stdin.write(KEY.TAB); - await tick(); - stdin.write(KEY.ENTER); - await tick(); - expect(onSubmit).toHaveBeenCalledWith('/model'); - }); - - it('submits typed text on Enter without suggestion selected', async () => { - const onSubmit = vi.fn(); - const { stdin } = render(); - stdin.write('h'); - await tick(); - stdin.write('i'); - await tick(); - stdin.write(KEY.ENTER); - await tick(); - expect(onSubmit).toHaveBeenCalledWith('hi'); - }); - - it('does not submit blank input', async () => { - const onSubmit = vi.fn(); - const { stdin } = render(); - stdin.write(KEY.ENTER); - await tick(); - expect(onSubmit).not.toHaveBeenCalled(); - }); - - it('clears input after submit', async () => { - const { lastFrame, stdin } = render(); - stdin.write('h'); - await tick(); - stdin.write('i'); - await tick(); - stdin.write(KEY.ENTER); - await tick(); - expect(lastFrame()).not.toContain('hi'); - }); - - it('clears input and suggestions on Escape', async () => { - const { lastFrame, stdin } = render(); - stdin.write('/'); - await tick(); - expect(lastFrame()).toContain('/model'); - stdin.write(KEY.ESCAPE); - await tick(50); - expect(lastFrame()).not.toContain('/model'); - }); - - it('deletes last character on backspace', async () => { - const { lastFrame, stdin } = render(); - stdin.write('/'); - await tick(); - stdin.write('m'); - await tick(); - stdin.write(KEY.BACKSPACE); - await tick(); - expect(lastFrame()).toContain('/model'); - stdin.write(KEY.BACKSPACE); - await tick(); - expect(lastFrame()).not.toContain('/model'); - }); - - it('does not accept input when disabled', async () => { - const onSubmit = vi.fn(); - const { lastFrame, stdin } = render( - , - ); - stdin.write('h'); - await tick(); - expect(lastFrame()).not.toContain('h'); - stdin.write(KEY.ENTER); - await tick(); - expect(onSubmit).not.toHaveBeenCalled(); - }); - - it('moves highlight down with arrow keys', async () => { - const { stdin } = render(); - stdin.write('/'); - await tick(); - stdin.write(KEY.DOWN); - await tick(); - stdin.write(KEY.UP); - await tick(); - }); - - it('submits highlighted suggestion on Enter', async () => { - const onSubmit = vi.fn(); - const { stdin } = render(); - stdin.write('/'); - await tick(); - stdin.write(KEY.ENTER); - await tick(); - expect(onSubmit).toHaveBeenCalledWith('/model'); - }); - - it('shows non-highlighted suggestions when arrow down moves selection', async () => { - const { lastFrame, stdin } = render(); - stdin.write('/'); - await tick(); - expect(lastFrame()).toContain('/model'); - expect(lastFrame()).toContain('/mock'); - stdin.write(KEY.DOWN); - await tick(); - expect(lastFrame()).toContain('/mock'); - }); - - it('shows block cursor in output', async () => { - const { lastFrame, stdin } = render(); - stdin.write('hi'); - await tick(); - // The input should be visible with block cursor at end - expect(lastFrame()).toContain('hi'); - }); - - it('cursor stays at end when typing', async () => { - const { lastFrame, stdin } = render(); - stdin.write('a'); - await tick(); - stdin.write('b'); - await tick(); - stdin.write('c'); - await tick(); - expect(lastFrame()).toContain('abc'); - }); - - it('handles down arrow when no matches available', async () => { - const { lastFrame, stdin } = render(); - // Type /xyz to get zero matches - stdin.write('/xyz'); - await tick(); - expect(lastFrame()).not.toContain('/model'); - // Down arrow with matches.length=0 triggers Math.min(-1, 1) = -1 (line 39-40) - stdin.write(KEY.DOWN); - await tick(); - // Should not crash - expect(lastFrame()).toContain('/xyz'); - }); - - it('uses fallback match when selectedIndex out of bounds', async () => { - const onSubmit = vi.fn(); - const { stdin } = render(); - // Type / then Tab twice - first Tab sets value, second tests with changed state - stdin.write('/'); - await tick(); - // Move down to increase selectedIndex - stdin.write(KEY.DOWN); - await tick(); - stdin.write(KEY.DOWN); - await tick(); - // Tab should complete using fallback when selectedIndex >= matches.length (line 44) - stdin.write(KEY.TAB); - await tick(); - stdin.write(KEY.ENTER); - await tick(); - // Should submit successfully - expect(onSubmit).toHaveBeenCalled(); - }); - - it('nullish coalescing when selectedIndex exceeds matches after filtering', async () => { - const { lastFrame, stdin } = render(); - // Start with both matches showing - stdin.write('/'); - await tick(); - // Move down to select /mock (index 1) - stdin.write(KEY.DOWN); - await tick(); - // Type 'mock' to narrow to just /mock - selectedIndex stays 1, matches.length becomes 1 - stdin.write('m'); - await tick(); - stdin.write('o'); - await tick(); - stdin.write('c'); - await tick(); - stdin.write('k'); - await tick(); - // Tab should trigger ?? fallback since matches[1] is undefined - stdin.write(KEY.TAB); - await tick(); - // Should complete to /mock using the fallback - expect(lastFrame()).toContain('/mock'); - }); -}); diff --git a/src/components/Autocomplete.tsx b/src/components/Autocomplete.tsx deleted file mode 100644 index f2ce488f..00000000 --- a/src/components/Autocomplete.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { TextInput } from '@inkjs/ui'; -import { Box, Text, useInput } from 'ink'; -import { useCallback, useState } from 'react'; - -import { type Command, COMMANDS, UI } from '../constants'; - -interface Props { - isDisabled?: boolean; - onSubmit: (value: string) => void; -} - -function getMatches(input: string): Command[] { - if (!input.startsWith('/')) { - return []; - } - return COMMANDS.filter((command) => command.name.startsWith(input)); -} - -export function Autocomplete({ isDisabled = false, onSubmit }: Props) { - const [value, setValue] = useState(''); - const [selectedIndex, setSelectedIndex] = useState(0); - const [inputKey, setInputKey] = useState(0); - - const matches = getMatches(value); - const isCommandMode = value.startsWith('/'); - - useInput( - (_char, key) => { - // v8 ignore next - if (!isCommandMode) { - return; - } - - if (key.upArrow) { - setSelectedIndex((i) => Math.max(0, i - 1)); - return; - } - - if (key.downArrow) { - setSelectedIndex((i) => Math.min(matches.length - 1, i + 1)); - return; - } - - if (key.tab && matches.length > 0) { - // v8 ignore next - const match = matches[selectedIndex] ?? matches[0]; - setValue(match.name); - setSelectedIndex(0); - setInputKey((key) => key + 1); - return; - } - }, - { isActive: !isDisabled && isCommandMode }, - ); - - const handleSubmit = useCallback( - (input: string) => { - const submitValue = - isCommandMode && matches.length > 0 && matches[selectedIndex] - ? matches[selectedIndex].name - : input; - const trimmed = submitValue.trim(); - if (trimmed) { - onSubmit(trimmed); - setValue(''); - setSelectedIndex(0); - setInputKey((key) => key + 1); - } - }, - [isCommandMode, matches, onSubmit, selectedIndex], - ); - - return ( - - - {UI.PROMPT_PREFIX} - - - - {isCommandMode && matches.length > 0 && ( - - {matches.map((command, index) => { - const isHighlighted = index === selectedIndex; - return ( - - - {command.name} - - {command.description} - - ); - })} - - )} - - ); -} diff --git a/src/components/Chat.test.tsx b/src/components/Chat.test.tsx index a64d3f3b..afba95cd 100644 --- a/src/components/Chat.test.tsx +++ b/src/components/Chat.test.tsx @@ -32,8 +32,8 @@ vi.mock('../utils', async () => { }; }); -vi.mock('./Autocomplete', () => ({ - Autocomplete: (props: { +vi.mock('./ChatInput', () => ({ + ChatInput: (props: { onSubmit?: (value: string) => void; isDisabled?: boolean; }) => { diff --git a/src/components/Chat.tsx b/src/components/Chat.tsx index c2b6f071..0725e611 100644 --- a/src/components/Chat.tsx +++ b/src/components/Chat.tsx @@ -3,7 +3,7 @@ import { useCallback, useState } from 'react'; import { ROLE, TOOL } from '../constants'; import { agents, ollama, tools } from '../utils'; -import { Autocomplete } from './Autocomplete'; +import { ChatInput } from './ChatInput'; import { Messages } from './Messages'; import { ToolApproval } from './ToolApproval'; @@ -17,7 +17,6 @@ export function Chat({ model, onCommand, autoExecute }: Props) { const [messages, setMessages] = useState([ agents.createSystemMessage(), ]); - const [submitKey, setSubmitKey] = useState(0); const [isLoading, setIsLoading] = useState(false); const [pendingToolCall, setPendingToolCall] = useState(null); @@ -28,6 +27,7 @@ export function Chat({ model, onCommand, autoExecute }: Props) { role: ROLE.ASSISTANT, content: '', }; + setMessages((previousMessages) => [ ...previousMessages, assistantMessage, @@ -157,9 +157,9 @@ export function Chat({ model, onCommand, autoExecute }: Props) { const handleSubmit = useCallback( async (value: string) => { const userContent = value.trim(); - if (!userContent) return; - - setSubmitKey((key) => key + 1); + if (!userContent) { + return; + } if (userContent.startsWith('/')) { onCommand(userContent); @@ -194,13 +194,8 @@ export function Chat({ model, onCommand, autoExecute }: Props) { )} {!pendingToolCall && ( - { - void handleSubmit(val); - }} - /> + // eslint-disable-next-line @typescript-eslint/no-misused-promises + )} ); diff --git a/src/components/ChatInput.test.tsx b/src/components/ChatInput.test.tsx new file mode 100644 index 00000000..8715cd5b --- /dev/null +++ b/src/components/ChatInput.test.tsx @@ -0,0 +1,94 @@ +import { render } from 'ink-testing-library'; + +import { KEY } from '../constants'; +import { tick } from '../utils/test'; +import { ChatInput } from './ChatInput'; + +describe('ChatInput', () => { + it('renders input prompt', () => { + const { lastFrame } = render(); + expect(lastFrame()).toContain('>'); + }); + + it('does not show command suggestion on non-slash input', async () => { + const { lastFrame, stdin } = render(); + stdin.write('h'); + await tick(); + expect(lastFrame()).not.toContain('/model'); + }); + + it('shows inline command suggestion when typing /', async () => { + const { lastFrame, stdin } = render(); + stdin.write('/'); + await tick(); + expect(lastFrame()).toContain('/model'); + }); + + it('submits typed text on Enter', async () => { + const onSubmit = vi.fn(); + const { stdin } = render(); + stdin.write('h'); + await tick(); + stdin.write('i'); + await tick(); + stdin.write(KEY.ENTER); + await tick(); + expect(onSubmit).toHaveBeenCalledWith('hi'); + }); + + it('submits completed slash command on Enter when suggestion is visible', async () => { + const onSubmit = vi.fn(); + const { stdin } = render(); + stdin.write('/'); + await tick(); + stdin.write(KEY.ENTER); + await tick(); + expect(onSubmit).toHaveBeenCalledWith('/model'); + }); + + it('does not submit blank input', async () => { + const onSubmit = vi.fn(); + const { stdin } = render(); + stdin.write(KEY.ENTER); + await tick(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('clears input after submit', async () => { + const { lastFrame, stdin } = render(); + stdin.write('h'); + await tick(); + stdin.write('i'); + await tick(); + stdin.write(KEY.ENTER); + await tick(); + expect(lastFrame()).not.toContain('hi'); + }); + + it('deletes last character on backspace', async () => { + const { lastFrame, stdin } = render(); + stdin.write('/'); + await tick(); + stdin.write('m'); + await tick(); + stdin.write(KEY.BACKSPACE); + await tick(); + expect(lastFrame()).toContain('/model'); + stdin.write(KEY.BACKSPACE); + await tick(); + expect(lastFrame()).not.toContain('/model'); + }); + + it('does not accept input when disabled', async () => { + const onSubmit = vi.fn(); + const { lastFrame, stdin } = render( + , + ); + stdin.write('h'); + await tick(); + expect(lastFrame()).not.toContain('h'); + stdin.write(KEY.ENTER); + await tick(); + expect(onSubmit).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx new file mode 100644 index 00000000..3e893883 --- /dev/null +++ b/src/components/ChatInput.tsx @@ -0,0 +1,40 @@ +import { TextInput } from '@inkjs/ui'; +import { Box, Text } from 'ink'; +import { useCallback, useState } from 'react'; + +import { COMMAND, UI } from '../constants'; + +interface Props { + isDisabled?: boolean; + onSubmit: (value: string) => void; +} + +export function ChatInput({ isDisabled = false, onSubmit }: Props) { + const [resetKey, setResetKey] = useState(0); + + const handleSubmit = useCallback( + (input: string) => { + const trimmed = input.trim(); + if (!trimmed) { + return; + } + + onSubmit(trimmed); + setResetKey((key) => key + 1); + }, + [onSubmit], + ); + + return ( + + {UI.PROMPT_PREFIX} + + + + ); +} diff --git a/src/components/index.ts b/src/components/index.ts index 8e4e4d10..4e671c98 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,6 +1,6 @@ export { App } from './App'; -export { Autocomplete } from './Autocomplete'; export { Chat } from './Chat'; +export { ChatInput } from './ChatInput'; export { Footer } from './Footer'; export { Header } from './Header'; export { Messages } from './Messages'; diff --git a/src/constants/command.ts b/src/constants/command.ts new file mode 100644 index 00000000..7d671073 --- /dev/null +++ b/src/constants/command.ts @@ -0,0 +1,10 @@ +export interface CommandList { + name: string; + description: string; +} + +export const LIST: CommandList[] = [ + { name: '/model', description: 'switch the model' }, +] as const; + +export const NAMES = LIST.map(({ name }) => name); diff --git a/src/constants/commands.ts b/src/constants/commands.ts deleted file mode 100644 index d95cfed5..00000000 --- a/src/constants/commands.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface Command { - name: string; - description: string; -} - -export const COMMANDS: Command[] = [ - { name: '/model', description: 'switch the model' }, -] as const; diff --git a/src/constants/index.ts b/src/constants/index.ts index ac763599..7c2f6fc0 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1,4 +1,4 @@ -export * from './commands'; +export * as COMMAND from './command'; export * as KEY from './key'; export * as PACKAGE from './package'; export * as PROMPT from './prompt';