mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-26 21:14:35 -07:00
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:
@@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user