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

View File

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

View File

@@ -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, string>,
): 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<TextQuestionViewProps> = ({
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<ChoiceQuestionViewProps> = ({
}
});
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<ChoiceQuestionViewProps> = ({
} 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<ChoiceQuestionViewProps> = ({
selectedIndices,
isCustomOptionSelected,
customOptionText,
customBuffer.pastedContent,
onAnswer,
buildAnswerString,
],

View File

@@ -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<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();
});
});

View File

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