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' });