fix(cli): expand paste placeholders in AskUserDialog submissions

The AskUserDialog was submitting raw paste placeholder text like
'[Pasted Text: 55 lines]' instead of the actual pasted content.
InputPrompt already handled this expansion, but AskUserDialog's
TextQuestionView and ChoiceQuestionView did not.

Add expandPastePlaceholders helper and apply it to all submit and
selection-change paths in both question view types.
This commit is contained in:
Sandy Tao
2026-03-05 21:00:24 -08:00
parent 5575c5ff66
commit 32a0a8a2c8
4 changed files with 188 additions and 10 deletions
@@ -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(
<AskUserDialog
questions={textQuestion}
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
/>,
{ 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(
<AskUserDialog
questions={choiceQuestion}
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
/>,
{ 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 });
});
});
});
}); });
@@ -23,7 +23,10 @@ import { useKeypress, type Key } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js'; import { keyMatchers, Command } from '../keyMatchers.js';
import { checkExhaustive } from '@google/gemini-cli-core'; import { checkExhaustive } from '@google/gemini-cli-core';
import { TextInput } from './shared/TextInput.js'; 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 { getCachedStringWidth } from '../utils/textUtils.js';
import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js'; import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js';
import { DialogFooter } from './shared/DialogFooter.js'; import { DialogFooter } from './shared/DialogFooter.js';
@@ -81,6 +84,21 @@ function autoBoldIfPlain(text: string): string {
return text; 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, string>,
): string {
if (!pastedContent || Object.keys(pastedContent).length === 0) return text;
return text.replace(
PASTED_TEXT_PLACEHOLDER_REGEX,
(match) => pastedContent[match] || match,
);
}
interface AskUserDialogState { interface AskUserDialogState {
answers: { [key: string]: string }; answers: { [key: string]: string };
isEditingCustomOption: boolean; isEditingCustomOption: boolean;
@@ -302,10 +320,11 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
const lastTextValueRef = useRef(textValue); const lastTextValueRef = useRef(textValue);
useEffect(() => { useEffect(() => {
if (textValue !== lastTextValueRef.current) { if (textValue !== lastTextValueRef.current) {
onSelectionChange?.(textValue); const expanded = expandPastePlaceholders(textValue, buffer.pastedContent);
onSelectionChange?.(expanded);
lastTextValueRef.current = textValue; lastTextValueRef.current = textValue;
} }
}, [textValue, onSelectionChange]); }, [textValue, onSelectionChange, buffer.pastedContent]);
// Handle Ctrl+C to clear all text // Handle Ctrl+C to clear all text
const handleExtraKeys = useCallback( const handleExtraKeys = useCallback(
@@ -588,11 +607,15 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
} }
}); });
if (includeCustomOption && customOption.trim()) { if (includeCustomOption && customOption.trim()) {
answers.push(customOption.trim()); const expanded = expandPastePlaceholders(
customOption.trim(),
customBuffer.pastedContent,
);
answers.push(expanded);
} }
return answers.join(', '); return answers.join(', ');
}, },
[questionOptions], [questionOptions, customBuffer.pastedContent],
); );
// Synchronize selection changes with parent - only when it actually changes // Synchronize selection changes with parent - only when it actually changes
@@ -757,7 +780,11 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
} else if (itemValue.type === 'other') { } else if (itemValue.type === 'other') {
// In single select, selecting other submits it if it has text // In single select, selecting other submits it if it has text
if (customOptionText.trim()) { if (customOptionText.trim()) {
onAnswer(customOptionText.trim()); const expanded = expandPastePlaceholders(
customOptionText.trim(),
customBuffer.pastedContent,
);
onAnswer(expanded);
} }
} }
} }
@@ -767,6 +794,7 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
selectedIndices, selectedIndices,
isCustomOptionSelected, isCustomOptionSelected,
customOptionText, customOptionText,
customBuffer.pastedContent,
onAnswer, onAnswer,
buildAnswerString, buildAnswerString,
], ],
@@ -17,7 +17,8 @@ vi.mock('../../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(), useKeypress: vi.fn(),
})); }));
vi.mock('./text-buffer.js', () => { vi.mock(import('./text-buffer.js'), async (importOriginal) => {
const actual = await importOriginal();
const mockTextBuffer = { const mockTextBuffer = {
text: '', text: '',
lines: [''], lines: [''],
@@ -60,6 +61,7 @@ vi.mock('./text-buffer.js', () => {
}; };
return { return {
...actual,
useTextBuffer: vi.fn(() => mockTextBuffer as unknown as TextBuffer), useTextBuffer: vi.fn(() => mockTextBuffer as unknown as TextBuffer),
TextBuffer: 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'); expect(lastFrame()).toContain('line2');
unmount(); 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<string, string> }
).pastedContent = {
[placeholder]: actualContent,
};
const { waitUntilReady, unmount } = render(
<TextInput buffer={mockBuffer} onSubmit={onSubmit} onCancel={onCancel} />,
);
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<string, string> }
).pastedContent = {};
const { waitUntilReady, unmount } = render(
<TextInput buffer={mockBuffer} onSubmit={onSubmit} onCancel={onCancel} />,
);
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();
});
}); });
@@ -11,7 +11,10 @@ import { Text, Box } from 'ink';
import { useKeypress } from '../../hooks/useKeypress.js'; import { useKeypress } from '../../hooks/useKeypress.js';
import chalk from 'chalk'; import chalk from 'chalk';
import { theme } from '../../semantic-colors.js'; 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 { cpSlice, cpIndexToOffset } from '../../utils/textUtils.js';
import { keyMatchers, Command } from '../../keyMatchers.js'; import { keyMatchers, Command } from '../../keyMatchers.js';
@@ -47,14 +50,23 @@ export function TextInput({
} }
if (keyMatchers[Command.SUBMIT](key) && onSubmit) { 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; return true;
} }
const handled = handleInput(key); const handled = handleInput(key);
return handled; return handled;
}, },
[handleInput, onCancel, onSubmit, text], [handleInput, onCancel, onSubmit, text, buffer],
); );
useKeypress(handleKeyPress, { isActive: focus, priority: true }); useKeypress(handleKeyPress, { isActive: focus, priority: true });