diff --git a/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx index 86d3204b84..da8b43dd20 100644 --- a/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx @@ -29,9 +29,16 @@ vi.mock('../hooks/useKeypress.js', () => ({ useKeypress: vi.fn(), })); -vi.mock('../components/shared/text-buffer.js', () => ({ - useTextBuffer: vi.fn(), -})); +vi.mock('../components/shared/text-buffer.js', async (importOriginal) => { + const actual = + await importOriginal< + typeof import('../components/shared/text-buffer.js') + >(); + return { + ...actual, + useTextBuffer: vi.fn(), + }; +}); vi.mock('../contexts/UIStateContext.js', () => ({ useUIState: vi.fn(() => ({ diff --git a/packages/cli/src/ui/components/AskUserDialog.test.tsx b/packages/cli/src/ui/components/AskUserDialog.test.tsx index 1bd29241db..0857306ea8 100644 --- a/packages/cli/src/ui/components/AskUserDialog.test.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.test.tsx @@ -1347,4 +1347,47 @@ describe('AskUserDialog', () => { }); }); }); + + it('expands paste placeholders in multi-select custom option via Done', async () => { + const questions: Question[] = [ + { + question: 'Which features?', + header: 'Features', + type: QuestionType.CHOICE, + options: [{ label: 'TypeScript', description: '' }], + multiSelect: true, + }, + ]; + + const onSubmit = vi.fn(); + const { stdin } = renderWithProviders( + , + { width: 120 }, + ); + + // Select TypeScript + writeKey(stdin, '\r'); + // Down to Other + writeKey(stdin, '\x1b[B'); + + // Simulate bracketed paste of multi-line text into the custom option + const pastedText = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6'; + const ESC = '\x1b'; + writeKey(stdin, `${ESC}[200~${pastedText}${ESC}[201~`); + + // Down to Done and submit + writeKey(stdin, '\x1b[B'); + writeKey(stdin, '\r'); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({ + '0': `TypeScript, ${pastedText}`, + }); + }); + }); }); diff --git a/packages/cli/src/ui/components/AskUserDialog.tsx b/packages/cli/src/ui/components/AskUserDialog.tsx index 488a00b45e..284e4e1df8 100644 --- a/packages/cli/src/ui/components/AskUserDialog.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.tsx @@ -24,7 +24,10 @@ import { keyMatchers, Command } from '../keyMatchers.js'; import { checkExhaustive } from '@google/gemini-cli-core'; import { TextInput } from './shared/TextInput.js'; import { formatCommand } from '../utils/keybindingUtils.js'; -import { useTextBuffer } from './shared/text-buffer.js'; +import { + useTextBuffer, + expandPastePlaceholders, +} from './shared/text-buffer.js'; import { getCachedStringWidth } from '../utils/textUtils.js'; import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js'; import { DialogFooter } from './shared/DialogFooter.js'; @@ -303,10 +306,12 @@ const TextQuestionView: React.FC = ({ const lastTextValueRef = useRef(textValue); useEffect(() => { if (textValue !== lastTextValueRef.current) { - onSelectionChange?.(textValue); + onSelectionChange?.( + expandPastePlaceholders(textValue, buffer.pastedContent), + ); lastTextValueRef.current = textValue; } - }, [textValue, onSelectionChange]); + }, [textValue, onSelectionChange, buffer.pastedContent]); // Handle Ctrl+C to clear all text const handleExtraKeys = useCallback( @@ -589,11 +594,15 @@ const ChoiceQuestionView: React.FC = ({ } }); if (includeCustomOption && customOption.trim()) { - answers.push(customOption.trim()); + const expanded = expandPastePlaceholders( + customOption, + customBuffer.pastedContent, + ); + answers.push(expanded.trim()); } return answers.join(', '); }, - [questionOptions], + [questionOptions, customBuffer.pastedContent], ); // Synchronize selection changes with parent - only when it actually changes @@ -758,7 +767,12 @@ 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()); + onAnswer( + expandPastePlaceholders( + customOptionText, + customBuffer.pastedContent, + ).trim(), + ); } } } @@ -768,6 +782,7 @@ const ChoiceQuestionView: React.FC = ({ selectedIndices, isCustomOptionSelected, customOptionText, + customBuffer.pastedContent, onAnswer, buildAnswerString, ], diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 6f2cd9ab7a..05184838ee 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -15,7 +15,7 @@ import { HalfLinePaddedBox } from './shared/HalfLinePaddedBox.js'; import { type TextBuffer, logicalPosToOffset, - PASTED_TEXT_PLACEHOLDER_REGEX, + expandPastePlaceholders, getTransformUnderCursor, LARGE_PASTE_LINE_THRESHOLD, LARGE_PASTE_CHAR_THRESHOLD, @@ -346,10 +346,9 @@ export const InputPrompt: React.FC = ({ (submittedValue: string) => { let processedValue = submittedValue; if (buffer.pastedContent) { - // Replace placeholders like [Pasted Text: 6 lines] with actual content - processedValue = processedValue.replace( - PASTED_TEXT_PLACEHOLDER_REGEX, - (match) => buffer.pastedContent[match] || match, + processedValue = expandPastePlaceholders( + processedValue, + buffer.pastedContent, ); } diff --git a/packages/cli/src/ui/components/shared/TextInput.test.tsx b/packages/cli/src/ui/components/shared/TextInput.test.tsx index f12714e288..7e802bbbe3 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('./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), }; @@ -82,6 +84,7 @@ describe('TextInput', () => { cursor: [0, 0], visualCursor: [0, 0], viewportVisualLines: [''], + pastedContent: {} as Record, handleInput: vi.fn((key) => { if (key.sequence) { buffer.text += key.sequence; @@ -298,6 +301,58 @@ describe('TextInput', () => { unmount(); }); + it('expands paste placeholder to real content on submit', async () => { + const placeholder = '[Pasted Text: 6 lines]'; + const realContent = 'line1\nline2\nline3\nline4\nline5\nline6'; + mockBuffer.setText(placeholder); + mockBuffer.pastedContent = { [placeholder]: realContent }; + 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(realContent); + unmount(); + }); + + it('submits text unchanged when pastedContent is empty', async () => { + mockBuffer.setText('normal text'); + mockBuffer.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('normal text'); + unmount(); + }); + it('calls onCancel on escape', async () => { vi.useFakeTimers(); const { waitUntilReady, unmount } = render( diff --git a/packages/cli/src/ui/components/shared/TextInput.tsx b/packages/cli/src/ui/components/shared/TextInput.tsx index 40f44cda53..8a4745eea7 100644 --- a/packages/cli/src/ui/components/shared/TextInput.tsx +++ b/packages/cli/src/ui/components/shared/TextInput.tsx @@ -12,6 +12,7 @@ import { useKeypress } from '../../hooks/useKeypress.js'; import chalk from 'chalk'; import { theme } from '../../semantic-colors.js'; import type { TextBuffer } from './text-buffer.js'; +import { expandPastePlaceholders } from './text-buffer.js'; import { cpSlice, cpIndexToOffset } from '../../utils/textUtils.js'; import { keyMatchers, Command } from '../../keyMatchers.js'; @@ -47,14 +48,14 @@ export function TextInput({ } if (keyMatchers[Command.SUBMIT](key) && onSubmit) { - onSubmit(text); + onSubmit(expandPastePlaceholders(text, buffer.pastedContent)); return true; } const handled = handleInput(key); return handled; }, - [handleInput, onCancel, onSubmit, text], + [handleInput, onCancel, onSubmit, text, buffer.pastedContent], ); useKeypress(handleKeyPress, { isActive: focus, priority: true }); diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index 71ee40b642..34d757a61b 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -38,6 +38,17 @@ export const LARGE_PASTE_CHAR_THRESHOLD = 500; export const PASTED_TEXT_PLACEHOLDER_REGEX = /\[Pasted Text: \d+ (?:lines|chars)(?: #\d+)?\]/g; +// Replace paste placeholder strings with their actual pasted content. +export function expandPastePlaceholders( + text: string, + pastedContent: Record, +): string { + return text.replace( + PASTED_TEXT_PLACEHOLDER_REGEX, + (match) => pastedContent[match] || match, + ); +} + export type Direction = | 'left' | 'right' @@ -3086,10 +3097,7 @@ export function useTextBuffer({ const tmpDir = fs.mkdtempSync(pathMod.join(os.tmpdir(), 'gemini-edit-')); const filePath = pathMod.join(tmpDir, 'buffer.txt'); // Expand paste placeholders so user sees full content in editor - const expandedText = text.replace( - PASTED_TEXT_PLACEHOLDER_REGEX, - (match) => pastedContent[match] || match, - ); + const expandedText = expandPastePlaceholders(text, pastedContent); fs.writeFileSync(filePath, expandedText, 'utf8'); dispatch({ type: 'create_undo_snapshot' });