feat(ui): dynamically generate all keybinding hints (#21346)

This commit is contained in:
Tommaso Sciortino
2026-03-06 18:34:26 +00:00
committed by GitHub
parent 4669148a4c
commit 6d607a5953
24 changed files with 424 additions and 293 deletions

View File

@@ -8,22 +8,14 @@ import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { ApprovalMode } from '@google/gemini-cli-core';
import { formatCommand } from '../utils/keybindingUtils.js';
import { Command } from '../../config/keyBindings.js';
interface ApprovalModeIndicatorProps {
approvalMode: ApprovalMode;
allowPlanMode?: boolean;
}
export const APPROVAL_MODE_TEXT = {
AUTO_EDIT: 'auto-accept edits',
PLAN: 'plan',
YOLO: 'YOLO',
HINT_SWITCH_TO_PLAN_MODE: 'shift+tab to plan',
HINT_SWITCH_TO_MANUAL_MODE: 'shift+tab to manual',
HINT_SWITCH_TO_AUTO_EDIT_MODE: 'shift+tab to accept edits',
HINT_SWITCH_TO_YOLO_MODE: 'ctrl+y',
};
export const ApprovalModeIndicator: React.FC<ApprovalModeIndicatorProps> = ({
approvalMode,
allowPlanMode,
@@ -32,29 +24,32 @@ export const ApprovalModeIndicator: React.FC<ApprovalModeIndicatorProps> = ({
let textContent = '';
let subText = '';
const cycleHint = formatCommand(Command.CYCLE_APPROVAL_MODE);
const yoloHint = formatCommand(Command.TOGGLE_YOLO);
switch (approvalMode) {
case ApprovalMode.AUTO_EDIT:
textColor = theme.status.warning;
textContent = APPROVAL_MODE_TEXT.AUTO_EDIT;
textContent = 'auto-accept edits';
subText = allowPlanMode
? APPROVAL_MODE_TEXT.HINT_SWITCH_TO_PLAN_MODE
: APPROVAL_MODE_TEXT.HINT_SWITCH_TO_MANUAL_MODE;
? `${cycleHint} to plan`
: `${cycleHint} to manual`;
break;
case ApprovalMode.PLAN:
textColor = theme.status.success;
textContent = APPROVAL_MODE_TEXT.PLAN;
subText = APPROVAL_MODE_TEXT.HINT_SWITCH_TO_MANUAL_MODE;
textContent = 'plan';
subText = `${cycleHint} to manual`;
break;
case ApprovalMode.YOLO:
textColor = theme.status.error;
textContent = APPROVAL_MODE_TEXT.YOLO;
subText = APPROVAL_MODE_TEXT.HINT_SWITCH_TO_YOLO_MODE;
textContent = 'YOLO';
subText = yoloHint;
break;
case ApprovalMode.DEFAULT:
default:
textColor = theme.text.accent;
textContent = '';
subText = APPROVAL_MODE_TEXT.HINT_SWITCH_TO_AUTO_EDIT_MODE;
subText = `${cycleHint} to accept edits`;
break;
}

View File

@@ -23,6 +23,7 @@ import { useKeypress, type Key } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { checkExhaustive } from '@google/gemini-cli-core';
import { TextInput } from './shared/TextInput.js';
import { formatCommand } from '../utils/keybindingUtils.js';
import { useTextBuffer } from './shared/text-buffer.js';
import { getCachedStringWidth } from '../utils/textUtils.js';
import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js';
@@ -252,7 +253,7 @@ const ReviewView: React.FC<ReviewViewProps> = ({
</Box>
<DialogFooter
primaryAction="Enter to submit"
navigationActions="Tab/Shift+Tab to edit answers"
navigationActions={`${formatCommand(Command.DIALOG_NEXT)}/${formatCommand(Command.DIALOG_PREV)} to edit answers`}
extraParts={extraParts}
/>
</Box>
@@ -1146,7 +1147,7 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
navigationActions={
questions.length > 1
? currentQuestion.type === 'text' || isEditingCustomOption
? 'Tab/Shift+Tab to switch questions'
? `${formatCommand(Command.DIALOG_NEXT)}/${formatCommand(Command.DIALOG_PREV)} to switch questions`
: '←/→ to switch questions'
: currentQuestion.type === 'text' || isEditingCustomOption
? undefined

View File

@@ -77,7 +77,7 @@ describe('Help Component', () => {
expect(output).toContain('Keyboard Shortcuts:');
expect(output).toContain('Ctrl+C');
expect(output).toContain('Ctrl+S');
expect(output).toContain('Page Up/Down');
expect(output).toContain('Page Up/Page Down');
unmount();
});
});

View File

@@ -10,6 +10,8 @@ import { theme } from '../semantic-colors.js';
import { type SlashCommand, CommandKind } from '../commands/types.js';
import { KEYBOARD_SHORTCUTS_URL } from '../constants.js';
import { sanitizeForDisplay } from '../utils/textUtils.js';
import { formatCommand } from '../utils/keybindingUtils.js';
import { Command } from '../../config/keyBindings.js';
interface Help {
commands: readonly SlashCommand[];
@@ -116,75 +118,75 @@ export const Help: React.FC<Help> = ({ commands }) => (
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Alt+Left/Right
{formatCommand(Command.MOVE_WORD_LEFT)}/
{formatCommand(Command.MOVE_WORD_RIGHT)}
</Text>{' '}
- Jump through words in the input
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Ctrl+C
{formatCommand(Command.QUIT)}
</Text>{' '}
- Quit application
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
{process.platform === 'win32' ? 'Ctrl+Enter' : 'Ctrl+J'}
{formatCommand(Command.NEWLINE)}
</Text>{' '}
{process.platform === 'linux'
? '- New line (Alt+Enter works for certain linux distros)'
: '- New line'}
- New line
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Ctrl+L
{formatCommand(Command.CLEAR_SCREEN)}
</Text>{' '}
- Clear the screen
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Ctrl+S
{formatCommand(Command.TOGGLE_COPY_MODE)}
</Text>{' '}
- Enter selection mode to copy text
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Ctrl+X
{formatCommand(Command.OPEN_EXTERNAL_EDITOR)}
</Text>{' '}
- Open input in external editor
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Ctrl+Y
{formatCommand(Command.TOGGLE_YOLO)}
</Text>{' '}
- Toggle YOLO mode
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Enter
{formatCommand(Command.SUBMIT)}
</Text>{' '}
- Send message
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Esc
{formatCommand(Command.ESCAPE)}
</Text>{' '}
- Cancel operation / Clear input (double press)
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Page Up/Down
{formatCommand(Command.PAGE_UP)}/{formatCommand(Command.PAGE_DOWN)}
</Text>{' '}
- Scroll page up/down
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Shift+Tab
{formatCommand(Command.CYCLE_APPROVAL_MODE)}
</Text>{' '}
- Toggle auto-accepting edits
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Up/Down
{formatCommand(Command.HISTORY_UP)}/
{formatCommand(Command.HISTORY_DOWN)}
</Text>{' '}
- Cycle through your prompt history
</Text>

View File

@@ -6,15 +6,18 @@
import { render } from '../../test-utils/render.js';
import { RawMarkdownIndicator } from './RawMarkdownIndicator.js';
import { describe, it, expect, afterEach } from 'vitest';
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
describe('RawMarkdownIndicator', () => {
const originalPlatform = process.platform;
beforeEach(() => vi.stubEnv('FORCE_GENERIC_KEYBINDING_HINTS', ''));
afterEach(() => {
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
vi.unstubAllEnvs();
});
it('renders correct key binding for darwin', async () => {
@@ -26,7 +29,7 @@ describe('RawMarkdownIndicator', () => {
);
await waitUntilReady();
expect(lastFrame()).toContain('raw markdown mode');
expect(lastFrame()).toContain('option+m to toggle');
expect(lastFrame()).toContain('Option+M to toggle');
unmount();
});
@@ -39,7 +42,7 @@ describe('RawMarkdownIndicator', () => {
);
await waitUntilReady();
expect(lastFrame()).toContain('raw markdown mode');
expect(lastFrame()).toContain('alt+m to toggle');
expect(lastFrame()).toContain('Alt+M to toggle');
unmount();
});
});

View File

@@ -7,9 +7,11 @@
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { formatCommand } from '../utils/keybindingUtils.js';
import { Command } from '../../config/keyBindings.js';
export const RawMarkdownIndicator: React.FC = () => {
const modKey = process.platform === 'darwin' ? 'option+m' : 'alt+m';
const modKey = formatCommand(Command.TOGGLE_MARKDOWN);
return (
<Box>
<Text>

View File

@@ -4,17 +4,20 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
import { renderWithProviders } from '../../test-utils/render.js';
import { ShortcutsHelp } from './ShortcutsHelp.js';
describe('ShortcutsHelp', () => {
const originalPlatform = process.platform;
beforeEach(() => vi.stubEnv('FORCE_GENERIC_KEYBINDING_HINTS', ''));
afterEach(() => {
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
@@ -52,10 +55,10 @@ describe('ShortcutsHelp', () => {
},
);
it('always shows Tab Tab focus UI shortcut', async () => {
it('always shows Tab focus UI shortcut', async () => {
const rendered = renderWithProviders(<ShortcutsHelp />);
await rendered.waitUntilReady();
expect(rendered.lastFrame()).toContain('Tab Tab');
expect(rendered.lastFrame()).toContain('Tab focus UI');
rendered.unmount();
});
});

View File

@@ -10,29 +10,41 @@ import { theme } from '../semantic-colors.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { SectionHeader } from './shared/SectionHeader.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { Command } from '../../config/keyBindings.js';
import { formatCommand } from '../utils/keybindingUtils.js';
type ShortcutItem = {
key: string;
description: string;
};
const buildShortcutItems = (): ShortcutItem[] => {
const isMac = process.platform === 'darwin';
const altLabel = isMac ? 'Option' : 'Alt';
return [
{ key: '!', description: 'shell mode' },
{ key: '@', description: 'select file or folder' },
{ key: 'Esc Esc', description: 'clear & rewind' },
{ key: 'Tab Tab', description: 'focus UI' },
{ key: 'Ctrl+Y', description: 'YOLO mode' },
{ key: 'Shift+Tab', description: 'cycle mode' },
{ key: 'Ctrl+V', description: 'paste images' },
{ key: `${altLabel}+M`, description: 'raw markdown mode' },
{ key: 'Ctrl+R', description: 'reverse-search history' },
{ key: 'Ctrl+X', description: 'open external editor' },
];
};
const buildShortcutItems = (): ShortcutItem[] => [
{ key: '!', description: 'shell mode' },
{ key: '@', description: 'select file or folder' },
{ key: formatCommand(Command.REWIND), description: 'clear & rewind' },
{ key: formatCommand(Command.FOCUS_SHELL_INPUT), description: 'focus UI' },
{ key: formatCommand(Command.TOGGLE_YOLO), description: 'YOLO mode' },
{
key: formatCommand(Command.CYCLE_APPROVAL_MODE),
description: 'cycle mode',
},
{
key: formatCommand(Command.PASTE_CLIPBOARD),
description: 'paste images',
},
{
key: formatCommand(Command.TOGGLE_MARKDOWN),
description: 'raw markdown mode',
},
{
key: formatCommand(Command.REVERSE_SEARCH),
description: 'reverse-search history',
},
{
key: formatCommand(Command.OPEN_EXTERNAL_EDITOR),
description: 'open external editor',
},
];
const Shortcut: React.FC<{ item: ShortcutItem }> = ({ item }) => (
<Box flexDirection="row">

View File

@@ -1,31 +1,31 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ApprovalModeIndicator > renders correctly for AUTO_EDIT mode 1`] = `
"auto-accept edits shift+tab to manual
"auto-accept edits Shift+Tab to manual
"
`;
exports[`ApprovalModeIndicator > renders correctly for AUTO_EDIT mode with plan enabled 1`] = `
"auto-accept edits shift+tab to plan
"auto-accept edits Shift+Tab to plan
"
`;
exports[`ApprovalModeIndicator > renders correctly for DEFAULT mode 1`] = `
"shift+tab to accept edits
"Shift+Tab to accept edits
"
`;
exports[`ApprovalModeIndicator > renders correctly for DEFAULT mode with plan enabled 1`] = `
"shift+tab to accept edits
"Shift+Tab to accept edits
"
`;
exports[`ApprovalModeIndicator > renders correctly for PLAN mode 1`] = `
"plan shift+tab to manual
"plan Shift+Tab to manual
"
`;
exports[`ApprovalModeIndicator > renders correctly for YOLO mode 1`] = `
"YOLO ctrl+y
"YOLO Ctrl+Y
"
`;

View File

@@ -115,6 +115,20 @@ Review your answers:
Tests → (not answered)
Docs → (not answered)
Enter to submit · / to edit answers · Esc to cancel
"
`;
exports[`AskUserDialog > allows navigating to Review tab and back 2`] = `
"← □ Tests │ □ Docs │ ≡ Review →
Review your answers:
⚠ You have 2 unanswered questions
Tests → (not answered)
Docs → (not answered)
Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel
"
`;
@@ -198,6 +212,20 @@ Review your answers:
License → (not answered)
README → (not answered)
Enter to submit · / to edit answers · Esc to cancel
"
`;
exports[`AskUserDialog > shows warning for unanswered questions on Review tab 2`] = `
"← □ License │ □ README │ ≡ Review →
Review your answers:
⚠ You have 2 unanswered questions
License → (not answered)
README → (not answered)
Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel
"
`;

View File

@@ -5,8 +5,8 @@ exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'linux' 1`] = `
Shortcuts See /help for more
! shell mode
@ select file or folder
Esc Esc clear & rewind
Tab Tab focus UI
Double Esc clear & rewind
Tab focus UI
Ctrl+Y YOLO mode
Shift+Tab cycle mode
Ctrl+V paste images
@@ -21,8 +21,8 @@ exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'mac' 1`] = `
Shortcuts See /help for more
! shell mode
@ select file or folder
Esc Esc clear & rewind
Tab Tab focus UI
Double Esc clear & rewind
Tab focus UI
Ctrl+Y YOLO mode
Shift+Tab cycle mode
Ctrl+V paste images
@@ -37,8 +37,8 @@ exports[`ShortcutsHelp > renders correctly in 'wide' mode on 'linux' 1`] = `
Shortcuts See /help for more
! shell mode Shift+Tab cycle mode Ctrl+V paste images
@ select file or folder Ctrl+Y YOLO mode Alt+M raw markdown mode
Esc Esc clear & rewind Ctrl+R reverse-search history Ctrl+X open external editor
Tab Tab focus UI
Double Esc clear & rewind Ctrl+R reverse-search history Ctrl+X open external editor
Tab focus UI
"
`;
@@ -47,7 +47,7 @@ exports[`ShortcutsHelp > renders correctly in 'wide' mode on 'mac' 1`] = `
Shortcuts See /help for more
! shell mode Shift+Tab cycle mode Ctrl+V paste images
@ select file or folder Ctrl+Y YOLO mode Option+M raw markdown mode
Esc Esc clear & rewind Ctrl+R reverse-search history Ctrl+X open external editor
Tab Tab focus UI
Double Esc clear & rewind Ctrl+R reverse-search history Ctrl+X open external editor
Tab focus UI
"
`;

View File

@@ -11,6 +11,8 @@ import { useMemo } from 'react';
import type { HistoryItemToolGroup } from '../../types.js';
import { Checklist } from '../Checklist.js';
import type { ChecklistItemData } from '../ChecklistItem.js';
import { formatCommand } from '../../utils/keybindingUtils.js';
import { Command } from '../../../config/keyBindings.js';
export const TodoTray: React.FC = () => {
const uiState = useUIState();
@@ -55,7 +57,7 @@ export const TodoTray: React.FC = () => {
title="Todo"
items={checklistItems}
isExpanded={uiState.showFullTodos}
toggleHint="ctrl+t to toggle"
toggleHint={`${formatCommand(Command.SHOW_FULL_TODOS)} to toggle`}
/>
);
};

View File

@@ -31,12 +31,6 @@ import { theme } from '../../semantic-colors.js';
import { useSettings } from '../../contexts/SettingsContext.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
import { formatCommand } from '../../utils/keybindingUtils.js';
import {
REDIRECTION_WARNING_NOTE_LABEL,
REDIRECTION_WARNING_NOTE_TEXT,
REDIRECTION_WARNING_TIP_LABEL,
REDIRECTION_WARNING_TIP_TEXT,
} from '../../textConstants.js';
import { AskUserDialog } from '../AskUserDialog.js';
import { ExitPlanModeDialog } from '../ExitPlanModeDialog.js';
import { WarningMessage } from './WarningMessage.js';
@@ -57,6 +51,11 @@ export interface ToolConfirmationMessageProps {
terminalWidth: number;
}
const REDIRECTION_WARNING_NOTE_LABEL = 'Note: ';
const REDIRECTION_WARNING_NOTE_TEXT =
'Command contains redirection which can be undesirable.';
const REDIRECTION_WARNING_TIP_LABEL = 'Tip: '; // Padded to align with "Note: "
export const ToolConfirmationMessage: React.FC<
ToolConfirmationMessageProps
> = ({
@@ -503,12 +502,12 @@ export const ToolConfirmationMessage: React.FC<
if (containsRedirection) {
// Calculate lines needed for Note and Tip
const safeWidth = Math.max(terminalWidth, 1);
const tipText = `Toggle auto-edit (${formatCommand(Command.CYCLE_APPROVAL_MODE)}) to allow redirection in the future.`;
const noteLength =
REDIRECTION_WARNING_NOTE_LABEL.length +
REDIRECTION_WARNING_NOTE_TEXT.length;
const tipLength =
REDIRECTION_WARNING_TIP_LABEL.length +
REDIRECTION_WARNING_TIP_TEXT.length;
const tipLength = REDIRECTION_WARNING_TIP_LABEL.length + tipText.length;
const noteLines = Math.ceil(noteLength / safeWidth);
const tipLines = Math.ceil(tipLength / safeWidth);
@@ -534,7 +533,7 @@ export const ToolConfirmationMessage: React.FC<
<Box>
<Text color={theme.border.default}>
<Text bold>{REDIRECTION_WARNING_TIP_LABEL}</Text>
{REDIRECTION_WARNING_TIP_TEXT}
{tipText}
</Text>
</Box>
</>

View File

@@ -2,7 +2,7 @@
exports[`<TodoTray /> (showFullTodos: false) > renders a todo list with long descriptions that wrap when full view is on 1`] = `
"──────────────────────────────────────────────────
Todo 1/2 completed (ctrl+t to toggle) » This i…
Todo 1/2 completed (Ctrl+T to toggle) » This i…
"
`;
@@ -14,25 +14,25 @@ exports[`<TodoTray /> (showFullTodos: false) > renders null when todo list is em
exports[`<TodoTray /> (showFullTodos: false) > renders the most recent todo list when multiple write_todos calls are in history 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
Todo 0/2 completed (ctrl+t to toggle) » Newer Task 2
Todo 0/2 completed (Ctrl+T to toggle) » Newer Task 2
"
`;
exports[`<TodoTray /> (showFullTodos: false) > renders when todos exist and one is in progress 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
Todo 1/3 completed (ctrl+t to toggle) » Task 2
Todo 1/3 completed (Ctrl+T to toggle) » Task 2
"
`;
exports[`<TodoTray /> (showFullTodos: false) > renders when todos exist but none are in progress 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
Todo 1/2 completed (ctrl+t to toggle)
Todo 1/2 completed (Ctrl+T to toggle)
"
`;
exports[`<TodoTray /> (showFullTodos: true) > renders a todo list with long descriptions that wrap when full view is on 1`] = `
"──────────────────────────────────────────────────
Todo 1/2 completed (ctrl+t to toggle)
Todo 1/2 completed (Ctrl+T to toggle)
» This is a very long description for a pending
task that should wrap around multiple lines
@@ -44,7 +44,7 @@ exports[`<TodoTray /> (showFullTodos: true) > renders a todo list with long desc
exports[`<TodoTray /> (showFullTodos: true) > renders full list when all todos are inactive 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
Todo 1/1 completed (ctrl+t to toggle)
Todo 1/1 completed (Ctrl+T to toggle)
✓ Task 1
✗ Task 2
@@ -57,7 +57,7 @@ exports[`<TodoTray /> (showFullTodos: true) > renders null when todo list is emp
exports[`<TodoTray /> (showFullTodos: true) > renders the most recent todo list when multiple write_todos calls are in history 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
Todo 0/2 completed (ctrl+t to toggle)
Todo 0/2 completed (Ctrl+T to toggle)
☐ Newer Task 1
» Newer Task 2
@@ -66,7 +66,7 @@ exports[`<TodoTray /> (showFullTodos: true) > renders the most recent todo list
exports[`<TodoTray /> (showFullTodos: true) > renders when todos exist and one is in progress 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
Todo 1/3 completed (ctrl+t to toggle)
Todo 1/3 completed (Ctrl+T to toggle)
☐ Pending Task
» Task 2
@@ -77,7 +77,7 @@ exports[`<TodoTray /> (showFullTodos: true) > renders when todos exist and one i
exports[`<TodoTray /> (showFullTodos: true) > renders when todos exist but none are in progress 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
Todo 1/2 completed (ctrl+t to toggle)
Todo 1/2 completed (Ctrl+T to toggle)
☐ Pending Task
✗ In Progress Task

View File

@@ -11,7 +11,7 @@ import { theme } from '../../semantic-colors.js';
export interface DialogFooterProps {
/** The main shortcut (e.g., "Enter to submit") */
primaryAction: string;
/** Secondary navigation shortcuts (e.g., "Tab/Shift+Tab to switch questions") */
/** Secondary navigation shortcuts (e.g., "Tab to switch questions") */
navigationActions?: string;
/** Exit shortcut (defaults to "Esc to cancel") */
cancelAction?: string;

View File

@@ -29,6 +29,8 @@ import {
cleanupTerminalOnExit,
terminalCapabilityManager,
} from '../utils/terminalCapabilityManager.js';
import { formatCommand } from '../utils/keybindingUtils.js';
import { Command } from '../../config/keyBindings.js';
vi.mock('@google/gemini-cli-core', async () => {
const actual = await vi.importActual('@google/gemini-cli-core');
@@ -99,8 +101,12 @@ describe('useSuspend', () => {
act(() => {
result.current.handleSuspend();
});
const suspendKey = formatCommand(Command.SUSPEND_APP);
const undoKey = formatCommand(Command.UNDO);
expect(handleWarning).toHaveBeenCalledWith(
'Press Ctrl+Z again to suspend. Undo has moved to Cmd + Z or Alt/Opt + Z.',
`Press ${suspendKey} again to suspend. Undo has moved to ${undoKey}.`,
);
act(() => {
@@ -190,8 +196,9 @@ describe('useSuspend', () => {
result.current.handleSuspend();
});
const suspendKey = formatCommand(Command.SUSPEND_APP);
expect(handleWarning).toHaveBeenCalledWith(
'Ctrl+Z suspend is not supported on Windows.',
`${suspendKey} suspend is not supported on Windows.`,
);
expect(killSpy).not.toHaveBeenCalled();
expect(cleanupTerminalOnExit).not.toHaveBeenCalled();

View File

@@ -20,6 +20,8 @@ import {
terminalCapabilityManager,
} from '../utils/terminalCapabilityManager.js';
import { WARNING_PROMPT_DURATION_MS } from '../constants.js';
import { formatCommand } from '../utils/keybindingUtils.js';
import { Command } from '../../config/keyBindings.js';
interface UseSuspendProps {
handleWarning: (message: string) => void;
@@ -59,10 +61,11 @@ export function useSuspend({
clearTimeout(ctrlZTimerRef.current);
ctrlZTimerRef.current = null;
}
const suspendKey = formatCommand(Command.SUSPEND_APP);
if (ctrlZPressCount > 1) {
setCtrlZPressCount(0);
if (process.platform === 'win32') {
handleWarning('Ctrl+Z suspend is not supported on Windows.');
handleWarning(`${suspendKey} suspend is not supported on Windows.`);
return;
}
@@ -130,8 +133,9 @@ export function useSuspend({
process.kill(0, 'SIGTSTP');
} else if (ctrlZPressCount > 0) {
const undoKey = formatCommand(Command.UNDO);
handleWarning(
'Press Ctrl+Z again to suspend. Undo has moved to Cmd + Z or Alt/Opt + Z.',
`Press ${suspendKey} again to suspend. Undo has moved to ${undoKey}.`,
);
ctrlZTimerRef.current = setTimeout(() => {
setCtrlZPressCount(0);

View File

@@ -16,5 +16,5 @@ export const REDIRECTION_WARNING_NOTE_LABEL = 'Note: ';
export const REDIRECTION_WARNING_NOTE_TEXT =
'Command contains redirection which can be undesirable.';
export const REDIRECTION_WARNING_TIP_LABEL = 'Tip: '; // Padded to align with "Note: "
export const REDIRECTION_WARNING_TIP_TEXT =
'Toggle auto-edit (Shift+Tab) to allow redirection in the future.';
export const getRedirectionWarningTipText = (shiftTabHint: string) =>
`Toggle auto-edit (${shiftTabHint}) to allow redirection in the future.`;

View File

@@ -7,47 +7,137 @@
import { describe, it, expect } from 'vitest';
import { formatKeyBinding, formatCommand } from './keybindingUtils.js';
import { Command } from '../../config/keyBindings.js';
import type { KeyBinding } from '../../config/keyBindings.js';
describe('keybindingUtils', () => {
describe('formatKeyBinding', () => {
it('formats simple keys', () => {
expect(formatKeyBinding({ key: 'a' })).toBe('A');
expect(formatKeyBinding({ key: 'return' })).toBe('Enter');
expect(formatKeyBinding({ key: 'escape' })).toBe('Esc');
});
const testCases: Array<{
name: string;
binding: KeyBinding;
expected: {
darwin: string;
win32: string;
linux: string;
default: string;
};
}> = [
{
name: 'simple key',
binding: { key: 'a' },
expected: { darwin: 'A', win32: 'A', linux: 'A', default: 'A' },
},
{
name: 'named key (return)',
binding: { key: 'return' },
expected: {
darwin: 'Enter',
win32: 'Enter',
linux: 'Enter',
default: 'Enter',
},
},
{
name: 'named key (escape)',
binding: { key: 'escape' },
expected: { darwin: 'Esc', win32: 'Esc', linux: 'Esc', default: 'Esc' },
},
{
name: 'ctrl modifier',
binding: { key: 'c', ctrl: true },
expected: {
darwin: 'Ctrl+C',
win32: 'Ctrl+C',
linux: 'Ctrl+C',
default: 'Ctrl+C',
},
},
{
name: 'cmd modifier',
binding: { key: 'z', cmd: true },
expected: {
darwin: 'Cmd+Z',
win32: 'Win+Z',
linux: 'Super+Z',
default: 'Cmd/Win+Z',
},
},
{
name: 'alt/option modifier',
binding: { key: 'left', alt: true },
expected: {
darwin: 'Option+Left',
win32: 'Alt+Left',
linux: 'Alt+Left',
default: 'Alt+Left',
},
},
{
name: 'shift modifier',
binding: { key: 'up', shift: true },
expected: {
darwin: 'Shift+Up',
win32: 'Shift+Up',
linux: 'Shift+Up',
default: 'Shift+Up',
},
},
{
name: 'multiple modifiers (ctrl+shift)',
binding: { key: 'z', ctrl: true, shift: true },
expected: {
darwin: 'Ctrl+Shift+Z',
win32: 'Ctrl+Shift+Z',
linux: 'Ctrl+Shift+Z',
default: 'Ctrl+Shift+Z',
},
},
{
name: 'all modifiers',
binding: { key: 'a', ctrl: true, alt: true, shift: true, cmd: true },
expected: {
darwin: 'Ctrl+Option+Shift+Cmd+A',
win32: 'Ctrl+Alt+Shift+Win+A',
linux: 'Ctrl+Alt+Shift+Super+A',
default: 'Ctrl+Alt+Shift+Cmd/Win+A',
},
},
];
it('formats modifiers', () => {
expect(formatKeyBinding({ key: 'c', ctrl: true })).toBe('Ctrl+C');
expect(formatKeyBinding({ key: 'z', cmd: true })).toBe('Cmd+Z');
expect(formatKeyBinding({ key: 'up', shift: true })).toBe('Shift+Up');
expect(formatKeyBinding({ key: 'left', alt: true })).toBe('Alt+Left');
});
it('formats multiple modifiers in order', () => {
expect(formatKeyBinding({ key: 'z', ctrl: true, shift: true })).toBe(
'Ctrl+Shift+Z',
);
expect(
formatKeyBinding({
key: 'a',
ctrl: true,
alt: true,
shift: true,
cmd: true,
}),
).toBe('Ctrl+Alt+Shift+Cmd+A');
testCases.forEach(({ name, binding, expected }) => {
describe(`${name}`, () => {
it('formats correctly for darwin', () => {
expect(formatKeyBinding(binding, 'darwin')).toBe(expected.darwin);
});
it('formats correctly for win32', () => {
expect(formatKeyBinding(binding, 'win32')).toBe(expected.win32);
});
it('formats correctly for linux', () => {
expect(formatKeyBinding(binding, 'linux')).toBe(expected.linux);
});
it('formats correctly for default', () => {
expect(formatKeyBinding(binding, 'default')).toBe(expected.default);
});
});
});
});
describe('formatCommand', () => {
it('formats default commands', () => {
expect(formatCommand(Command.QUIT)).toBe('Ctrl+C');
expect(formatCommand(Command.SUBMIT)).toBe('Enter');
expect(formatCommand(Command.TOGGLE_BACKGROUND_SHELL)).toBe('Ctrl+B');
it('formats default commands (using default platform behavior)', () => {
expect(formatCommand(Command.QUIT, undefined, 'default')).toBe('Ctrl+C');
expect(formatCommand(Command.SUBMIT, undefined, 'default')).toBe('Enter');
expect(
formatCommand(Command.TOGGLE_BACKGROUND_SHELL, undefined, 'default'),
).toBe('Ctrl+B');
});
it('returns empty string for unknown commands', () => {
expect(formatCommand('unknown.command' as unknown as Command)).toBe('');
expect(
formatCommand(
'unknown.command' as unknown as Command,
undefined,
'default',
),
).toBe('');
});
});
});

View File

@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import process from 'node:process';
import {
type Command,
type KeyBinding,
@@ -29,18 +30,62 @@ const KEY_NAME_MAP: Record<string, string> = {
end: 'End',
tab: 'Tab',
space: 'Space',
'double escape': 'Double Esc',
};
interface ModifierMap {
ctrl: string;
alt: string;
shift: string;
cmd: string;
}
const MODIFIER_MAPS: Record<string, ModifierMap> = {
darwin: {
ctrl: 'Ctrl',
alt: 'Option',
shift: 'Shift',
cmd: 'Cmd',
},
win32: {
ctrl: 'Ctrl',
alt: 'Alt',
shift: 'Shift',
cmd: 'Win',
},
linux: {
ctrl: 'Ctrl',
alt: 'Alt',
shift: 'Shift',
cmd: 'Super',
},
default: {
ctrl: 'Ctrl',
alt: 'Alt',
shift: 'Shift',
cmd: 'Cmd/Win',
},
};
/**
* Formats a single KeyBinding into a human-readable string (e.g., "Ctrl+C").
*/
export function formatKeyBinding(binding: KeyBinding): string {
export function formatKeyBinding(
binding: KeyBinding,
platform?: string,
): string {
const activePlatform =
platform ??
(process.env['FORCE_GENERIC_KEYBINDING_HINTS']
? 'default'
: process.platform);
const modMap = MODIFIER_MAPS[activePlatform] || MODIFIER_MAPS['default'];
const parts: string[] = [];
if (binding.ctrl) parts.push('Ctrl');
if (binding.alt) parts.push('Alt');
if (binding.shift) parts.push('Shift');
if (binding.cmd) parts.push('Cmd');
if (binding.ctrl) parts.push(modMap.ctrl);
if (binding.alt) parts.push(modMap.alt);
if (binding.shift) parts.push(modMap.shift);
if (binding.cmd) parts.push(modMap.cmd);
const keyName = KEY_NAME_MAP[binding.key] || binding.key.toUpperCase();
parts.push(keyName);
@@ -54,6 +99,7 @@ export function formatKeyBinding(binding: KeyBinding): string {
export function formatCommand(
command: Command,
config: KeyBindingConfig = defaultKeyBindings,
platform?: string,
): string {
const bindings = config[command];
if (!bindings || bindings.length === 0) {
@@ -61,5 +107,5 @@ export function formatCommand(
}
// Use the first binding as the primary one for display
return formatKeyBinding(bindings[0]);
return formatKeyBinding(bindings[0], platform);
}