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.';