Skip to content

Commit ee1b395

Browse files
prevent auto-execute on paste and preserve multi-line content in chat input (google-gemini#5834)
Co-authored-by: HYPERXD <[email protected]> Co-authored-by: Allen Hutchison <[email protected]>
1 parent 8190400 commit ee1b395

File tree

2 files changed

+171
-9
lines changed

2 files changed

+171
-9
lines changed

packages/cli/src/ui/components/InputPrompt.test.tsx

Lines changed: 145 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,16 @@ import type { UseCommandCompletionReturn } from '../hooks/useCommandCompletion.j
2020
import { useCommandCompletion } from '../hooks/useCommandCompletion.js';
2121
import type { UseInputHistoryReturn } from '../hooks/useInputHistory.js';
2222
import { useInputHistory } from '../hooks/useInputHistory.js';
23+
import type { UseReverseSearchCompletionReturn } from '../hooks/useReverseSearchCompletion.js';
24+
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
2325
import * as clipboardUtils from '../utils/clipboardUtils.js';
2426
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
2527
import chalk from 'chalk';
2628

2729
vi.mock('../hooks/useShellHistory.js');
2830
vi.mock('../hooks/useCommandCompletion.js');
2931
vi.mock('../hooks/useInputHistory.js');
32+
vi.mock('../hooks/useReverseSearchCompletion.js');
3033
vi.mock('../utils/clipboardUtils.js');
3134

3235
const mockSlashCommands: SlashCommand[] = [
@@ -82,12 +85,16 @@ describe('InputPrompt', () => {
8285
let mockShellHistory: UseShellHistoryReturn;
8386
let mockCommandCompletion: UseCommandCompletionReturn;
8487
let mockInputHistory: UseInputHistoryReturn;
88+
let mockReverseSearchCompletion: UseReverseSearchCompletionReturn;
8589
let mockBuffer: TextBuffer;
8690
let mockCommandContext: CommandContext;
8791

8892
const mockedUseShellHistory = vi.mocked(useShellHistory);
8993
const mockedUseCommandCompletion = vi.mocked(useCommandCompletion);
9094
const mockedUseInputHistory = vi.mocked(useInputHistory);
95+
const mockedUseReverseSearchCompletion = vi.mocked(
96+
useReverseSearchCompletion,
97+
);
9198

9299
beforeEach(() => {
93100
vi.resetAllMocks();
@@ -168,6 +175,21 @@ describe('InputPrompt', () => {
168175
};
169176
mockedUseInputHistory.mockReturnValue(mockInputHistory);
170177

178+
mockReverseSearchCompletion = {
179+
suggestions: [],
180+
activeSuggestionIndex: -1,
181+
visibleStartIndex: 0,
182+
showSuggestions: false,
183+
isLoadingSuggestions: false,
184+
navigateUp: vi.fn(),
185+
navigateDown: vi.fn(),
186+
handleAutocomplete: vi.fn(),
187+
resetCompletionState: vi.fn(),
188+
};
189+
mockedUseReverseSearchCompletion.mockReturnValue(
190+
mockReverseSearchCompletion,
191+
);
192+
171193
props = {
172194
buffer: mockBuffer,
173195
onSubmit: vi.fn(),
@@ -1375,6 +1397,77 @@ describe('InputPrompt', () => {
13751397
});
13761398
});
13771399

1400+
describe('paste auto-submission protection', () => {
1401+
it('should prevent auto-submission immediately after paste with newlines', async () => {
1402+
const { stdin, unmount } = renderWithProviders(
1403+
<InputPrompt {...props} />,
1404+
);
1405+
await wait();
1406+
1407+
// First type some text manually
1408+
stdin.write('test command');
1409+
await wait();
1410+
1411+
// Simulate a paste operation (this should set the paste protection)
1412+
stdin.write(`\x1b[200~\npasted content\x1b[201~`);
1413+
await wait();
1414+
1415+
// Simulate an Enter key press immediately after paste
1416+
stdin.write('\r');
1417+
await wait();
1418+
1419+
// Verify that onSubmit was NOT called due to recent paste protection
1420+
expect(props.onSubmit).not.toHaveBeenCalled();
1421+
1422+
unmount();
1423+
});
1424+
1425+
it('should allow submission after paste protection timeout', async () => {
1426+
// Set up buffer with text for submission
1427+
props.buffer.text = 'test command';
1428+
1429+
const { stdin, unmount } = renderWithProviders(
1430+
<InputPrompt {...props} />,
1431+
);
1432+
await wait();
1433+
1434+
// Simulate a paste operation (this sets the protection)
1435+
stdin.write(`\x1b[200~\npasted\x1b[201~`);
1436+
await wait();
1437+
1438+
// Wait for the protection timeout to naturally expire
1439+
await new Promise((resolve) => setTimeout(resolve, 600));
1440+
1441+
// Now Enter should work normally
1442+
stdin.write('\r');
1443+
await wait();
1444+
1445+
// Verify that onSubmit was called after the timeout
1446+
expect(props.onSubmit).toHaveBeenCalledWith('test command');
1447+
1448+
unmount();
1449+
});
1450+
1451+
it('should not interfere with normal Enter key submission when no recent paste', async () => {
1452+
// Set up buffer with text before rendering to ensure submission works
1453+
props.buffer.text = 'normal command';
1454+
1455+
const { stdin, unmount } = renderWithProviders(
1456+
<InputPrompt {...props} />,
1457+
);
1458+
await wait();
1459+
1460+
// Press Enter without any recent paste
1461+
stdin.write('\r');
1462+
await wait();
1463+
1464+
// Verify that onSubmit was called normally
1465+
expect(props.onSubmit).toHaveBeenCalledWith('normal command');
1466+
1467+
unmount();
1468+
});
1469+
});
1470+
13781471
describe('enhanced input UX - double ESC clear functionality', () => {
13791472
it('should clear buffer on second ESC press', async () => {
13801473
const onEscapePromptChange = vi.fn();
@@ -1502,12 +1595,27 @@ describe('InputPrompt', () => {
15021595
});
15031596

15041597
it('invokes reverse search on Ctrl+R', async () => {
1598+
// Mock the reverse search completion to return suggestions
1599+
mockedUseReverseSearchCompletion.mockReturnValue({
1600+
...mockReverseSearchCompletion,
1601+
suggestions: [
1602+
{ label: 'echo hello', value: 'echo hello' },
1603+
{ label: 'echo world', value: 'echo world' },
1604+
{ label: 'ls', value: 'ls' },
1605+
],
1606+
showSuggestions: true,
1607+
activeSuggestionIndex: 0,
1608+
});
1609+
15051610
const { stdin, stdout, unmount } = renderWithProviders(
15061611
<InputPrompt {...props} />,
15071612
);
15081613
await wait();
15091614

1510-
stdin.write('\x12');
1615+
// Trigger reverse search with Ctrl+R
1616+
act(() => {
1617+
stdin.write('\x12');
1618+
});
15111619
await wait();
15121620

15131621
const frame = stdout.lastFrame();
@@ -1539,6 +1647,27 @@ describe('InputPrompt', () => {
15391647
});
15401648

15411649
it('completes the highlighted entry on Tab and exits reverse-search', async () => {
1650+
// Mock the reverse search completion
1651+
const mockHandleAutocomplete = vi.fn(() => {
1652+
props.buffer.setText('echo hello');
1653+
});
1654+
1655+
mockedUseReverseSearchCompletion.mockImplementation(
1656+
(buffer, shellHistory, reverseSearchActive) => ({
1657+
...mockReverseSearchCompletion,
1658+
suggestions: reverseSearchActive
1659+
? [
1660+
{ label: 'echo hello', value: 'echo hello' },
1661+
{ label: 'echo world', value: 'echo world' },
1662+
{ label: 'ls', value: 'ls' },
1663+
]
1664+
: [],
1665+
showSuggestions: reverseSearchActive,
1666+
activeSuggestionIndex: reverseSearchActive ? 0 : -1,
1667+
handleAutocomplete: mockHandleAutocomplete,
1668+
}),
1669+
);
1670+
15421671
const { stdin, stdout, unmount } = renderWithProviders(
15431672
<InputPrompt {...props} />,
15441673
);
@@ -1556,19 +1685,26 @@ describe('InputPrompt', () => {
15561685
act(() => {
15571686
stdin.write('\t');
15581687
});
1688+
await wait();
15591689

1560-
await waitFor(
1561-
() => {
1562-
expect(stdout.lastFrame()).not.toContain('(r:)');
1563-
},
1564-
{ timeout: 5000 },
1565-
); // Increase timeout
1566-
1690+
expect(mockHandleAutocomplete).toHaveBeenCalledWith(0);
15671691
expect(props.buffer.setText).toHaveBeenCalledWith('echo hello');
15681692
unmount();
1569-
});
1693+
}, 15000);
15701694

15711695
it('submits the highlighted entry on Enter and exits reverse-search', async () => {
1696+
// Mock the reverse search completion to return suggestions
1697+
mockedUseReverseSearchCompletion.mockReturnValue({
1698+
...mockReverseSearchCompletion,
1699+
suggestions: [
1700+
{ label: 'echo hello', value: 'echo hello' },
1701+
{ label: 'echo world', value: 'echo world' },
1702+
{ label: 'ls', value: 'ls' },
1703+
],
1704+
showSuggestions: true,
1705+
activeSuggestionIndex: 0,
1706+
});
1707+
15721708
const { stdin, stdout, unmount } = renderWithProviders(
15731709
<InputPrompt {...props} />,
15741710
);

packages/cli/src/ui/components/InputPrompt.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
7171
const [escPressCount, setEscPressCount] = useState(0);
7272
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
7373
const escapeTimerRef = useRef<NodeJS.Timeout | null>(null);
74+
const [recentPasteTime, setRecentPasteTime] = useState<number | null>(null);
75+
const pasteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
7476

7577
const [dirs, setDirs] = useState<readonly string[]>(
7678
config.getWorkspaceContext().getDirectories(),
@@ -130,6 +132,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
130132
if (escapeTimerRef.current) {
131133
clearTimeout(escapeTimerRef.current);
132134
}
135+
if (pasteTimeoutRef.current) {
136+
clearTimeout(pasteTimeoutRef.current);
137+
}
133138
},
134139
[],
135140
);
@@ -245,6 +250,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
245250
}
246251

247252
if (key.paste) {
253+
// Record paste time to prevent accidental auto-submission
254+
setRecentPasteTime(Date.now());
255+
256+
// Clear any existing paste timeout
257+
if (pasteTimeoutRef.current) {
258+
clearTimeout(pasteTimeoutRef.current);
259+
}
260+
261+
// Clear the paste protection after a safe delay
262+
pasteTimeoutRef.current = setTimeout(() => {
263+
setRecentPasteTime(null);
264+
pasteTimeoutRef.current = null;
265+
}, 500);
266+
248267
// Ensure we never accidentally interpret paste as regular input.
249268
buffer.handleInput(key);
250269
return;
@@ -460,6 +479,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
460479

461480
if (keyMatchers[Command.SUBMIT](key)) {
462481
if (buffer.text.trim()) {
482+
// Check if a paste operation occurred recently to prevent accidental auto-submission
483+
if (recentPasteTime !== null) {
484+
// Paste occurred recently, ignore this submit to prevent auto-execution
485+
return;
486+
}
487+
463488
const [row, col] = buffer.cursor;
464489
const line = buffer.lines[row];
465490
const charBefore = col > 0 ? cpSlice(line, col - 1, col) : '';
@@ -558,6 +583,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
558583
reverseSearchActive,
559584
textBeforeReverseSearch,
560585
cursorPosition,
586+
recentPasteTime,
561587
],
562588
);
563589

0 commit comments

Comments
 (0)