From 747885950227fac8df996a199f61cf9de28abeb0 Mon Sep 17 00:00:00 2001 From: Rajesh patel <145205731+Rajeshpatel07@users.noreply.github.com> Date: Tue, 19 May 2026 05:47:55 +0530 Subject: [PATCH] fix(cli): Prevent unmapped keys in Vim Normal mode from inserting text into prompt Input. (#25139) Co-authored-by: Tommaso Sciortino --- packages/cli/src/ui/components/Composer.tsx | 2 + .../src/ui/components/InputPrompt.test.tsx | 54 ++++++++++++++ .../cli/src/ui/components/InputPrompt.tsx | 16 +++- packages/cli/src/ui/hooks/vim.test.tsx | 74 +++++++++++++++++++ packages/cli/src/ui/hooks/vim.ts | 5 ++ 5 files changed, 149 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 52bb2b294f..253380a449 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -154,6 +154,8 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { onEscapePromptChange={uiActions.onEscapePromptChange} focus={isFocused} vimHandleInput={uiActions.vimHandleInput} + vimEnabled={vimEnabled} + vimMode={vimMode} isEmbeddedShellFocused={uiState.embeddedShellFocused} popAllMessages={uiActions.popAllMessages} onQueueMessage={uiActions.addMessage} diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index af25023cd4..9413ae79a4 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -4898,6 +4898,60 @@ describe('InputPrompt', () => { unmount(); }); + it('should NOT open shortcuts help with ? in vim NORMAL mode', async () => { + const setShortcutsHelpVisible = vi.fn(); + const vimHandleInput = vi.fn().mockReturnValue(true); + + const { stdin, unmount } = await renderWithProviders( + , + { + uiActions: { setShortcutsHelpVisible }, + }, + ); + + await act(async () => { + stdin.write('?'); + }); + + expect(setShortcutsHelpVisible).not.toHaveBeenCalled(); + expect(vimHandleInput).toHaveBeenCalled(); + expect(mockBuffer.handleInput).not.toHaveBeenCalled(); + + unmount(); + }); + + it('should open shortcuts help with ? in vim INSERT mode', async () => { + const setShortcutsHelpVisible = vi.fn(); + const vimHandleInput = vi.fn().mockReturnValue(false); + + const { stdin, unmount } = await renderWithProviders( + , + { + uiActions: { setShortcutsHelpVisible }, + }, + ); + + await act(async () => { + stdin.write('?'); + }); + + await waitFor(() => { + expect(setShortcutsHelpVisible).toHaveBeenCalledWith(true); + }); + + unmount(); + }); + it.each([ { name: 'terminal paste event occurs', diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index cd37e56abd..511c4b6ceb 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -92,6 +92,7 @@ import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; import { useIsHelpDismissKey } from '../utils/shortcutsHelp.js'; import { useRepeatedKeyPress } from '../hooks/useRepeatedKeyPress.js'; import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; +import type { VimMode } from '../contexts/VimModeContext.js'; const SCROLLBAR_GUTTER_WIDTH = 1; @@ -126,6 +127,8 @@ export interface InputPromptProps { onEscapePromptChange?: (showPrompt: boolean) => void; onSuggestionsVisibilityChange?: (visible: boolean) => void; vimHandleInput?: (key: Key) => boolean; + vimEnabled?: boolean; + vimMode?: VimMode; isEmbeddedShellFocused?: boolean; setQueueErrorMessage: (message: string | null) => void; streamingState: StreamingState; @@ -214,6 +217,8 @@ export const InputPrompt: React.FC = ({ onEscapePromptChange, onSuggestionsVisibilityChange, vimHandleInput, + vimEnabled, + vimMode, isEmbeddedShellFocused, setQueueErrorMessage, streamingState, @@ -859,7 +864,11 @@ export const InputPrompt: React.FC = ({ } if (shortcutsHelpVisible) { - if (key.sequence === '?' && key.insertable) { + if ( + key.sequence === '?' && + key.insertable && + (!vimEnabled || vimMode === 'INSERT') + ) { setShortcutsHelpVisible(false); buffer.handleInput(key); return true; @@ -879,7 +888,8 @@ export const InputPrompt: React.FC = ({ key.sequence === '?' && key.insertable && !shortcutsHelpVisible && - buffer.text.length === 0 + buffer.text.length === 0 && + (!vimEnabled || vimMode === 'INSERT') ) { setShortcutsHelpVisible(true); return true; @@ -1374,6 +1384,8 @@ export const InputPrompt: React.FC = ({ resetCompletionState, resetEscapeState, vimHandleInput, + vimEnabled, + vimMode, reverseSearchActive, textBeforeReverseSearch, cursorPosition, diff --git a/packages/cli/src/ui/hooks/vim.test.tsx b/packages/cli/src/ui/hooks/vim.test.tsx index 93e140db18..0aadb69532 100644 --- a/packages/cli/src/ui/hooks/vim.test.tsx +++ b/packages/cli/src/ui/hooks/vim.test.tsx @@ -2258,6 +2258,80 @@ describe('useVim hook', async () => { }); }); + describe('should handle unmapped keys in Normal mode', () => { + type UnmappedKeyCase = { + char: string; + insertable: boolean; + }; + it.each([ + { char: 'm', insertable: true }, + { char: 'n', insertable: true }, + { char: 'p', insertable: true }, + { char: 'q', insertable: true }, + { char: 's', insertable: true }, + { char: 'v', insertable: true }, + { char: 'y', insertable: true }, + { char: 'z', insertable: true }, + { char: 'H', insertable: true }, + { char: 'J', insertable: true }, + { char: 'K', insertable: true }, + { char: 'L', insertable: true }, + { char: 'M', insertable: true }, + { char: 'N', insertable: true }, + { char: 'P', insertable: true }, + { char: 'Q', insertable: true }, + { char: 'R', insertable: true }, + { char: 'S', insertable: true }, + { char: 'U', insertable: true }, + { char: 'V', insertable: true }, + { char: 'Y', insertable: true }, + { char: 'Z', insertable: true }, + { char: '/', insertable: true }, + { char: '#', insertable: true }, + { char: '%', insertable: true }, + { char: '&', insertable: true }, + { char: "'", insertable: true }, + { char: '(', insertable: true }, + { char: ')', insertable: true }, + { char: '*', insertable: true }, + { char: '+', insertable: true }, + { char: '-', insertable: true }, + { char: '/', insertable: true }, + { char: ':', insertable: true }, + { char: '<', insertable: true }, + { char: '=', insertable: true }, + { char: '>', insertable: true }, + { char: '@', insertable: true }, + { char: '[', insertable: true }, + { char: '\\', insertable: true }, + { char: ']', insertable: true }, + { char: '_', insertable: true }, + { char: '`', insertable: true }, + { char: '{', insertable: true }, + { char: '|', insertable: true }, + { char: '}', insertable: true }, + ])( + '$char: should be swallowed and do nothing in Normal mode', + async ({ char, insertable }) => { + const { result } = await renderVimHook(); + exitInsertMode(result); + + let handled = false; + act(() => { + handled = result.current.handleInput( + createKey({ sequence: char, name: char, insertable }), + ); + }); + + expect(handled).toBe(true); + expect(mockVimContext.setVimMode).not.toHaveBeenCalledWith('INSERT'); + + expect(mockBuffer.vimFindCharForward).not.toHaveBeenCalled(); + expect(mockBuffer.vimFindCharBackward).not.toHaveBeenCalled(); + }, + ); + }); + describe('Operator + find motions (df, dt, dF, dT, cf, ct, cF, cT)', async () => { it('df{char}: executes delete-to-char, not a dangling operator', async () => { const { result } = await renderVimHook(); diff --git a/packages/cli/src/ui/hooks/vim.ts b/packages/cli/src/ui/hooks/vim.ts index d1780c3c98..9e0006ea53 100644 --- a/packages/cli/src/ui/hooks/vim.ts +++ b/packages/cli/src/ui/hooks/vim.ts @@ -1486,6 +1486,11 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { // Unknown command, clear count and pending states dispatch({ type: 'CLEAR_PENDING_STATES' }); + // Ignore any Insertable key in Normal Mode + if (normalizedKey.insertable) { + return true; + } + // Not handled by vim so allow other handlers to process it. return false; }