Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions packages/cli/src/ui/auth/ApiAuthDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => ({
Expand Down
43 changes: 43 additions & 0 deletions packages/cli/src/ui/components/AskUserDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
});
});
});
});
27 changes: 21 additions & 6 deletions packages/cli/src/ui/components/AskUserDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(),
);
}
}
}
Expand All @@ -768,6 +782,7 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
selectedIndices,
isCustomOptionSelected,
customOptionText,
customBuffer.pastedContent,
onAnswer,
buildAnswerString,
],
Expand Down
9 changes: 4 additions & 5 deletions packages/cli/src/ui/components/InputPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
);
}

Expand Down
57 changes: 56 additions & 1 deletion packages/cli/src/ui/components/shared/TextInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: [''],
Expand Down Expand Up @@ -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),
};
Expand All @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/ui/components/shared/TextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 });
Expand Down
16 changes: 12 additions & 4 deletions packages/cli/src/ui/components/shared/text-buffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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' });
Expand Down
Loading