Modify navigation and completion keyboard shortcuts to not use scroll. (#12502)

This commit is contained in:
Jacob Richman
2025-11-03 16:22:04 -08:00
committed by GitHub
parent f3759381b1
commit ad33c22374
7 changed files with 131 additions and 29 deletions
+3 -3
View File
@@ -53,10 +53,10 @@ This document lists the available keyboard shortcuts within Gemini CLI.
## Suggestions ## Suggestions
| Shortcut | Description | | Shortcut | Description |
| --------------- | -------------------------------------- | | ----------------------- | -------------------------------------- |
| `Down Arrow` | Navigate down through the suggestions. | | `Down Arrow` / `Ctrl+N` | Navigate down through the suggestions. |
| `Tab` / `Enter` | Accept the selected suggestion. | | `Tab` / `Enter` | Accept the selected suggestion. |
| `Up Arrow` | Navigate up through the suggestions. | | `Up Arrow` / `Ctrl+P` | Navigate up through the suggestions. |
## Radio Button Select ## Radio Button Select
@@ -55,5 +55,27 @@ describe('keyBindings config', () => {
const config: KeyBindingConfig = defaultKeyBindings; const config: KeyBindingConfig = defaultKeyBindings;
expect(config[Command.HOME]).toBeDefined(); expect(config[Command.HOME]).toBeDefined();
}); });
it('should have correct specific bindings', () => {
// Verify navigation ignores shift
const navUp = defaultKeyBindings[Command.NAVIGATION_UP];
expect(navUp).toContainEqual({ key: 'up', shift: false });
const navDown = defaultKeyBindings[Command.NAVIGATION_DOWN];
expect(navDown).toContainEqual({ key: 'down', shift: false });
// Verify dialog navigation
const dialogNavUp = defaultKeyBindings[Command.DIALOG_NAVIGATION_UP];
expect(dialogNavUp).toContainEqual({ key: 'up', shift: false });
expect(dialogNavUp).toContainEqual({ key: 'k', shift: false });
const dialogNavDown = defaultKeyBindings[Command.DIALOG_NAVIGATION_DOWN];
expect(dialogNavDown).toContainEqual({ key: 'down', shift: false });
expect(dialogNavDown).toContainEqual({ key: 'j', shift: false });
// Verify physical home/end keys
expect(defaultKeyBindings[Command.HOME]).toContainEqual({ key: 'home' });
expect(defaultKeyBindings[Command.END]).toContainEqual({ key: 'end' });
});
}); });
}); });
+29 -7
View File
@@ -31,6 +31,10 @@ export enum Command {
NAVIGATION_UP = 'navigationUp', NAVIGATION_UP = 'navigationUp',
NAVIGATION_DOWN = 'navigationDown', NAVIGATION_DOWN = 'navigationDown',
// Dialog navigation
DIALOG_NAVIGATION_UP = 'dialogNavigationUp',
DIALOG_NAVIGATION_DOWN = 'dialogNavigationDown',
// Auto-completion // Auto-completion
ACCEPT_SUGGESTION = 'acceptSuggestion', ACCEPT_SUGGESTION = 'acceptSuggestion',
COMPLETION_UP = 'completionUp', COMPLETION_UP = 'completionUp',
@@ -100,8 +104,8 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.ESCAPE]: [{ key: 'escape' }], [Command.ESCAPE]: [{ key: 'escape' }],
// Cursor movement // Cursor movement
[Command.HOME]: [{ key: 'a', ctrl: true }], [Command.HOME]: [{ key: 'a', ctrl: true }, { key: 'home' }],
[Command.END]: [{ key: 'e', ctrl: true }], [Command.END]: [{ key: 'e', ctrl: true }, { key: 'end' }],
// Text deletion // Text deletion
[Command.KILL_LINE_RIGHT]: [{ key: 'k', ctrl: true }], [Command.KILL_LINE_RIGHT]: [{ key: 'k', ctrl: true }],
@@ -118,15 +122,33 @@ export const defaultKeyBindings: KeyBindingConfig = {
// History navigation // History navigation
[Command.HISTORY_UP]: [{ key: 'p', ctrl: true, shift: false }], [Command.HISTORY_UP]: [{ key: 'p', ctrl: true, shift: false }],
[Command.HISTORY_DOWN]: [{ key: 'n', ctrl: true }], [Command.HISTORY_DOWN]: [{ key: 'n', ctrl: true, shift: false }],
[Command.NAVIGATION_UP]: [{ key: 'up' }], [Command.NAVIGATION_UP]: [{ key: 'up', shift: false }],
[Command.NAVIGATION_DOWN]: [{ key: 'down' }], [Command.NAVIGATION_DOWN]: [{ key: 'down', shift: false }],
// Dialog navigation
// Navigation shortcuts appropriate for dialogs where we do not need to accept
// text input.
[Command.DIALOG_NAVIGATION_UP]: [
{ key: 'up', shift: false },
{ key: 'k', shift: false },
],
[Command.DIALOG_NAVIGATION_DOWN]: [
{ key: 'down', shift: false },
{ key: 'j', shift: false },
],
// Auto-completion // Auto-completion
[Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return', ctrl: false }], [Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return', ctrl: false }],
// Completion navigation (arrow or Ctrl+P/N) // Completion navigation (arrow or Ctrl+P/N)
[Command.COMPLETION_UP]: [{ key: 'up' }, { key: 'p', ctrl: true }], [Command.COMPLETION_UP]: [
[Command.COMPLETION_DOWN]: [{ key: 'down' }, { key: 'n', ctrl: true }], { key: 'up', shift: false },
{ key: 'p', ctrl: true, shift: false },
],
[Command.COMPLETION_DOWN]: [
{ key: 'down', shift: false },
{ key: 'n', ctrl: true, shift: false },
],
// Text input // Text input
// Must also exclude shift to allow shift+enter for newline // Must also exclude shift to allow shift+enter for newline
@@ -38,6 +38,7 @@ import {
TOGGLE_TYPES, TOGGLE_TYPES,
} from '../../config/settingsSchema.js'; } from '../../config/settingsSchema.js';
import { debugLogger } from '@google/gemini-cli-core'; import { debugLogger } from '@google/gemini-cli-core';
import { keyMatchers, Command } from '../keyMatchers.js';
interface SettingsDialogProps { interface SettingsDialogProps {
settings: LoadedSettings; settings: LoadedSettings;
@@ -480,7 +481,7 @@ export function SettingsDialog({
useKeypress( useKeypress(
(key) => { (key) => {
const { name, ctrl } = key; const { name } = key;
if (name === 'tab' && showScopeSelection) { if (name === 'tab' && showScopeSelection) {
setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings')); setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings'));
} }
@@ -523,11 +524,11 @@ export function SettingsDialog({
} }
return; return;
} }
if (name === 'escape') { if (keyMatchers[Command.ESCAPE](key)) {
commitEdit(editingKey); commitEdit(editingKey);
return; return;
} }
if (name === 'return') { if (keyMatchers[Command.RETURN](key)) {
commitEdit(editingKey); commitEdit(editingKey);
return; return;
} }
@@ -564,18 +565,18 @@ export function SettingsDialog({
return; return;
} }
// Home and End keys // Home and End keys
if (name === 'home') { if (keyMatchers[Command.HOME](key)) {
setEditCursorPos(0); setEditCursorPos(0);
return; return;
} }
if (name === 'end') { if (keyMatchers[Command.END](key)) {
setEditCursorPos(cpLen(editBuffer)); setEditCursorPos(cpLen(editBuffer));
return; return;
} }
// Block other keys while editing // Block other keys while editing
return; return;
} }
if (name === 'up' || name === 'k') { if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
// If editing, commit first // If editing, commit first
if (editingKey) { if (editingKey) {
commitEdit(editingKey); commitEdit(editingKey);
@@ -591,7 +592,7 @@ export function SettingsDialog({
} else if (newIndex < scrollOffset) { } else if (newIndex < scrollOffset) {
setScrollOffset(newIndex); setScrollOffset(newIndex);
} }
} else if (name === 'down' || name === 'j') { } else if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
// If editing, commit first // If editing, commit first
if (editingKey) { if (editingKey) {
commitEdit(editingKey); commitEdit(editingKey);
@@ -605,7 +606,7 @@ export function SettingsDialog({
} else if (newIndex >= scrollOffset + effectiveMaxItemsToShow) { } else if (newIndex >= scrollOffset + effectiveMaxItemsToShow) {
setScrollOffset(newIndex - effectiveMaxItemsToShow + 1); setScrollOffset(newIndex - effectiveMaxItemsToShow + 1);
} }
} else if (name === 'return' || name === 'space') { } else if (keyMatchers[Command.RETURN](key) || name === 'space') {
const currentItem = items[activeSettingIndex]; const currentItem = items[activeSettingIndex];
if ( if (
currentItem?.type === 'number' || currentItem?.type === 'number' ||
@@ -620,7 +621,10 @@ export function SettingsDialog({
if (currentItem?.type === 'number') { if (currentItem?.type === 'number') {
startEditing(currentItem.value, key.sequence); startEditing(currentItem.value, key.sequence);
} }
} else if (ctrl && (name === 'c' || name === 'l')) { } else if (
keyMatchers[Command.CLEAR_INPUT](key) ||
keyMatchers[Command.CLEAR_SCREEN](key)
) {
// Ctrl+C or Ctrl+L: Clear current setting and reset to default // Ctrl+C or Ctrl+L: Clear current setting and reset to default
const currentSetting = items[activeSettingIndex]; const currentSetting = items[activeSettingIndex];
if (currentSetting) { if (currentSetting) {
@@ -730,7 +734,7 @@ export function SettingsDialog({
setRestartRequiredSettings(new Set()); // Clear restart-required settings setRestartRequiredSettings(new Set()); // Clear restart-required settings
if (onRestartRequest) onRestartRequest(); if (onRestartRequest) onRestartRequest();
} }
if (name === 'escape') { if (keyMatchers[Command.ESCAPE](key)) {
if (editingKey) { if (editingKey) {
commitEdit(editingKey); commitEdit(editingKey);
} else { } else {
@@ -48,15 +48,19 @@ describe('useSelectionList', () => {
mockOnHighlight.mockClear(); mockOnHighlight.mockClear();
}); });
const pressKey = (name: string, sequence: string = name) => { const pressKey = (
name: string,
sequence: string = name,
options: { shift?: boolean; ctrl?: boolean } = {},
) => {
act(() => { act(() => {
if (activeKeypressHandler) { if (activeKeypressHandler) {
const key: Key = { const key: Key = {
name, name,
sequence, sequence,
ctrl: false, ctrl: options.ctrl ?? false,
meta: false, meta: false,
shift: false, shift: options.shift ?? false,
paste: false, paste: false,
}; };
activeKeypressHandler(key); activeKeypressHandler(key);
@@ -202,6 +206,31 @@ describe('useSelectionList', () => {
expect(result.current.activeIndex).toBe(0); expect(result.current.activeIndex).toBe(0);
}); });
it('should ignore navigation keys when shift is pressed', async () => {
const { result } = await renderSelectionListHook({
items,
initialIndex: 2, // Start at middle item 'C'
onSelect: mockOnSelect,
});
expect(result.current.activeIndex).toBe(2);
// Shift+Down / Shift+J should not move down
pressKey('down', undefined, { shift: true });
expect(result.current.activeIndex).toBe(2);
pressKey('j', undefined, { shift: true });
expect(result.current.activeIndex).toBe(2);
// Shift+Up / Shift+K should not move up
pressKey('up', undefined, { shift: true });
expect(result.current.activeIndex).toBe(2);
pressKey('k', undefined, { shift: true });
expect(result.current.activeIndex).toBe(2);
// Verify normal navigation still works
pressKey('down');
expect(result.current.activeIndex).toBe(3);
});
it('should wrap navigation correctly', async () => { it('should wrap navigation correctly', async () => {
const { result } = await renderSelectionListHook({ const { result } = await renderSelectionListHook({
items, items,
@@ -6,6 +6,7 @@
import { useReducer, useRef, useEffect, useCallback } from 'react'; import { useReducer, useRef, useEffect, useCallback } from 'react';
import { useKeypress, type Key } from './useKeypress.js'; import { useKeypress, type Key } from './useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
export interface SelectionListItem<T> { export interface SelectionListItem<T> {
key: string; key: string;
@@ -318,7 +319,7 @@ export function useSelectionList<T>({
const itemsLength = items.length; const itemsLength = items.length;
const handleKeypress = useCallback( const handleKeypress = useCallback(
(key: Key) => { (key: Key) => {
const { sequence, name } = key; const { sequence } = key;
const isNumeric = showNumbers && /^[0-9]$/.test(sequence); const isNumeric = showNumbers && /^[0-9]$/.test(sequence);
// Clear number input buffer on non-numeric key press // Clear number input buffer on non-numeric key press
@@ -327,17 +328,17 @@ export function useSelectionList<T>({
numberInputRef.current = ''; numberInputRef.current = '';
} }
if (name === 'k' || name === 'up') { if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
dispatch({ type: 'MOVE_UP' }); dispatch({ type: 'MOVE_UP' });
return; return;
} }
if (name === 'j' || name === 'down') { if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
dispatch({ type: 'MOVE_DOWN' }); dispatch({ type: 'MOVE_DOWN' });
return; return;
} }
if (name === 'return') { if (keyMatchers[Command.RETURN](key)) {
dispatch({ type: 'SELECT_CURRENT' }); dispatch({ type: 'SELECT_CURRENT' });
return; return;
} }
+24
View File
@@ -36,6 +36,10 @@ describe('keyMatchers', () => {
[Command.HISTORY_DOWN]: (key: Key) => key.ctrl && key.name === 'n', [Command.HISTORY_DOWN]: (key: Key) => key.ctrl && key.name === 'n',
[Command.NAVIGATION_UP]: (key: Key) => key.name === 'up', [Command.NAVIGATION_UP]: (key: Key) => key.name === 'up',
[Command.NAVIGATION_DOWN]: (key: Key) => key.name === 'down', [Command.NAVIGATION_DOWN]: (key: Key) => key.name === 'down',
[Command.DIALOG_NAVIGATION_UP]: (key: Key) =>
!key.shift && (key.name === 'up' || key.name === 'k'),
[Command.DIALOG_NAVIGATION_DOWN]: (key: Key) =>
!key.shift && (key.name === 'down' || key.name === 'j'),
[Command.ACCEPT_SUGGESTION]: (key: Key) => [Command.ACCEPT_SUGGESTION]: (key: Key) =>
key.name === 'tab' || (key.name === 'return' && !key.ctrl), key.name === 'tab' || (key.name === 'return' && !key.ctrl),
[Command.COMPLETION_UP]: (key: Key) => [Command.COMPLETION_UP]: (key: Key) =>
@@ -158,6 +162,26 @@ describe('keyMatchers', () => {
negative: [createKey('n'), createKey('d')], negative: [createKey('n'), createKey('d')],
}, },
// Dialog navigation
{
command: Command.DIALOG_NAVIGATION_UP,
positive: [createKey('up'), createKey('k')],
negative: [
createKey('up', { shift: true }),
createKey('k', { shift: true }),
createKey('p'),
],
},
{
command: Command.DIALOG_NAVIGATION_DOWN,
positive: [createKey('down'), createKey('j')],
negative: [
createKey('down', { shift: true }),
createKey('j', { shift: true }),
createKey('n'),
],
},
// Auto-completion // Auto-completion
{ {
command: Command.ACCEPT_SUGGESTION, command: Command.ACCEPT_SUGGESTION,