diff --git a/packages/cli/src/ui/components/shared/TextInput.test.tsx b/packages/cli/src/ui/components/shared/TextInput.test.tsx new file mode 100644 index 000000000..09b72cb1c --- /dev/null +++ b/packages/cli/src/ui/components/shared/TextInput.test.tsx @@ -0,0 +1,143 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render } from 'ink-testing-library'; +import { TextInput } from './TextInput.js'; +import { useKeypress } from '../../hooks/useKeypress.js'; +import type { Key } from '../../hooks/useKeypress.js'; + +vi.mock('../../hooks/useKeypress.js', () => ({ + useKeypress: vi.fn(), +})); + +vi.mock('../../semantic-colors.js', () => ({ + theme: { + text: { accent: 'cyan' }, + status: { error: 'red' }, + }, +})); + +const mockedUseKeypress = vi.mocked(useKeypress); + +function makeKey(overrides: Partial): Key { + return { + name: '', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '', + ...overrides, + }; +} + +function captureKeypressHandler(): (key: Key) => void { + const calls = mockedUseKeypress.mock.calls; + if (calls.length === 0) { + throw new Error('useKeypress was not called'); + } + // Return the most recent handler + return calls[calls.length - 1]![0] as (key: Key) => void; +} + +describe('TextInput', () => { + let onChange: ReturnType; + let onSubmit: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + onChange = vi.fn(); + onSubmit = vi.fn(); + }); + + describe('multiline mode (height > 1)', () => { + it('submits on plain Enter', () => { + render( + , + ); + + const handler = captureKeypressHandler(); + handler(makeKey({ name: 'return', sequence: '\r' })); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it('does NOT submit on Shift+Enter — inserts newline instead', () => { + render( + , + ); + + const handler = captureKeypressHandler(); + handler(makeKey({ name: 'return', shift: true, sequence: '\r' })); + + expect(onSubmit).not.toHaveBeenCalled(); + // onChange should be called with the newline character + expect(onChange).toHaveBeenCalled(); + }); + + it('does NOT submit on Ctrl+Enter — inserts newline instead', () => { + render( + , + ); + + const handler = captureKeypressHandler(); + handler(makeKey({ name: 'return', ctrl: true, sequence: '\r' })); + + expect(onSubmit).not.toHaveBeenCalled(); + expect(onChange).toHaveBeenCalled(); + }); + }); + + describe('single-line mode (height = 1)', () => { + it('submits on plain Enter', () => { + render( + , + ); + + const handler = captureKeypressHandler(); + handler(makeKey({ name: 'return', sequence: '\r' })); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it('submits on Shift+Enter (no newline concept in single-line)', () => { + render( + , + ); + + const handler = captureKeypressHandler(); + handler(makeKey({ name: 'return', shift: true, sequence: '\r' })); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/cli/src/ui/components/shared/TextInput.tsx b/packages/cli/src/ui/components/shared/TextInput.tsx index eed7d2a78..383d32333 100644 --- a/packages/cli/src/ui/components/shared/TextInput.tsx +++ b/packages/cli/src/ui/components/shared/TextInput.tsx @@ -93,27 +93,20 @@ export function TextInput({ return; } - // Submit on Enter - if (keyMatchers[Command.SUBMIT](key) || key.name === 'return') { - if (allowMultiline) { - const [row, col] = buffer.cursor; - const line = buffer.lines[row]; - const charBefore = col > 0 ? cpSlice(line, col - 1, col) : ''; - if (charBefore === '\\') { - buffer.backspace(); - buffer.newline(); - } else { - handleSubmit(); - } - } else { - handleSubmit(); - } + // Multiline newline insertion (Shift+Enter etc.) — check before SUBMIT + // so that modified-Return keys aren't swallowed by the submit branch. + if (allowMultiline && keyMatchers[Command.NEWLINE](key)) { + buffer.newline(); return; } - // Multiline newline insertion (Shift+Enter etc.) - if (allowMultiline && keyMatchers[Command.NEWLINE](key)) { - buffer.newline(); + // Submit on Enter (plain Return). In single-line mode any Return + // variant submits since there is no newline concept. + if ( + keyMatchers[Command.SUBMIT](key) || + (!allowMultiline && key.name === 'return') + ) { + handleSubmit(); return; }