mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 21:03:05 -07:00
Modify navigation and completion keyboard shortcuts to not use scroll. (#12502)
This commit is contained in:
@@ -52,11 +52,11 @@ 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' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user