Skip to content

Commit 7478859

Browse files
fix(cli): Prevent unmapped keys in Vim Normal mode from inserting text into prompt Input. (google-gemini#25139)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
1 parent f09d45d commit 7478859

5 files changed

Lines changed: 149 additions & 2 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,8 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
154154
onEscapePromptChange={uiActions.onEscapePromptChange}
155155
focus={isFocused}
156156
vimHandleInput={uiActions.vimHandleInput}
157+
vimEnabled={vimEnabled}
158+
vimMode={vimMode}
157159
isEmbeddedShellFocused={uiState.embeddedShellFocused}
158160
popAllMessages={uiActions.popAllMessages}
159161
onQueueMessage={uiActions.addMessage}

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4898,6 +4898,60 @@ describe('InputPrompt', () => {
48984898
unmount();
48994899
});
49004900

4901+
it('should NOT open shortcuts help with ? in vim NORMAL mode', async () => {
4902+
const setShortcutsHelpVisible = vi.fn();
4903+
const vimHandleInput = vi.fn().mockReturnValue(true);
4904+
4905+
const { stdin, unmount } = await renderWithProviders(
4906+
<TestInputPrompt
4907+
{...props}
4908+
vimEnabled={true}
4909+
vimMode="NORMAL"
4910+
vimHandleInput={vimHandleInput}
4911+
/>,
4912+
{
4913+
uiActions: { setShortcutsHelpVisible },
4914+
},
4915+
);
4916+
4917+
await act(async () => {
4918+
stdin.write('?');
4919+
});
4920+
4921+
expect(setShortcutsHelpVisible).not.toHaveBeenCalled();
4922+
expect(vimHandleInput).toHaveBeenCalled();
4923+
expect(mockBuffer.handleInput).not.toHaveBeenCalled();
4924+
4925+
unmount();
4926+
});
4927+
4928+
it('should open shortcuts help with ? in vim INSERT mode', async () => {
4929+
const setShortcutsHelpVisible = vi.fn();
4930+
const vimHandleInput = vi.fn().mockReturnValue(false);
4931+
4932+
const { stdin, unmount } = await renderWithProviders(
4933+
<TestInputPrompt
4934+
{...props}
4935+
vimEnabled={true}
4936+
vimMode="INSERT"
4937+
vimHandleInput={vimHandleInput}
4938+
/>,
4939+
{
4940+
uiActions: { setShortcutsHelpVisible },
4941+
},
4942+
);
4943+
4944+
await act(async () => {
4945+
stdin.write('?');
4946+
});
4947+
4948+
await waitFor(() => {
4949+
expect(setShortcutsHelpVisible).toHaveBeenCalledWith(true);
4950+
});
4951+
4952+
unmount();
4953+
});
4954+
49014955
it.each([
49024956
{
49034957
name: 'terminal paste event occurs',

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
9292
import { useIsHelpDismissKey } from '../utils/shortcutsHelp.js';
9393
import { useRepeatedKeyPress } from '../hooks/useRepeatedKeyPress.js';
9494
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
95+
import type { VimMode } from '../contexts/VimModeContext.js';
9596

9697
const SCROLLBAR_GUTTER_WIDTH = 1;
9798

@@ -126,6 +127,8 @@ export interface InputPromptProps {
126127
onEscapePromptChange?: (showPrompt: boolean) => void;
127128
onSuggestionsVisibilityChange?: (visible: boolean) => void;
128129
vimHandleInput?: (key: Key) => boolean;
130+
vimEnabled?: boolean;
131+
vimMode?: VimMode;
129132
isEmbeddedShellFocused?: boolean;
130133
setQueueErrorMessage: (message: string | null) => void;
131134
streamingState: StreamingState;
@@ -214,6 +217,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
214217
onEscapePromptChange,
215218
onSuggestionsVisibilityChange,
216219
vimHandleInput,
220+
vimEnabled,
221+
vimMode,
217222
isEmbeddedShellFocused,
218223
setQueueErrorMessage,
219224
streamingState,
@@ -859,7 +864,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
859864
}
860865

861866
if (shortcutsHelpVisible) {
862-
if (key.sequence === '?' && key.insertable) {
867+
if (
868+
key.sequence === '?' &&
869+
key.insertable &&
870+
(!vimEnabled || vimMode === 'INSERT')
871+
) {
863872
setShortcutsHelpVisible(false);
864873
buffer.handleInput(key);
865874
return true;
@@ -879,7 +888,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
879888
key.sequence === '?' &&
880889
key.insertable &&
881890
!shortcutsHelpVisible &&
882-
buffer.text.length === 0
891+
buffer.text.length === 0 &&
892+
(!vimEnabled || vimMode === 'INSERT')
883893
) {
884894
setShortcutsHelpVisible(true);
885895
return true;
@@ -1374,6 +1384,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
13741384
resetCompletionState,
13751385
resetEscapeState,
13761386
vimHandleInput,
1387+
vimEnabled,
1388+
vimMode,
13771389
reverseSearchActive,
13781390
textBeforeReverseSearch,
13791391
cursorPosition,

packages/cli/src/ui/hooks/vim.test.tsx

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2258,6 +2258,80 @@ describe('useVim hook', async () => {
22582258
});
22592259
});
22602260

2261+
describe('should handle unmapped keys in Normal mode', () => {
2262+
type UnmappedKeyCase = {
2263+
char: string;
2264+
insertable: boolean;
2265+
};
2266+
it.each<UnmappedKeyCase>([
2267+
{ char: 'm', insertable: true },
2268+
{ char: 'n', insertable: true },
2269+
{ char: 'p', insertable: true },
2270+
{ char: 'q', insertable: true },
2271+
{ char: 's', insertable: true },
2272+
{ char: 'v', insertable: true },
2273+
{ char: 'y', insertable: true },
2274+
{ char: 'z', insertable: true },
2275+
{ char: 'H', insertable: true },
2276+
{ char: 'J', insertable: true },
2277+
{ char: 'K', insertable: true },
2278+
{ char: 'L', insertable: true },
2279+
{ char: 'M', insertable: true },
2280+
{ char: 'N', insertable: true },
2281+
{ char: 'P', insertable: true },
2282+
{ char: 'Q', insertable: true },
2283+
{ char: 'R', insertable: true },
2284+
{ char: 'S', insertable: true },
2285+
{ char: 'U', insertable: true },
2286+
{ char: 'V', insertable: true },
2287+
{ char: 'Y', insertable: true },
2288+
{ char: 'Z', insertable: true },
2289+
{ char: '/', insertable: true },
2290+
{ char: '#', insertable: true },
2291+
{ char: '%', insertable: true },
2292+
{ char: '&', insertable: true },
2293+
{ char: "'", insertable: true },
2294+
{ char: '(', insertable: true },
2295+
{ char: ')', insertable: true },
2296+
{ char: '*', insertable: true },
2297+
{ char: '+', insertable: true },
2298+
{ char: '-', insertable: true },
2299+
{ char: '/', insertable: true },
2300+
{ char: ':', insertable: true },
2301+
{ char: '<', insertable: true },
2302+
{ char: '=', insertable: true },
2303+
{ char: '>', insertable: true },
2304+
{ char: '@', insertable: true },
2305+
{ char: '[', insertable: true },
2306+
{ char: '\\', insertable: true },
2307+
{ char: ']', insertable: true },
2308+
{ char: '_', insertable: true },
2309+
{ char: '`', insertable: true },
2310+
{ char: '{', insertable: true },
2311+
{ char: '|', insertable: true },
2312+
{ char: '}', insertable: true },
2313+
])(
2314+
'$char: should be swallowed and do nothing in Normal mode',
2315+
async ({ char, insertable }) => {
2316+
const { result } = await renderVimHook();
2317+
exitInsertMode(result);
2318+
2319+
let handled = false;
2320+
act(() => {
2321+
handled = result.current.handleInput(
2322+
createKey({ sequence: char, name: char, insertable }),
2323+
);
2324+
});
2325+
2326+
expect(handled).toBe(true);
2327+
expect(mockVimContext.setVimMode).not.toHaveBeenCalledWith('INSERT');
2328+
2329+
expect(mockBuffer.vimFindCharForward).not.toHaveBeenCalled();
2330+
expect(mockBuffer.vimFindCharBackward).not.toHaveBeenCalled();
2331+
},
2332+
);
2333+
});
2334+
22612335
describe('Operator + find motions (df, dt, dF, dT, cf, ct, cF, cT)', async () => {
22622336
it('df{char}: executes delete-to-char, not a dangling operator', async () => {
22632337
const { result } = await renderVimHook();

packages/cli/src/ui/hooks/vim.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1486,6 +1486,11 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
14861486
// Unknown command, clear count and pending states
14871487
dispatch({ type: 'CLEAR_PENDING_STATES' });
14881488

1489+
// Ignore any Insertable key in Normal Mode
1490+
if (normalizedKey.insertable) {
1491+
return true;
1492+
}
1493+
14891494
// Not handled by vim so allow other handlers to process it.
14901495
return false;
14911496
}

0 commit comments

Comments
 (0)