diff --git a/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx index 551cc68634..6de0f41403 100644 --- a/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx @@ -110,6 +110,7 @@ describe('ApiAuthDialog', () => { keypressHandler({ name: keyName, shift: false, + alt: false, ctrl: false, cmd: false, sequence, diff --git a/packages/cli/src/ui/components/AskUserDialog.test.tsx b/packages/cli/src/ui/components/AskUserDialog.test.tsx index 52013bf175..b93db2a2af 100644 --- a/packages/cli/src/ui/components/AskUserDialog.test.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, afterEach } from 'vitest'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { act } from 'react'; import { renderWithProviders } from '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; @@ -21,6 +21,14 @@ const writeKey = (stdin: { write: (data: string) => void }, key: string) => { }; describe('AskUserDialog', () => { + // Ensure keystrokes appear spaced in time to avoid bufferFastReturn + // converting Enter into Shift+Enter during synchronous test execution. + let mockTime: number; + beforeEach(() => { + mockTime = 0; + vi.spyOn(Date, 'now').mockImplementation(() => (mockTime += 50)); + }); + afterEach(() => { vi.restoreAllMocks(); }); @@ -158,6 +166,57 @@ describe('AskUserDialog', () => { }); }); + it('supports multi-line input for "Other" option in choice questions', async () => { + const authQuestionWithOther: Question[] = [ + { + question: 'Which authentication method?', + header: 'Auth', + options: [{ label: 'OAuth 2.0', description: '' }], + multiSelect: false, + }, + ]; + + const onSubmit = vi.fn(); + const { stdin, lastFrame } = renderWithProviders( + , + { width: 120 }, + ); + + // Navigate to "Other" option + writeKey(stdin, '\x1b[B'); // Down to "Other" + + // Type first line + for (const char of 'Line 1') { + writeKey(stdin, char); + } + + // Insert newline using \ + Enter (handled by bufferBackslashEnter) + writeKey(stdin, '\\'); + writeKey(stdin, '\r'); + + // Type second line + for (const char of 'Line 2') { + writeKey(stdin, char); + } + + await waitFor(() => { + expect(lastFrame()).toContain('Line 1'); + expect(lastFrame()).toContain('Line 2'); + }); + + // Press Enter to submit + writeKey(stdin, '\r'); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({ '0': 'Line 1\nLine 2' }); + }); + }); + describe.each([ { useAlternateBuffer: true, expectedArrows: false }, { useAlternateBuffer: false, expectedArrows: true }, @@ -763,7 +822,7 @@ describe('AskUserDialog', () => { }); }); - it('does not submit empty text', () => { + it('submits empty text as unanswered', async () => { const textQuestion: Question[] = [ { question: 'Enter the class name:', @@ -785,8 +844,9 @@ describe('AskUserDialog', () => { writeKey(stdin, '\r'); - // onSubmit should not be called for empty text - expect(onSubmit).not.toHaveBeenCalled(); + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({}); + }); }); it('clears text on Ctrl+C', async () => { diff --git a/packages/cli/src/ui/components/AskUserDialog.tsx b/packages/cli/src/ui/components/AskUserDialog.tsx index 362d8896b6..1d31b1a1f4 100644 --- a/packages/cli/src/ui/components/AskUserDialog.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.tsx @@ -93,6 +93,7 @@ type AskUserDialogAction = payload: { index: number; answer: string; + submit?: boolean; }; } | { type: 'SET_EDITING_CUSTOM'; payload: { isEditing: boolean } } @@ -114,7 +115,7 @@ function askUserDialogReducerLogic( switch (action.type) { case 'SET_ANSWER': { - const { index, answer } = action.payload; + const { index, answer, submit } = action.payload; const hasAnswer = answer !== undefined && answer !== null && answer.trim() !== ''; const newAnswers = { ...state.answers }; @@ -128,6 +129,7 @@ function askUserDialogReducerLogic( return { ...state, answers: newAnswers, + submitted: submit ? true : state.submitted, }; } case 'SET_EDITING_CUSTOM': { @@ -283,8 +285,8 @@ const TextQuestionView: React.FC = ({ const buffer = useTextBuffer({ initialText: initialAnswer, - viewport: { width: Math.max(1, bufferWidth), height: 1 }, - singleLine: true, + viewport: { width: Math.max(1, bufferWidth), height: 3 }, + singleLine: false, }); const { text: textValue } = buffer; @@ -317,9 +319,7 @@ const TextQuestionView: React.FC = ({ const handleSubmit = useCallback( (val: string) => { - if (val.trim()) { - onAnswer(val.trim()); - } + onAnswer(val.trim()); }, [onAnswer], ); @@ -561,8 +561,8 @@ const ChoiceQuestionView: React.FC = ({ const customBuffer = useTextBuffer({ initialText: initialCustomText, - viewport: { width: Math.max(1, bufferWidth), height: 1 }, - singleLine: true, + viewport: { width: Math.max(1, bufferWidth), height: 3 }, + singleLine: false, }); const customOptionText = customBuffer.text; @@ -850,9 +850,22 @@ const ChoiceQuestionView: React.FC = ({ buffer={customBuffer} placeholder={placeholder} focus={context.isSelected} - onSubmit={() => handleSelect(optionItem)} + onSubmit={(val) => { + if (question.multiSelect) { + const fullAnswer = buildAnswerString( + selectedIndices, + true, + val, + ); + if (fullAnswer) { + onAnswer(fullAnswer); + } + } else if (val.trim()) { + onAnswer(val.trim()); + } + }} /> - {isChecked && !question.multiSelect && ( + {isChecked && !question.multiSelect && !context.isSelected && ( )} @@ -1012,21 +1025,27 @@ export const AskUserDialog: React.FC = ({ (answer: string) => { if (submitted) return; - dispatch({ - type: 'SET_ANSWER', - payload: { - index: currentQuestionIndex, - answer, - }, - }); - if (questions.length > 1) { + dispatch({ + type: 'SET_ANSWER', + payload: { + index: currentQuestionIndex, + answer, + }, + }); goToNextTab(); } else { - dispatch({ type: 'SUBMIT' }); + dispatch({ + type: 'SET_ANSWER', + payload: { + index: currentQuestionIndex, + answer, + submit: true, + }, + }); } }, - [currentQuestionIndex, questions.length, submitted, goToNextTab], + [currentQuestionIndex, questions, submitted, goToNextTab], ); const handleReviewSubmit = useCallback(() => { diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx index adf9c247d4..36c7bb3437 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx @@ -47,6 +47,13 @@ const writeKey = (stdin: { write: (data: string) => void }, key: string) => { act(() => { stdin.write(key); }); + // Advance timers to simulate time passing between keystrokes. + // This avoids bufferFastReturn converting Enter to Shift+Enter. + if (vi.isFakeTimers()) { + act(() => { + vi.advanceTimersByTime(50); + }); + } }; describe('ExitPlanModeDialog', () => { @@ -234,7 +241,6 @@ Implement a comprehensive authentication system with multiple providers. // Navigate to feedback option writeKey(stdin, '\x1b[B'); // Down arrow writeKey(stdin, '\x1b[B'); // Down arrow - writeKey(stdin, '\r'); // Select to focus input // Type feedback for (const char of 'Add tests') { @@ -512,7 +518,6 @@ Implement a comprehensive authentication system with multiple providers. // Navigate to feedback option and start typing writeKey(stdin, '\x1b[B'); // Down arrow writeKey(stdin, '\x1b[B'); // Down arrow - writeKey(stdin, '\r'); // Select to focus input // Type some feedback for (const char of 'test') { diff --git a/packages/cli/src/ui/components/__snapshots__/BackgroundShellDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/BackgroundShellDisplay.test.tsx.snap index 9996ea504b..b93819b570 100644 --- a/packages/cli/src/ui/components/__snapshots__/BackgroundShellDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/BackgroundShellDisplay.test.tsx.snap @@ -54,14 +54,3 @@ exports[` > scrolls to active shell when list opens 1` │ ● 2. tail -f log.txt (PID: 1002) │ └──────────────────────────────────────────────────────────────────────────────────────────────────┘" `; - -exports[` > selects the current process and closes the list when Ctrl+L is pressed in list view 1`] = ` -"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ -│ 1: npm sta... (PID: 1001) (Focused) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │ -│ │ -│ Select Process (Enter to select, Ctrl+K to kill, Esc to cancel): │ -│ │ -│ ● 1. npm start (PID: 1001) │ -│ 2. tail -f log.txt (PID: 1002) │ -└──────────────────────────────────────────────────────────────────────────────────────────────────┘" -`; diff --git a/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap index 252066d445..f7aaca5694 100644 --- a/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap @@ -21,7 +21,7 @@ Files to Modify Approves plan and allows tools to run automatically 2. Yes, manually accept edits Approves plan but requires confirmation for each tool -● 3. Type your feedback... ✓ +● 3. Type your feedback... Enter to submit · Esc to cancel" `; @@ -47,7 +47,7 @@ Files to Modify Approves plan and allows tools to run automatically 2. Yes, manually accept edits Approves plan but requires confirmation for each tool -● 3. Add tests ✓ +● 3. Add tests Enter to submit · Esc to cancel" `; @@ -127,7 +127,7 @@ Files to Modify Approves plan and allows tools to run automatically 2. Yes, manually accept edits Approves plan but requires confirmation for each tool -● 3. Type your feedback... ✓ +● 3. Type your feedback... Enter to submit · Esc to cancel" `; @@ -153,7 +153,7 @@ Files to Modify Approves plan and allows tools to run automatically 2. Yes, manually accept edits Approves plan but requires confirmation for each tool -● 3. Add tests ✓ +● 3. Add tests Enter to submit · Esc to cancel" `; diff --git a/packages/cli/src/ui/components/shared/TextInput.tsx b/packages/cli/src/ui/components/shared/TextInput.tsx index 972cf04214..40f44cda53 100644 --- a/packages/cli/src/ui/components/shared/TextInput.tsx +++ b/packages/cli/src/ui/components/shared/TextInput.tsx @@ -13,6 +13,7 @@ import chalk from 'chalk'; import { theme } from '../../semantic-colors.js'; import type { TextBuffer } from './text-buffer.js'; import { cpSlice, cpIndexToOffset } from '../../utils/textUtils.js'; +import { keyMatchers, Command } from '../../keyMatchers.js'; export interface TextInputProps { buffer: TextBuffer; @@ -45,7 +46,7 @@ export function TextInput({ return true; } - if (key.name === 'return' && onSubmit) { + if (keyMatchers[Command.SUBMIT](key) && onSubmit) { onSubmit(text); return true; } diff --git a/packages/core/src/tools/ask-user.ts b/packages/core/src/tools/ask-user.ts index adbfa6b5c8..071dd1b317 100644 --- a/packages/core/src/tools/ask-user.ts +++ b/packages/core/src/tools/ask-user.ts @@ -207,7 +207,11 @@ export class AskUserInvocation extends BaseToolInvocation< .map(([index, answer]) => { const question = this.params.questions[parseInt(index, 10)]; const category = question?.header ?? `Q${index}`; - return ` ${category} → ${answer}`; + const prefix = ` ${category} → `; + const indent = ' '.repeat(prefix.length); + + const lines = answer.split('\n'); + return prefix + lines.join('\n' + indent); }) .join('\n')}` : 'User submitted without answering questions.';