From ee1b395c5c088c9821e06c52b30be0123fb9f003 Mon Sep 17 00:00:00 2001 From: HYPERXD <72203904+Alish-0x@users.noreply.github.com> Date: Sat, 6 Sep 2025 07:38:53 +0545 Subject: [PATCH] prevent auto-execute on paste and preserve multi-line content in chat input (#5834) Co-authored-by: HYPERXD Co-authored-by: Allen Hutchison --- .../src/ui/components/InputPrompt.test.tsx | 154 +++++++++++++++++- .../cli/src/ui/components/InputPrompt.tsx | 26 +++ 2 files changed, 171 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index d2a08d8fcc..5c6fddecec 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -20,6 +20,8 @@ import type { UseCommandCompletionReturn } from '../hooks/useCommandCompletion.j import { useCommandCompletion } from '../hooks/useCommandCompletion.js'; import type { UseInputHistoryReturn } from '../hooks/useInputHistory.js'; 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 { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import chalk from 'chalk'; @@ -27,6 +29,7 @@ import chalk from 'chalk'; vi.mock('../hooks/useShellHistory.js'); vi.mock('../hooks/useCommandCompletion.js'); vi.mock('../hooks/useInputHistory.js'); +vi.mock('../hooks/useReverseSearchCompletion.js'); vi.mock('../utils/clipboardUtils.js'); const mockSlashCommands: SlashCommand[] = [ @@ -82,12 +85,16 @@ describe('InputPrompt', () => { let mockShellHistory: UseShellHistoryReturn; let mockCommandCompletion: UseCommandCompletionReturn; let mockInputHistory: UseInputHistoryReturn; + let mockReverseSearchCompletion: UseReverseSearchCompletionReturn; let mockBuffer: TextBuffer; let mockCommandContext: CommandContext; const mockedUseShellHistory = vi.mocked(useShellHistory); const mockedUseCommandCompletion = vi.mocked(useCommandCompletion); const mockedUseInputHistory = vi.mocked(useInputHistory); + const mockedUseReverseSearchCompletion = vi.mocked( + useReverseSearchCompletion, + ); beforeEach(() => { vi.resetAllMocks(); @@ -168,6 +175,21 @@ describe('InputPrompt', () => { }; mockedUseInputHistory.mockReturnValue(mockInputHistory); + mockReverseSearchCompletion = { + suggestions: [], + activeSuggestionIndex: -1, + visibleStartIndex: 0, + showSuggestions: false, + isLoadingSuggestions: false, + navigateUp: vi.fn(), + navigateDown: vi.fn(), + handleAutocomplete: vi.fn(), + resetCompletionState: vi.fn(), + }; + mockedUseReverseSearchCompletion.mockReturnValue( + mockReverseSearchCompletion, + ); + props = { buffer: mockBuffer, onSubmit: vi.fn(), @@ -1375,6 +1397,77 @@ describe('InputPrompt', () => { }); }); + describe('paste auto-submission protection', () => { + it('should prevent auto-submission immediately after paste with newlines', async () => { + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + // First type some text manually + stdin.write('test command'); + await wait(); + + // Simulate a paste operation (this should set the paste protection) + stdin.write(`\x1b[200~\npasted content\x1b[201~`); + await wait(); + + // Simulate an Enter key press immediately after paste + stdin.write('\r'); + await wait(); + + // Verify that onSubmit was NOT called due to recent paste protection + expect(props.onSubmit).not.toHaveBeenCalled(); + + unmount(); + }); + + it('should allow submission after paste protection timeout', async () => { + // Set up buffer with text for submission + props.buffer.text = 'test command'; + + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + // Simulate a paste operation (this sets the protection) + stdin.write(`\x1b[200~\npasted\x1b[201~`); + await wait(); + + // Wait for the protection timeout to naturally expire + await new Promise((resolve) => setTimeout(resolve, 600)); + + // Now Enter should work normally + stdin.write('\r'); + await wait(); + + // Verify that onSubmit was called after the timeout + expect(props.onSubmit).toHaveBeenCalledWith('test 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'; + + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + // Press Enter without any recent paste + stdin.write('\r'); + await wait(); + + // Verify that onSubmit was called normally + expect(props.onSubmit).toHaveBeenCalledWith('normal command'); + + unmount(); + }); + }); + describe('enhanced input UX - double ESC clear functionality', () => { it('should clear buffer on second ESC press', async () => { const onEscapePromptChange = vi.fn(); @@ -1502,12 +1595,27 @@ describe('InputPrompt', () => { }); it('invokes reverse search on Ctrl+R', async () => { + // Mock the reverse search completion to return suggestions + mockedUseReverseSearchCompletion.mockReturnValue({ + ...mockReverseSearchCompletion, + suggestions: [ + { label: 'echo hello', value: 'echo hello' }, + { label: 'echo world', value: 'echo world' }, + { label: 'ls', value: 'ls' }, + ], + showSuggestions: true, + activeSuggestionIndex: 0, + }); + const { stdin, stdout, unmount } = renderWithProviders( , ); await wait(); - stdin.write('\x12'); + // Trigger reverse search with Ctrl+R + act(() => { + stdin.write('\x12'); + }); await wait(); const frame = stdout.lastFrame(); @@ -1539,6 +1647,27 @@ describe('InputPrompt', () => { }); it('completes the highlighted entry on Tab and exits reverse-search', async () => { + // Mock the reverse search completion + const mockHandleAutocomplete = vi.fn(() => { + props.buffer.setText('echo hello'); + }); + + mockedUseReverseSearchCompletion.mockImplementation( + (buffer, shellHistory, reverseSearchActive) => ({ + ...mockReverseSearchCompletion, + suggestions: reverseSearchActive + ? [ + { label: 'echo hello', value: 'echo hello' }, + { label: 'echo world', value: 'echo world' }, + { label: 'ls', value: 'ls' }, + ] + : [], + showSuggestions: reverseSearchActive, + activeSuggestionIndex: reverseSearchActive ? 0 : -1, + handleAutocomplete: mockHandleAutocomplete, + }), + ); + const { stdin, stdout, unmount } = renderWithProviders( , ); @@ -1556,19 +1685,26 @@ describe('InputPrompt', () => { act(() => { stdin.write('\t'); }); + await wait(); - await waitFor( - () => { - expect(stdout.lastFrame()).not.toContain('(r:)'); - }, - { timeout: 5000 }, - ); // Increase timeout - + expect(mockHandleAutocomplete).toHaveBeenCalledWith(0); expect(props.buffer.setText).toHaveBeenCalledWith('echo hello'); unmount(); - }); + }, 15000); it('submits the highlighted entry on Enter and exits reverse-search', async () => { + // Mock the reverse search completion to return suggestions + mockedUseReverseSearchCompletion.mockReturnValue({ + ...mockReverseSearchCompletion, + suggestions: [ + { label: 'echo hello', value: 'echo hello' }, + { label: 'echo world', value: 'echo world' }, + { label: 'ls', value: 'ls' }, + ], + showSuggestions: true, + activeSuggestionIndex: 0, + }); + const { stdin, stdout, unmount } = renderWithProviders( , ); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 58b19e4f8d..dc03b4bea5 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -71,6 +71,8 @@ export const InputPrompt: React.FC = ({ const [escPressCount, setEscPressCount] = useState(0); const [showEscapePrompt, setShowEscapePrompt] = useState(false); const escapeTimerRef = useRef(null); + const [recentPasteTime, setRecentPasteTime] = useState(null); + const pasteTimeoutRef = useRef(null); const [dirs, setDirs] = useState( config.getWorkspaceContext().getDirectories(), @@ -130,6 +132,9 @@ export const InputPrompt: React.FC = ({ if (escapeTimerRef.current) { clearTimeout(escapeTimerRef.current); } + if (pasteTimeoutRef.current) { + clearTimeout(pasteTimeoutRef.current); + } }, [], ); @@ -245,6 +250,20 @@ export const InputPrompt: React.FC = ({ } if (key.paste) { + // Record paste time to prevent accidental auto-submission + setRecentPasteTime(Date.now()); + + // Clear any existing paste timeout + if (pasteTimeoutRef.current) { + clearTimeout(pasteTimeoutRef.current); + } + + // 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; @@ -460,6 +479,12 @@ 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 + return; + } + const [row, col] = buffer.cursor; const line = buffer.lines[row]; const charBefore = col > 0 ? cpSlice(line, col - 1, col) : ''; @@ -558,6 +583,7 @@ export const InputPrompt: React.FC = ({ reverseSearchActive, textBeforeReverseSearch, cursorPosition, + recentPasteTime, ], );