diff --git a/packages/cli/src/ui/components/AskUserDialog.test.tsx b/packages/cli/src/ui/components/AskUserDialog.test.tsx index 1bd29241db..cade23a918 100644 --- a/packages/cli/src/ui/components/AskUserDialog.test.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.test.tsx @@ -1347,4 +1347,79 @@ describe('AskUserDialog', () => { }); }); }); + + describe('Paste placeholder expansion', () => { + const largePasteText = 'line1\nline2\nline3\nline4\nline5\nline6'; + + it('expands paste placeholders in text question submission', async () => { + const textQuestion: Question[] = [ + { + question: 'Describe the issue', + header: 'Description', + type: QuestionType.TEXT, + }, + ]; + + const onSubmit = vi.fn(); + const { stdin } = renderWithProviders( + , + { width: 120 }, + ); + + // Simulate a bracketed paste of large text (>5 lines triggers placeholder) + writeKey(stdin, `\x1B[200~${largePasteText}\x1B[201~`); + + // Submit with Enter + writeKey(stdin, '\r'); + + await waitFor(async () => { + expect(onSubmit).toHaveBeenCalledWith({ '0': largePasteText }); + }); + }); + + it('expands paste placeholders in choice question custom "Other" input', async () => { + const choiceQuestion: Question[] = [ + { + question: 'Which option?', + header: 'Option', + type: QuestionType.CHOICE, + options: [ + { label: 'Option A', description: 'First option' }, + { label: 'Option B', description: 'Second option' }, + ], + multiSelect: false, + }, + ]; + + const onSubmit = vi.fn(); + const { stdin } = renderWithProviders( + , + { width: 120 }, + ); + + // Navigate down past options to the "Other" custom option + writeKey(stdin, '\x1b[B'); // Down past Option A + writeKey(stdin, '\x1b[B'); // Down past Option B to "Other" + + // Simulate a bracketed paste of large text + writeKey(stdin, `\x1B[200~${largePasteText}\x1B[201~`); + + // Submit with Enter + writeKey(stdin, '\r'); + + await waitFor(async () => { + expect(onSubmit).toHaveBeenCalledWith({ '0': largePasteText }); + }); + }); + }); }); diff --git a/packages/cli/src/ui/components/AskUserDialog.tsx b/packages/cli/src/ui/components/AskUserDialog.tsx index 9606513510..83f9b18b45 100644 --- a/packages/cli/src/ui/components/AskUserDialog.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.tsx @@ -23,7 +23,10 @@ import { useKeypress, type Key } from '../hooks/useKeypress.js'; import { keyMatchers, Command } from '../keyMatchers.js'; import { checkExhaustive } from '@google/gemini-cli-core'; import { TextInput } from './shared/TextInput.js'; -import { useTextBuffer } from './shared/text-buffer.js'; +import { + useTextBuffer, + PASTED_TEXT_PLACEHOLDER_REGEX, +} from './shared/text-buffer.js'; import { getCachedStringWidth } from '../utils/textUtils.js'; import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js'; import { DialogFooter } from './shared/DialogFooter.js'; @@ -81,6 +84,21 @@ function autoBoldIfPlain(text: string): string { return text; } +/** + * Expands paste placeholders like [Pasted Text: 6 lines] with the actual + * pasted content stored in the buffer's pastedContent map. + */ +function expandPastePlaceholders( + text: string, + pastedContent: Record, +): string { + if (!pastedContent || Object.keys(pastedContent).length === 0) return text; + return text.replace( + PASTED_TEXT_PLACEHOLDER_REGEX, + (match) => pastedContent[match] || match, + ); +} + interface AskUserDialogState { answers: { [key: string]: string }; isEditingCustomOption: boolean; @@ -302,10 +320,11 @@ const TextQuestionView: React.FC = ({ const lastTextValueRef = useRef(textValue); useEffect(() => { if (textValue !== lastTextValueRef.current) { - onSelectionChange?.(textValue); + const expanded = expandPastePlaceholders(textValue, buffer.pastedContent); + onSelectionChange?.(expanded); lastTextValueRef.current = textValue; } - }, [textValue, onSelectionChange]); + }, [textValue, onSelectionChange, buffer.pastedContent]); // Handle Ctrl+C to clear all text const handleExtraKeys = useCallback( @@ -588,11 +607,15 @@ const ChoiceQuestionView: React.FC = ({ } }); if (includeCustomOption && customOption.trim()) { - answers.push(customOption.trim()); + const expanded = expandPastePlaceholders( + customOption.trim(), + customBuffer.pastedContent, + ); + answers.push(expanded); } return answers.join(', '); }, - [questionOptions], + [questionOptions, customBuffer.pastedContent], ); // Synchronize selection changes with parent - only when it actually changes @@ -757,7 +780,11 @@ const ChoiceQuestionView: React.FC = ({ } else if (itemValue.type === 'other') { // In single select, selecting other submits it if it has text if (customOptionText.trim()) { - onAnswer(customOptionText.trim()); + const expanded = expandPastePlaceholders( + customOptionText.trim(), + customBuffer.pastedContent, + ); + onAnswer(expanded); } } } @@ -767,6 +794,7 @@ const ChoiceQuestionView: React.FC = ({ selectedIndices, isCustomOptionSelected, customOptionText, + customBuffer.pastedContent, onAnswer, buildAnswerString, ], diff --git a/packages/cli/src/ui/components/shared/TextInput.test.tsx b/packages/cli/src/ui/components/shared/TextInput.test.tsx index f12714e288..0868e70497 100644 --- a/packages/cli/src/ui/components/shared/TextInput.test.tsx +++ b/packages/cli/src/ui/components/shared/TextInput.test.tsx @@ -17,7 +17,8 @@ vi.mock('../../hooks/useKeypress.js', () => ({ useKeypress: vi.fn(), })); -vi.mock('./text-buffer.js', () => { +vi.mock(import('./text-buffer.js'), async (importOriginal) => { + const actual = await importOriginal(); const mockTextBuffer = { text: '', lines: [''], @@ -60,6 +61,7 @@ vi.mock('./text-buffer.js', () => { }; return { + ...actual, useTextBuffer: vi.fn(() => mockTextBuffer as unknown as TextBuffer), TextBuffer: vi.fn(() => mockTextBuffer as unknown as TextBuffer), }; @@ -366,4 +368,65 @@ describe('TextInput', () => { expect(lastFrame()).toContain('line2'); unmount(); }); + + it('expands paste placeholders on submit', async () => { + const placeholder = '[Pasted Text: 6 lines]'; + const actualContent = 'line1\nline2\nline3\nline4\nline5\nline6'; + mockBuffer.text = placeholder; + mockBuffer.viewportVisualLines = [placeholder]; + ( + mockBuffer as unknown as { pastedContent: Record } + ).pastedContent = { + [placeholder]: actualContent, + }; + + const { waitUntilReady, unmount } = render( + , + ); + await waitUntilReady(); + const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + + await act(async () => { + keypressHandler({ + name: 'return', + shift: false, + alt: false, + ctrl: false, + cmd: false, + sequence: '', + }); + }); + await waitUntilReady(); + + expect(onSubmit).toHaveBeenCalledWith(actualContent); + unmount(); + }); + + it('passes text through unchanged on submit when no paste placeholders exist', async () => { + mockBuffer.setText('regular text'); + ( + mockBuffer as unknown as { pastedContent: Record } + ).pastedContent = {}; + + const { waitUntilReady, unmount } = render( + , + ); + await waitUntilReady(); + const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + + await act(async () => { + keypressHandler({ + name: 'return', + shift: false, + alt: false, + ctrl: false, + cmd: false, + sequence: '', + }); + }); + await waitUntilReady(); + + expect(onSubmit).toHaveBeenCalledWith('regular text'); + unmount(); + }); }); diff --git a/packages/cli/src/ui/components/shared/TextInput.tsx b/packages/cli/src/ui/components/shared/TextInput.tsx index 40f44cda53..1d6dbf2fad 100644 --- a/packages/cli/src/ui/components/shared/TextInput.tsx +++ b/packages/cli/src/ui/components/shared/TextInput.tsx @@ -11,7 +11,10 @@ import { Text, Box } from 'ink'; import { useKeypress } from '../../hooks/useKeypress.js'; import chalk from 'chalk'; import { theme } from '../../semantic-colors.js'; -import type { TextBuffer } from './text-buffer.js'; +import { + type TextBuffer, + PASTED_TEXT_PLACEHOLDER_REGEX, +} from './text-buffer.js'; import { cpSlice, cpIndexToOffset } from '../../utils/textUtils.js'; import { keyMatchers, Command } from '../../keyMatchers.js'; @@ -47,14 +50,23 @@ export function TextInput({ } if (keyMatchers[Command.SUBMIT](key) && onSubmit) { - onSubmit(text); + // Expand paste placeholders so consumers receive actual content + let expandedText = text; + const { pastedContent } = buffer; + if (pastedContent && Object.keys(pastedContent).length > 0) { + expandedText = text.replace( + PASTED_TEXT_PLACEHOLDER_REGEX, + (match) => pastedContent[match] || match, + ); + } + onSubmit(expandedText); return true; } const handled = handleInput(key); return handled; }, - [handleInput, onCancel, onSubmit, text], + [handleInput, onCancel, onSubmit, text, buffer], ); useKeypress(handleKeyPress, { isActive: focus, priority: true });