Skip to content

Commit ad33c22

Browse files
authored
Modify navigation and completion keyboard shortcuts to not use scroll. (#12502)
1 parent f375938 commit ad33c22

7 files changed

Lines changed: 131 additions & 29 deletions

File tree

docs/cli/keyboard-shortcuts.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,11 @@ This document lists the available keyboard shortcuts within Gemini CLI.
5252

5353
## Suggestions
5454

55-
| Shortcut | Description |
56-
| --------------- | -------------------------------------- |
57-
| `Down Arrow` | Navigate down through the suggestions. |
58-
| `Tab` / `Enter` | Accept the selected suggestion. |
59-
| `Up Arrow` | Navigate up through the suggestions. |
55+
| Shortcut | Description |
56+
| ----------------------- | -------------------------------------- |
57+
| `Down Arrow` / `Ctrl+N` | Navigate down through the suggestions. |
58+
| `Tab` / `Enter` | Accept the selected suggestion. |
59+
| `Up Arrow` / `Ctrl+P` | Navigate up through the suggestions. |
6060

6161
## Radio Button Select
6262

packages/cli/src/config/keyBindings.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,5 +55,27 @@ describe('keyBindings config', () => {
5555
const config: KeyBindingConfig = defaultKeyBindings;
5656
expect(config[Command.HOME]).toBeDefined();
5757
});
58+
59+
it('should have correct specific bindings', () => {
60+
// Verify navigation ignores shift
61+
const navUp = defaultKeyBindings[Command.NAVIGATION_UP];
62+
expect(navUp).toContainEqual({ key: 'up', shift: false });
63+
64+
const navDown = defaultKeyBindings[Command.NAVIGATION_DOWN];
65+
expect(navDown).toContainEqual({ key: 'down', shift: false });
66+
67+
// Verify dialog navigation
68+
const dialogNavUp = defaultKeyBindings[Command.DIALOG_NAVIGATION_UP];
69+
expect(dialogNavUp).toContainEqual({ key: 'up', shift: false });
70+
expect(dialogNavUp).toContainEqual({ key: 'k', shift: false });
71+
72+
const dialogNavDown = defaultKeyBindings[Command.DIALOG_NAVIGATION_DOWN];
73+
expect(dialogNavDown).toContainEqual({ key: 'down', shift: false });
74+
expect(dialogNavDown).toContainEqual({ key: 'j', shift: false });
75+
76+
// Verify physical home/end keys
77+
expect(defaultKeyBindings[Command.HOME]).toContainEqual({ key: 'home' });
78+
expect(defaultKeyBindings[Command.END]).toContainEqual({ key: 'end' });
79+
});
5880
});
5981
});

packages/cli/src/config/keyBindings.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ export enum Command {
3131
NAVIGATION_UP = 'navigationUp',
3232
NAVIGATION_DOWN = 'navigationDown',
3333

34+
// Dialog navigation
35+
DIALOG_NAVIGATION_UP = 'dialogNavigationUp',
36+
DIALOG_NAVIGATION_DOWN = 'dialogNavigationDown',
37+
3438
// Auto-completion
3539
ACCEPT_SUGGESTION = 'acceptSuggestion',
3640
COMPLETION_UP = 'completionUp',
@@ -100,8 +104,8 @@ export const defaultKeyBindings: KeyBindingConfig = {
100104
[Command.ESCAPE]: [{ key: 'escape' }],
101105

102106
// Cursor movement
103-
[Command.HOME]: [{ key: 'a', ctrl: true }],
104-
[Command.END]: [{ key: 'e', ctrl: true }],
107+
[Command.HOME]: [{ key: 'a', ctrl: true }, { key: 'home' }],
108+
[Command.END]: [{ key: 'e', ctrl: true }, { key: 'end' }],
105109

106110
// Text deletion
107111
[Command.KILL_LINE_RIGHT]: [{ key: 'k', ctrl: true }],
@@ -118,15 +122,33 @@ export const defaultKeyBindings: KeyBindingConfig = {
118122

119123
// History navigation
120124
[Command.HISTORY_UP]: [{ key: 'p', ctrl: true, shift: false }],
121-
[Command.HISTORY_DOWN]: [{ key: 'n', ctrl: true }],
122-
[Command.NAVIGATION_UP]: [{ key: 'up' }],
123-
[Command.NAVIGATION_DOWN]: [{ key: 'down' }],
125+
[Command.HISTORY_DOWN]: [{ key: 'n', ctrl: true, shift: false }],
126+
[Command.NAVIGATION_UP]: [{ key: 'up', shift: false }],
127+
[Command.NAVIGATION_DOWN]: [{ key: 'down', shift: false }],
128+
129+
// Dialog navigation
130+
// Navigation shortcuts appropriate for dialogs where we do not need to accept
131+
// text input.
132+
[Command.DIALOG_NAVIGATION_UP]: [
133+
{ key: 'up', shift: false },
134+
{ key: 'k', shift: false },
135+
],
136+
[Command.DIALOG_NAVIGATION_DOWN]: [
137+
{ key: 'down', shift: false },
138+
{ key: 'j', shift: false },
139+
],
124140

125141
// Auto-completion
126142
[Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return', ctrl: false }],
127143
// Completion navigation (arrow or Ctrl+P/N)
128-
[Command.COMPLETION_UP]: [{ key: 'up' }, { key: 'p', ctrl: true }],
129-
[Command.COMPLETION_DOWN]: [{ key: 'down' }, { key: 'n', ctrl: true }],
144+
[Command.COMPLETION_UP]: [
145+
{ key: 'up', shift: false },
146+
{ key: 'p', ctrl: true, shift: false },
147+
],
148+
[Command.COMPLETION_DOWN]: [
149+
{ key: 'down', shift: false },
150+
{ key: 'n', ctrl: true, shift: false },
151+
],
130152

131153
// Text input
132154
// Must also exclude shift to allow shift+enter for newline

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

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
TOGGLE_TYPES,
3939
} from '../../config/settingsSchema.js';
4040
import { debugLogger } from '@google/gemini-cli-core';
41+
import { keyMatchers, Command } from '../keyMatchers.js';
4142

4243
interface SettingsDialogProps {
4344
settings: LoadedSettings;
@@ -480,7 +481,7 @@ export function SettingsDialog({
480481

481482
useKeypress(
482483
(key) => {
483-
const { name, ctrl } = key;
484+
const { name } = key;
484485
if (name === 'tab' && showScopeSelection) {
485486
setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings'));
486487
}
@@ -523,11 +524,11 @@ export function SettingsDialog({
523524
}
524525
return;
525526
}
526-
if (name === 'escape') {
527+
if (keyMatchers[Command.ESCAPE](key)) {
527528
commitEdit(editingKey);
528529
return;
529530
}
530-
if (name === 'return') {
531+
if (keyMatchers[Command.RETURN](key)) {
531532
commitEdit(editingKey);
532533
return;
533534
}
@@ -564,18 +565,18 @@ export function SettingsDialog({
564565
return;
565566
}
566567
// Home and End keys
567-
if (name === 'home') {
568+
if (keyMatchers[Command.HOME](key)) {
568569
setEditCursorPos(0);
569570
return;
570571
}
571-
if (name === 'end') {
572+
if (keyMatchers[Command.END](key)) {
572573
setEditCursorPos(cpLen(editBuffer));
573574
return;
574575
}
575576
// Block other keys while editing
576577
return;
577578
}
578-
if (name === 'up' || name === 'k') {
579+
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
579580
// If editing, commit first
580581
if (editingKey) {
581582
commitEdit(editingKey);
@@ -591,7 +592,7 @@ export function SettingsDialog({
591592
} else if (newIndex < scrollOffset) {
592593
setScrollOffset(newIndex);
593594
}
594-
} else if (name === 'down' || name === 'j') {
595+
} else if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
595596
// If editing, commit first
596597
if (editingKey) {
597598
commitEdit(editingKey);
@@ -605,7 +606,7 @@ export function SettingsDialog({
605606
} else if (newIndex >= scrollOffset + effectiveMaxItemsToShow) {
606607
setScrollOffset(newIndex - effectiveMaxItemsToShow + 1);
607608
}
608-
} else if (name === 'return' || name === 'space') {
609+
} else if (keyMatchers[Command.RETURN](key) || name === 'space') {
609610
const currentItem = items[activeSettingIndex];
610611
if (
611612
currentItem?.type === 'number' ||
@@ -620,7 +621,10 @@ export function SettingsDialog({
620621
if (currentItem?.type === 'number') {
621622
startEditing(currentItem.value, key.sequence);
622623
}
623-
} else if (ctrl && (name === 'c' || name === 'l')) {
624+
} else if (
625+
keyMatchers[Command.CLEAR_INPUT](key) ||
626+
keyMatchers[Command.CLEAR_SCREEN](key)
627+
) {
624628
// Ctrl+C or Ctrl+L: Clear current setting and reset to default
625629
const currentSetting = items[activeSettingIndex];
626630
if (currentSetting) {
@@ -730,7 +734,7 @@ export function SettingsDialog({
730734
setRestartRequiredSettings(new Set()); // Clear restart-required settings
731735
if (onRestartRequest) onRestartRequest();
732736
}
733-
if (name === 'escape') {
737+
if (keyMatchers[Command.ESCAPE](key)) {
734738
if (editingKey) {
735739
commitEdit(editingKey);
736740
} else {

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

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,19 @@ describe('useSelectionList', () => {
4848
mockOnHighlight.mockClear();
4949
});
5050

51-
const pressKey = (name: string, sequence: string = name) => {
51+
const pressKey = (
52+
name: string,
53+
sequence: string = name,
54+
options: { shift?: boolean; ctrl?: boolean } = {},
55+
) => {
5256
act(() => {
5357
if (activeKeypressHandler) {
5458
const key: Key = {
5559
name,
5660
sequence,
57-
ctrl: false,
61+
ctrl: options.ctrl ?? false,
5862
meta: false,
59-
shift: false,
63+
shift: options.shift ?? false,
6064
paste: false,
6165
};
6266
activeKeypressHandler(key);
@@ -202,6 +206,31 @@ describe('useSelectionList', () => {
202206
expect(result.current.activeIndex).toBe(0);
203207
});
204208

209+
it('should ignore navigation keys when shift is pressed', async () => {
210+
const { result } = await renderSelectionListHook({
211+
items,
212+
initialIndex: 2, // Start at middle item 'C'
213+
onSelect: mockOnSelect,
214+
});
215+
expect(result.current.activeIndex).toBe(2);
216+
217+
// Shift+Down / Shift+J should not move down
218+
pressKey('down', undefined, { shift: true });
219+
expect(result.current.activeIndex).toBe(2);
220+
pressKey('j', undefined, { shift: true });
221+
expect(result.current.activeIndex).toBe(2);
222+
223+
// Shift+Up / Shift+K should not move up
224+
pressKey('up', undefined, { shift: true });
225+
expect(result.current.activeIndex).toBe(2);
226+
pressKey('k', undefined, { shift: true });
227+
expect(result.current.activeIndex).toBe(2);
228+
229+
// Verify normal navigation still works
230+
pressKey('down');
231+
expect(result.current.activeIndex).toBe(3);
232+
});
233+
205234
it('should wrap navigation correctly', async () => {
206235
const { result } = await renderSelectionListHook({
207236
items,

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import { useReducer, useRef, useEffect, useCallback } from 'react';
88
import { useKeypress, type Key } from './useKeypress.js';
9+
import { keyMatchers, Command } from '../keyMatchers.js';
910

1011
export interface SelectionListItem<T> {
1112
key: string;
@@ -318,7 +319,7 @@ export function useSelectionList<T>({
318319
const itemsLength = items.length;
319320
const handleKeypress = useCallback(
320321
(key: Key) => {
321-
const { sequence, name } = key;
322+
const { sequence } = key;
322323
const isNumeric = showNumbers && /^[0-9]$/.test(sequence);
323324

324325
// Clear number input buffer on non-numeric key press
@@ -327,17 +328,17 @@ export function useSelectionList<T>({
327328
numberInputRef.current = '';
328329
}
329330

330-
if (name === 'k' || name === 'up') {
331+
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
331332
dispatch({ type: 'MOVE_UP' });
332333
return;
333334
}
334335

335-
if (name === 'j' || name === 'down') {
336+
if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
336337
dispatch({ type: 'MOVE_DOWN' });
337338
return;
338339
}
339340

340-
if (name === 'return') {
341+
if (keyMatchers[Command.RETURN](key)) {
341342
dispatch({ type: 'SELECT_CURRENT' });
342343
return;
343344
}

packages/cli/src/ui/keyMatchers.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ describe('keyMatchers', () => {
3636
[Command.HISTORY_DOWN]: (key: Key) => key.ctrl && key.name === 'n',
3737
[Command.NAVIGATION_UP]: (key: Key) => key.name === 'up',
3838
[Command.NAVIGATION_DOWN]: (key: Key) => key.name === 'down',
39+
[Command.DIALOG_NAVIGATION_UP]: (key: Key) =>
40+
!key.shift && (key.name === 'up' || key.name === 'k'),
41+
[Command.DIALOG_NAVIGATION_DOWN]: (key: Key) =>
42+
!key.shift && (key.name === 'down' || key.name === 'j'),
3943
[Command.ACCEPT_SUGGESTION]: (key: Key) =>
4044
key.name === 'tab' || (key.name === 'return' && !key.ctrl),
4145
[Command.COMPLETION_UP]: (key: Key) =>
@@ -158,6 +162,26 @@ describe('keyMatchers', () => {
158162
negative: [createKey('n'), createKey('d')],
159163
},
160164

165+
// Dialog navigation
166+
{
167+
command: Command.DIALOG_NAVIGATION_UP,
168+
positive: [createKey('up'), createKey('k')],
169+
negative: [
170+
createKey('up', { shift: true }),
171+
createKey('k', { shift: true }),
172+
createKey('p'),
173+
],
174+
},
175+
{
176+
command: Command.DIALOG_NAVIGATION_DOWN,
177+
positive: [createKey('down'), createKey('j')],
178+
negative: [
179+
createKey('down', { shift: true }),
180+
createKey('j', { shift: true }),
181+
createKey('n'),
182+
],
183+
},
184+
161185
// Auto-completion
162186
{
163187
command: Command.ACCEPT_SUGGESTION,

0 commit comments

Comments
 (0)