From 6f693f1b711ba5eb8bfbcb0d96995bd704e04fd1 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Sat, 11 Apr 2026 13:02:28 +0800 Subject: [PATCH] fix(cli): check NEWLINE before SUBMIT in TextInput to fix multiline input (#3068) (#3094) The `|| key.name === 'return'` fallback in TextInput matched every Return keypress (Shift+Enter, Ctrl+Enter, etc.) and routed them all to the submit path, making the NEWLINE handler dead code. Multiline inputs like the agent creation description step could not insert newlines via keyboard. Reorder checks so NEWLINE is evaluated first in multiline mode, and restrict the broad return fallback to single-line inputs only. --- .../ui/components/shared/TextInput.test.tsx | 143 ++++++++++++++++++ .../src/ui/components/shared/TextInput.tsx | 29 ++-- 2 files changed, 154 insertions(+), 18 deletions(-) create mode 100644 packages/cli/src/ui/components/shared/TextInput.test.tsx 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; }