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;
}