fix: expand paste placeholders in TextInput on submit (#19946)

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
Jeffrey Ying
2026-03-06 22:29:38 -05:00
committed by GitHub
parent 9a7427197b
commit 0fd09e0150
7 changed files with 149 additions and 21 deletions

View File

@@ -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(() => ({

View File

@@ -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(
<AskUserDialog
questions={questions}
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
/>,
{ 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}`,
});
});
});
});

View File

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

View File

@@ -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<InputPromptProps> = ({
(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,
);
}

View File

@@ -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<typeof import('./text-buffer.js')>();
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<string, string>,
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(
<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(realContent);
unmount();
});
it('submits text unchanged when pastedContent is empty', async () => {
mockBuffer.setText('normal text');
mockBuffer.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('normal text');
unmount();
});
it('calls onCancel on escape', async () => {
vi.useFakeTimers();
const { waitUntilReady, unmount } = render(

View File

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

View File

@@ -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, string>,
): 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' });