From 6553e644313b6d3ee271589bcf4bc3edf3550deb Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Wed, 1 Oct 2025 15:21:57 -0700 Subject: [PATCH] Fix so paste timeout protection is much less invasive. (#9284) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../src/ui/components/InputPrompt.test.tsx | 99 ++++++++++++++----- .../cli/src/ui/components/InputPrompt.tsx | 65 +++++++++--- 2 files changed, 125 insertions(+), 39 deletions(-) diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index f954bf8393..4c4580b18a 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -14,7 +14,7 @@ import { ApprovalMode } from '@google/gemini-cli-core'; import * as path from 'node:path'; import type { CommandContext, SlashCommand } from '../commands/types.js'; import { CommandKind } from '../commands/types.js'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import type { UseShellHistoryReturn } from '../hooks/useShellHistory.js'; import { useShellHistory } from '../hooks/useShellHistory.js'; import type { UseCommandCompletionReturn } from '../hooks/useCommandCompletion.js'; @@ -24,6 +24,7 @@ import { useInputHistory } from '../hooks/useInputHistory.js'; import type { UseReverseSearchCompletionReturn } from '../hooks/useReverseSearchCompletion.js'; import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js'; import * as clipboardUtils from '../utils/clipboardUtils.js'; +import { useKittyKeyboardProtocol } from '../hooks/useKittyKeyboardProtocol.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import stripAnsi from 'strip-ansi'; import chalk from 'chalk'; @@ -33,6 +34,7 @@ vi.mock('../hooks/useCommandCompletion.js'); vi.mock('../hooks/useInputHistory.js'); vi.mock('../hooks/useReverseSearchCompletion.js'); vi.mock('../utils/clipboardUtils.js'); +vi.mock('../hooks/useKittyKeyboardProtocol.js'); const mockSlashCommands: SlashCommand[] = [ { @@ -97,6 +99,7 @@ describe('InputPrompt', () => { const mockedUseReverseSearchCompletion = vi.mocked( useReverseSearchCompletion, ); + const mockedUseKittyKeyboardProtocol = vi.mocked(useKittyKeyboardProtocol); beforeEach(() => { vi.resetAllMocks(); @@ -194,6 +197,10 @@ describe('InputPrompt', () => { mockReverseSearchCompletion, ); + mockedUseKittyKeyboardProtocol.mockReturnValue({ + supported: false, + }); + props = { buffer: mockBuffer, onSubmit: vi.fn(), @@ -1531,56 +1538,100 @@ describe('InputPrompt', () => { }); describe('paste auto-submission protection', () => { - it('should prevent auto-submission immediately after paste with newlines', async () => { + beforeEach(() => { + vi.useFakeTimers(); + mockedUseKittyKeyboardProtocol.mockReturnValue({ supported: false }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should prevent auto-submission immediately after an unsafe paste', async () => { + // isTerminalPasteTrusted will be false due to beforeEach setup. + props.buffer.text = 'some command'; + const { stdin, unmount } = renderWithProviders( , ); - await wait(); - - // First type some text manually - stdin.write('test command'); - await wait(); + await vi.runAllTimersAsync(); // Simulate a paste operation (this should set the paste protection) - stdin.write(`\x1b[200~\npasted content\x1b[201~`); - await wait(); + stdin.write(`\x1b[200~pasted content\x1b[201~`); + await vi.runAllTimersAsync(); // Simulate an Enter key press immediately after paste stdin.write('\r'); - await wait(); + await vi.runAllTimersAsync(); // Verify that onSubmit was NOT called due to recent paste protection expect(props.onSubmit).not.toHaveBeenCalled(); - + // It should call newline() instead + expect(props.buffer.newline).toHaveBeenCalled(); unmount(); }); - it('should allow submission after paste protection timeout', async () => { - // Set up buffer with text for submission - props.buffer.text = 'test command'; + it('should allow submission after unsafe paste protection timeout', async () => { + // isTerminalPasteTrusted will be false due to beforeEach setup. + props.buffer.text = 'pasted text'; const { stdin, unmount } = renderWithProviders( , ); - await wait(); + await vi.runAllTimersAsync(); // Simulate a paste operation (this sets the protection) - stdin.write(`\x1b[200~\npasted\x1b[201~`); - await wait(); + act(() => { + stdin.write('\x1b[200~pasted text\x1b[201~'); + }); + await vi.runAllTimersAsync(); - // Wait for the protection timeout to naturally expire - await new Promise((resolve) => setTimeout(resolve, 600)); + // Advance timers past the protection timeout + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); // Now Enter should work normally stdin.write('\r'); - await wait(); + await vi.runAllTimersAsync(); - // Verify that onSubmit was called after the timeout - expect(props.onSubmit).toHaveBeenCalledWith('test command'); + expect(props.onSubmit).toHaveBeenCalledWith('pasted text'); + expect(props.buffer.newline).not.toHaveBeenCalled(); unmount(); }); + it.each([ + { + name: 'kitty', + setup: () => + mockedUseKittyKeyboardProtocol.mockReturnValue({ supported: true }), + }, + ])( + 'should allow immediate submission for a trusted paste ($name)', + async ({ setup }) => { + setup(); + props.buffer.text = 'pasted command'; + + const { stdin, unmount } = renderWithProviders( + , + ); + await vi.runAllTimersAsync(); + + // Simulate a paste operation + stdin.write('\x1b[200~some pasted stuff\x1b[201~'); + await vi.runAllTimersAsync(); + + // Simulate an Enter key press immediately after paste + stdin.write('\r'); + await vi.runAllTimersAsync(); + + // Verify that onSubmit was called + expect(props.onSubmit).toHaveBeenCalledWith('pasted command'); + unmount(); + }, + ); + it('should not interfere with normal Enter key submission when no recent paste', async () => { // Set up buffer with text before rendering to ensure submission works props.buffer.text = 'normal command'; @@ -1588,11 +1639,11 @@ describe('InputPrompt', () => { const { stdin, unmount } = renderWithProviders( , ); - await wait(); + await vi.runAllTimersAsync(); // Press Enter without any recent paste stdin.write('\r'); - await wait(); + await vi.runAllTimersAsync(); // Verify that onSubmit was called normally expect(props.onSubmit).toHaveBeenCalledWith('normal command'); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 1ab76f8385..c0ac908e20 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -28,6 +28,7 @@ import { parseInputForHighlighting, buildSegmentsForVisualSlice, } from '../utils/highlight.js'; +import { useKittyKeyboardProtocol } from '../hooks/useKittyKeyboardProtocol.js'; import { clipboardHasImage, saveClipboardImage, @@ -36,6 +37,21 @@ import { import * as path from 'node:path'; import { SCREEN_READER_USER_PREFIX } from '../textConstants.js'; import { useShellFocusState } from '../contexts/ShellFocusContext.js'; + +/** + * Returns if the terminal can be trusted to handle paste events atomically + * rather than potentially sending multiple paste events separated by line + * breaks which could trigger unintended command execution. + */ +export function isTerminalPasteTrusted( + kittyProtocolSupported: boolean, +): boolean { + // Ideally we could trust all VSCode family terminals as well but it appears + // we cannot as Cursor users on windows reported being impacted by this + // issue (https://github.com/google-gemini/gemini-cli/issues/3763). + return kittyProtocolSupported; +} + export interface InputPromptProps { buffer: TextBuffer; onSubmit: (value: string) => void; @@ -100,12 +116,15 @@ export const InputPrompt: React.FC = ({ vimHandleInput, isEmbeddedShellFocused, }) => { + const kittyProtocol = useKittyKeyboardProtocol(); const isShellFocused = useShellFocusState(); const [justNavigatedHistory, setJustNavigatedHistory] = useState(false); const [escPressCount, setEscPressCount] = useState(0); const [showEscapePrompt, setShowEscapePrompt] = useState(false); const escapeTimerRef = useRef(null); - const [recentPasteTime, setRecentPasteTime] = useState(null); + const [recentUnsafePasteTime, setRecentUnsafePasteTime] = useState< + number | null + >(null); const pasteTimeoutRef = useRef(null); const [dirs, setDirs] = useState( @@ -305,19 +324,28 @@ export const InputPrompt: React.FC = ({ if (key.paste) { // Record paste time to prevent accidental auto-submission - setRecentPasteTime(Date.now()); + if (!isTerminalPasteTrusted(kittyProtocol.supported)) { + setRecentUnsafePasteTime(Date.now()); - // Clear any existing paste timeout - if (pasteTimeoutRef.current) { - clearTimeout(pasteTimeoutRef.current); + // Clear any existing paste timeout + if (pasteTimeoutRef.current) { + clearTimeout(pasteTimeoutRef.current); + } + + // Clear the paste protection after a very short delay to prevent + // false positives. + // Due to how we use a reducer for text buffer state updates, it is + // reasonable to expect that key events that are really part of the + // same paste will be processed in the same event loop tick. 40ms + // is chosen arbitrarily as it is faster than a typical human + // could go from pressing paste to pressing enter. The fastest typists + // can type at 200 words per minute which roughly translates to 50ms + // per letter. + pasteTimeoutRef.current = setTimeout(() => { + setRecentUnsafePasteTime(null); + pasteTimeoutRef.current = null; + }, 40); } - - // Clear the paste protection after a safe delay - pasteTimeoutRef.current = setTimeout(() => { - setRecentPasteTime(null); - pasteTimeoutRef.current = null; - }, 500); - // Ensure we never accidentally interpret paste as regular input. buffer.handleInput(key); return; @@ -586,8 +614,14 @@ export const InputPrompt: React.FC = ({ if (keyMatchers[Command.SUBMIT](key)) { if (buffer.text.trim()) { // Check if a paste operation occurred recently to prevent accidental auto-submission - if (recentPasteTime !== null) { - // Paste occurred recently, ignore this submit to prevent auto-execution + if (recentUnsafePasteTime !== null) { + // Paste occurred recently in a terminal where we don't trust pastes + // to be reported correctly so assume this paste was really a + // newline that was part of the paste. + // This has the added benefit that in the worst case at least users + // get some feedback that their keypress was handled rather than + // wondering why it was completey ignored. + buffer.newline(); return; } @@ -690,9 +724,10 @@ export const InputPrompt: React.FC = ({ reverseSearchActive, textBeforeReverseSearch, cursorPosition, - recentPasteTime, + recentUnsafePasteTime, commandSearchActive, commandSearchCompletion, + kittyProtocol.supported, ], );