From 6d607a5953fafb80d12c2bbd974f13cdb7041e58 Mon Sep 17 00:00:00 2001 From: Tommaso Sciortino Date: Fri, 6 Mar 2026 18:34:26 +0000 Subject: [PATCH] feat(ui): dynamically generate all keybinding hints (#21346) --- docs/reference/keyboard-shortcuts.md | 168 +++++++++--------- .../ui/components/ApprovalModeIndicator.tsx | 31 ++-- .../cli/src/ui/components/AskUserDialog.tsx | 5 +- packages/cli/src/ui/components/Help.test.tsx | 2 +- packages/cli/src/ui/components/Help.tsx | 32 ++-- .../components/RawMarkdownIndicator.test.tsx | 9 +- .../ui/components/RawMarkdownIndicator.tsx | 4 +- .../src/ui/components/ShortcutsHelp.test.tsx | 9 +- .../cli/src/ui/components/ShortcutsHelp.tsx | 46 +++-- .../ApprovalModeIndicator.test.tsx.snap | 12 +- .../__snapshots__/AskUserDialog.test.tsx.snap | 28 +++ .../__snapshots__/ShortcutsHelp.test.tsx.snap | 16 +- .../cli/src/ui/components/messages/Todo.tsx | 4 +- .../messages/ToolConfirmationMessage.tsx | 19 +- .../messages/__snapshots__/Todo.test.tsx.snap | 18 +- .../src/ui/components/shared/DialogFooter.tsx | 2 +- packages/cli/src/ui/hooks/useSuspend.test.ts | 11 +- packages/cli/src/ui/hooks/useSuspend.ts | 8 +- packages/cli/src/ui/textConstants.ts | 4 +- .../cli/src/ui/utils/keybindingUtils.test.ts | 150 ++++++++++++---- packages/cli/src/ui/utils/keybindingUtils.ts | 58 +++++- packages/cli/test-setup.ts | 3 + scripts/generate-keybindings-doc.ts | 71 +------- .../tests/generate-keybindings-doc.test.ts | 7 +- 24 files changed, 424 insertions(+), 293 deletions(-) diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index 5ad55a2c74..7b396b73d4 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -8,119 +8,119 @@ available combinations. #### Basic Controls -| Action | Keys | -| --------------------------------------------------------------- | --------------------- | -| Confirm the current selection or choice. | `Enter` | -| Dismiss dialogs or cancel the current focus. | `Esc`
`Ctrl + [` | -| Cancel the current request or quit the CLI when input is empty. | `Ctrl + C` | -| Exit the CLI when the input buffer is empty. | `Ctrl + D` | +| Action | Keys | +| --------------------------------------------------------------- | ------------------- | +| Confirm the current selection or choice. | `Enter` | +| Dismiss dialogs or cancel the current focus. | `Esc`
`Ctrl+[` | +| Cancel the current request or quit the CLI when input is empty. | `Ctrl+C` | +| Exit the CLI when the input buffer is empty. | `Ctrl+D` | #### Cursor Movement -| Action | Keys | -| ------------------------------------------- | ------------------------------------------------------------ | -| Move the cursor to the start of the line. | `Ctrl + A`
`Home` | -| Move the cursor to the end of the line. | `Ctrl + E`
`End` | -| Move the cursor up one line. | `Up Arrow` | -| Move the cursor down one line. | `Down Arrow` | -| Move the cursor one character to the left. | `Left Arrow` | -| Move the cursor one character to the right. | `Right Arrow`
`Ctrl + F` | -| Move the cursor one word to the left. | `Ctrl + Left Arrow`
`Alt + Left Arrow`
`Alt + B` | -| Move the cursor one word to the right. | `Ctrl + Right Arrow`
`Alt + Right Arrow`
`Alt + F` | +| Action | Keys | +| ------------------------------------------- | ------------------------------------------ | +| Move the cursor to the start of the line. | `Ctrl+A`
`Home` | +| Move the cursor to the end of the line. | `Ctrl+E`
`End` | +| Move the cursor up one line. | `Up` | +| Move the cursor down one line. | `Down` | +| Move the cursor one character to the left. | `Left` | +| Move the cursor one character to the right. | `Right`
`Ctrl+F` | +| Move the cursor one word to the left. | `Ctrl+Left`
`Alt+Left`
`Alt+B` | +| Move the cursor one word to the right. | `Ctrl+Right`
`Alt+Right`
`Alt+F` | #### Editing -| Action | Keys | -| ------------------------------------------------ | ---------------------------------------------------------------- | -| Delete from the cursor to the end of the line. | `Ctrl + K` | -| Delete from the cursor to the start of the line. | `Ctrl + U` | -| Clear all text in the input field. | `Ctrl + C` | -| Delete the previous word. | `Ctrl + Backspace`
`Alt + Backspace`
`Ctrl + W` | -| Delete the next word. | `Ctrl + Delete`
`Alt + Delete`
`Alt + D` | -| Delete the character to the left. | `Backspace`
`Ctrl + H` | -| Delete the character to the right. | `Delete`
`Ctrl + D` | -| Undo the most recent text edit. | `Cmd + Z`
`Alt + Z` | -| Redo the most recent undone text edit. | `Shift + Ctrl + Z`
`Shift + Cmd + Z`
`Shift + Alt + Z` | +| Action | Keys | +| ------------------------------------------------ | -------------------------------------------------------- | +| Delete from the cursor to the end of the line. | `Ctrl+K` | +| Delete from the cursor to the start of the line. | `Ctrl+U` | +| Clear all text in the input field. | `Ctrl+C` | +| Delete the previous word. | `Ctrl+Backspace`
`Alt+Backspace`
`Ctrl+W` | +| Delete the next word. | `Ctrl+Delete`
`Alt+Delete`
`Alt+D` | +| Delete the character to the left. | `Backspace`
`Ctrl+H` | +| Delete the character to the right. | `Delete`
`Ctrl+D` | +| Undo the most recent text edit. | `Cmd/Win+Z`
`Alt+Z` | +| Redo the most recent undone text edit. | `Ctrl+Shift+Z`
`Shift+Cmd/Win+Z`
`Alt+Shift+Z` | #### Scrolling -| Action | Keys | -| ------------------------ | --------------------------------- | -| Scroll content up. | `Shift + Up Arrow` | -| Scroll content down. | `Shift + Down Arrow` | -| Scroll to the top. | `Ctrl + Home`
`Shift + Home` | -| Scroll to the bottom. | `Ctrl + End`
`Shift + End` | -| Scroll up by one page. | `Page Up` | -| Scroll down by one page. | `Page Down` | +| Action | Keys | +| ------------------------ | ----------------------------- | +| Scroll content up. | `Shift+Up` | +| Scroll content down. | `Shift+Down` | +| Scroll to the top. | `Ctrl+Home`
`Shift+Home` | +| Scroll to the bottom. | `Ctrl+End`
`Shift+End` | +| Scroll up by one page. | `Page Up` | +| Scroll down by one page. | `Page Down` | #### History & Search | Action | Keys | | -------------------------------------------- | ------------ | -| Show the previous entry in history. | `Ctrl + P` | -| Show the next entry in history. | `Ctrl + N` | -| Start reverse search through history. | `Ctrl + R` | +| Show the previous entry in history. | `Ctrl+P` | +| Show the next entry in history. | `Ctrl+N` | +| Start reverse search through history. | `Ctrl+R` | | Submit the selected reverse-search match. | `Enter` | | Accept a suggestion while reverse searching. | `Tab` | | Browse and rewind previous interactions. | `Double Esc` | #### Navigation -| Action | Keys | -| -------------------------------------------------- | --------------------- | -| Move selection up in lists. | `Up Arrow` | -| Move selection down in lists. | `Down Arrow` | -| Move up within dialog options. | `Up Arrow`
`K` | -| Move down within dialog options. | `Down Arrow`
`J` | -| Move to the next item or question in a dialog. | `Tab` | -| Move to the previous item or question in a dialog. | `Shift + Tab` | +| Action | Keys | +| -------------------------------------------------- | --------------- | +| Move selection up in lists. | `Up` | +| Move selection down in lists. | `Down` | +| Move up within dialog options. | `Up`
`K` | +| Move down within dialog options. | `Down`
`J` | +| Move to the next item or question in a dialog. | `Tab` | +| Move to the previous item or question in a dialog. | `Shift+Tab` | #### Suggestions & Completions -| Action | Keys | -| --------------------------------------- | ---------------------------- | -| Accept the inline suggestion. | `Tab`
`Enter` | -| Move to the previous completion option. | `Up Arrow`
`Ctrl + P` | -| Move to the next completion option. | `Down Arrow`
`Ctrl + N` | -| Expand an inline suggestion. | `Right Arrow` | -| Collapse an inline suggestion. | `Left Arrow` | +| Action | Keys | +| --------------------------------------- | -------------------- | +| Accept the inline suggestion. | `Tab`
`Enter` | +| Move to the previous completion option. | `Up`
`Ctrl+P` | +| Move to the next completion option. | `Down`
`Ctrl+N` | +| Expand an inline suggestion. | `Right` | +| Collapse an inline suggestion. | `Left` | #### Text Input -| Action | Keys | -| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -| Submit the current prompt. | `Enter` | -| Insert a newline without submitting. | `Ctrl + Enter`
`Cmd + Enter`
`Alt + Enter`
`Shift + Enter`
`Ctrl + J` | -| Open the current prompt or the plan in an external editor. | `Ctrl + X` | -| Paste from the clipboard. | `Ctrl + V`
`Cmd + V`
`Alt + V` | +| Action | Keys | +| ---------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| Submit the current prompt. | `Enter` | +| Insert a newline without submitting. | `Ctrl+Enter`
`Cmd/Win+Enter`
`Alt+Enter`
`Shift+Enter`
`Ctrl+J` | +| Open the current prompt or the plan in an external editor. | `Ctrl+X` | +| Paste from the clipboard. | `Ctrl+V`
`Cmd/Win+V`
`Alt+V` | #### App Controls -| Action | Keys | -| -------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | -| Toggle detailed error information. | `F12` | -| Toggle the full TODO list. | `Ctrl + T` | -| Show IDE context details. | `Ctrl + G` | -| Toggle Markdown rendering. | `Alt + M` | -| Toggle copy mode when in alternate buffer mode. | `Ctrl + S` | -| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` | -| Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy. | `Shift + Tab` | -| Expand and collapse blocks of content when not in alternate buffer mode. | `Ctrl + O` | -| Expand or collapse a paste placeholder when cursor is over placeholder. | `Ctrl + O` | -| Toggle current background shell visibility. | `Ctrl + B` | -| Toggle background shell list. | `Ctrl + L` | -| Kill the active background shell. | `Ctrl + K` | -| Confirm selection in background shell list. | `Enter` | -| Dismiss background shell list. | `Esc` | -| Move focus from background shell to Gemini. | `Shift + Tab` | -| Move focus from background shell list to Gemini. | `Tab` | -| Show warning when trying to move focus away from background shell. | `Tab` | -| Show warning when trying to move focus away from shell input. | `Tab` | -| Move focus from Gemini to the active shell. | `Tab` | -| Move focus from the shell back to Gemini. | `Shift + Tab` | -| Clear the terminal screen and redraw the UI. | `Ctrl + L` | -| Restart the application. | `R`
`Shift + R` | -| Suspend the CLI and move it to the background. | `Ctrl + Z` | +| Action | Keys | +| -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | +| Toggle detailed error information. | `F12` | +| Toggle the full TODO list. | `Ctrl+T` | +| Show IDE context details. | `Ctrl+G` | +| Toggle Markdown rendering. | `Alt+M` | +| Toggle copy mode when in alternate buffer mode. | `Ctrl+S` | +| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl+Y` | +| Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy. | `Shift+Tab` | +| Expand and collapse blocks of content when not in alternate buffer mode. | `Ctrl+O` | +| Expand or collapse a paste placeholder when cursor is over placeholder. | `Ctrl+O` | +| Toggle current background shell visibility. | `Ctrl+B` | +| Toggle background shell list. | `Ctrl+L` | +| Kill the active background shell. | `Ctrl+K` | +| Confirm selection in background shell list. | `Enter` | +| Dismiss background shell list. | `Esc` | +| Move focus from background shell to Gemini. | `Shift+Tab` | +| Move focus from background shell list to Gemini. | `Tab` | +| Show warning when trying to move focus away from background shell. | `Tab` | +| Show warning when trying to move focus away from shell input. | `Tab` | +| Move focus from Gemini to the active shell. | `Tab` | +| Move focus from the shell back to Gemini. | `Shift+Tab` | +| Clear the terminal screen and redraw the UI. | `Ctrl+L` | +| Restart the application. | `R`
`Shift+R` | +| Suspend the CLI and move it to the background. | `Ctrl+Z` | diff --git a/packages/cli/src/ui/components/ApprovalModeIndicator.tsx b/packages/cli/src/ui/components/ApprovalModeIndicator.tsx index b5a981ac7a..4eaf3f18a4 100644 --- a/packages/cli/src/ui/components/ApprovalModeIndicator.tsx +++ b/packages/cli/src/ui/components/ApprovalModeIndicator.tsx @@ -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 = ({ approvalMode, allowPlanMode, @@ -32,29 +24,32 @@ export const ApprovalModeIndicator: React.FC = ({ 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; } diff --git a/packages/cli/src/ui/components/AskUserDialog.tsx b/packages/cli/src/ui/components/AskUserDialog.tsx index 9606513510..488a00b45e 100644 --- a/packages/cli/src/ui/components/AskUserDialog.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.tsx @@ -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 = ({ @@ -1146,7 +1147,7 @@ export const AskUserDialog: React.FC = ({ 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 diff --git a/packages/cli/src/ui/components/Help.test.tsx b/packages/cli/src/ui/components/Help.test.tsx index e16364a7ea..666593f04f 100644 --- a/packages/cli/src/ui/components/Help.test.tsx +++ b/packages/cli/src/ui/components/Help.test.tsx @@ -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(); }); }); diff --git a/packages/cli/src/ui/components/Help.tsx b/packages/cli/src/ui/components/Help.tsx index 762b8e9ff3..7f032b4e47 100644 --- a/packages/cli/src/ui/components/Help.tsx +++ b/packages/cli/src/ui/components/Help.tsx @@ -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 = ({ commands }) => ( - Alt+Left/Right + {formatCommand(Command.MOVE_WORD_LEFT)}/ + {formatCommand(Command.MOVE_WORD_RIGHT)} {' '} - Jump through words in the input - Ctrl+C + {formatCommand(Command.QUIT)} {' '} - Quit application - {process.platform === 'win32' ? 'Ctrl+Enter' : 'Ctrl+J'} + {formatCommand(Command.NEWLINE)} {' '} - {process.platform === 'linux' - ? '- New line (Alt+Enter works for certain linux distros)' - : '- New line'} + - New line - Ctrl+L + {formatCommand(Command.CLEAR_SCREEN)} {' '} - Clear the screen - Ctrl+S + {formatCommand(Command.TOGGLE_COPY_MODE)} {' '} - Enter selection mode to copy text - Ctrl+X + {formatCommand(Command.OPEN_EXTERNAL_EDITOR)} {' '} - Open input in external editor - Ctrl+Y + {formatCommand(Command.TOGGLE_YOLO)} {' '} - Toggle YOLO mode - Enter + {formatCommand(Command.SUBMIT)} {' '} - Send message - Esc + {formatCommand(Command.ESCAPE)} {' '} - Cancel operation / Clear input (double press) - Page Up/Down + {formatCommand(Command.PAGE_UP)}/{formatCommand(Command.PAGE_DOWN)} {' '} - Scroll page up/down - Shift+Tab + {formatCommand(Command.CYCLE_APPROVAL_MODE)} {' '} - Toggle auto-accepting edits - Up/Down + {formatCommand(Command.HISTORY_UP)}/ + {formatCommand(Command.HISTORY_DOWN)} {' '} - Cycle through your prompt history diff --git a/packages/cli/src/ui/components/RawMarkdownIndicator.test.tsx b/packages/cli/src/ui/components/RawMarkdownIndicator.test.tsx index fd74b9281e..0ae721ccd5 100644 --- a/packages/cli/src/ui/components/RawMarkdownIndicator.test.tsx +++ b/packages/cli/src/ui/components/RawMarkdownIndicator.test.tsx @@ -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(); }); }); diff --git a/packages/cli/src/ui/components/RawMarkdownIndicator.tsx b/packages/cli/src/ui/components/RawMarkdownIndicator.tsx index c47b35f244..922c30a36d 100644 --- a/packages/cli/src/ui/components/RawMarkdownIndicator.tsx +++ b/packages/cli/src/ui/components/RawMarkdownIndicator.tsx @@ -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 ( diff --git a/packages/cli/src/ui/components/ShortcutsHelp.test.tsx b/packages/cli/src/ui/components/ShortcutsHelp.test.tsx index 779907ce5a..dab39bfcbb 100644 --- a/packages/cli/src/ui/components/ShortcutsHelp.test.tsx +++ b/packages/cli/src/ui/components/ShortcutsHelp.test.tsx @@ -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(); await rendered.waitUntilReady(); - expect(rendered.lastFrame()).toContain('Tab Tab'); + expect(rendered.lastFrame()).toContain('Tab focus UI'); rendered.unmount(); }); }); diff --git a/packages/cli/src/ui/components/ShortcutsHelp.tsx b/packages/cli/src/ui/components/ShortcutsHelp.tsx index 63183ab922..149e4ddea9 100644 --- a/packages/cli/src/ui/components/ShortcutsHelp.tsx +++ b/packages/cli/src/ui/components/ShortcutsHelp.tsx @@ -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 }) => ( diff --git a/packages/cli/src/ui/components/__snapshots__/ApprovalModeIndicator.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ApprovalModeIndicator.test.tsx.snap index 2544f7322e..8ddb141478 100644 --- a/packages/cli/src/ui/components/__snapshots__/ApprovalModeIndicator.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ApprovalModeIndicator.test.tsx.snap @@ -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 " `; diff --git a/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap index 06f509f1f6..2e115ef12c 100644 --- a/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap @@ -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 " `; diff --git a/packages/cli/src/ui/components/__snapshots__/ShortcutsHelp.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ShortcutsHelp.test.tsx.snap index 70d2cba48d..9e65c72f69 100644 --- a/packages/cli/src/ui/components/__snapshots__/ShortcutsHelp.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ShortcutsHelp.test.tsx.snap @@ -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 " `; diff --git a/packages/cli/src/ui/components/messages/Todo.tsx b/packages/cli/src/ui/components/messages/Todo.tsx index 4f2b95fd3c..786fe5e2f1 100644 --- a/packages/cli/src/ui/components/messages/Todo.tsx +++ b/packages/cli/src/ui/components/messages/Todo.tsx @@ -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`} /> ); }; diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index b60dd4dc8b..b97a29565b 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -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< {REDIRECTION_WARNING_TIP_LABEL} - {REDIRECTION_WARNING_TIP_TEXT} + {tipText} diff --git a/packages/cli/src/ui/components/messages/__snapshots__/Todo.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/Todo.test.tsx.snap index 86ba095192..554808e830 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/Todo.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/Todo.test.tsx.snap @@ -2,7 +2,7 @@ exports[` (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[` (showFullTodos: false) > renders null when todo list is em exports[` (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[` (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[` (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[` (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[` (showFullTodos: true) > renders a todo list with long desc exports[` (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[` (showFullTodos: true) > renders null when todo list is emp exports[` (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[` (showFullTodos: true) > renders the most recent todo list exports[` (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[` (showFullTodos: true) > renders when todos exist and one i exports[` (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 diff --git a/packages/cli/src/ui/components/shared/DialogFooter.tsx b/packages/cli/src/ui/components/shared/DialogFooter.tsx index 7411a91611..ee16d43650 100644 --- a/packages/cli/src/ui/components/shared/DialogFooter.tsx +++ b/packages/cli/src/ui/components/shared/DialogFooter.tsx @@ -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; diff --git a/packages/cli/src/ui/hooks/useSuspend.test.ts b/packages/cli/src/ui/hooks/useSuspend.test.ts index 9aa90d16b3..1d0b34b1a3 100644 --- a/packages/cli/src/ui/hooks/useSuspend.test.ts +++ b/packages/cli/src/ui/hooks/useSuspend.test.ts @@ -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(); diff --git a/packages/cli/src/ui/hooks/useSuspend.ts b/packages/cli/src/ui/hooks/useSuspend.ts index 9c986d30d6..7d295b4450 100644 --- a/packages/cli/src/ui/hooks/useSuspend.ts +++ b/packages/cli/src/ui/hooks/useSuspend.ts @@ -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); diff --git a/packages/cli/src/ui/textConstants.ts b/packages/cli/src/ui/textConstants.ts index a7ea77de79..00be0623d2 100644 --- a/packages/cli/src/ui/textConstants.ts +++ b/packages/cli/src/ui/textConstants.ts @@ -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.`; diff --git a/packages/cli/src/ui/utils/keybindingUtils.test.ts b/packages/cli/src/ui/utils/keybindingUtils.test.ts index cdee917332..4dfe2f814c 100644 --- a/packages/cli/src/ui/utils/keybindingUtils.test.ts +++ b/packages/cli/src/ui/utils/keybindingUtils.test.ts @@ -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(''); }); }); }); diff --git a/packages/cli/src/ui/utils/keybindingUtils.ts b/packages/cli/src/ui/utils/keybindingUtils.ts index 43e3d4e1fd..a084b9c68c 100644 --- a/packages/cli/src/ui/utils/keybindingUtils.ts +++ b/packages/cli/src/ui/utils/keybindingUtils.ts @@ -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 = { end: 'End', tab: 'Tab', space: 'Space', + 'double escape': 'Double Esc', +}; + +interface ModifierMap { + ctrl: string; + alt: string; + shift: string; + cmd: string; +} + +const MODIFIER_MAPS: Record = { + 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); } diff --git a/packages/cli/test-setup.ts b/packages/cli/test-setup.ts index 1b7645c3f4..8d055bc63d 100644 --- a/packages/cli/test-setup.ts +++ b/packages/cli/test-setup.ts @@ -27,6 +27,9 @@ if (process.env.NO_COLOR !== undefined) { // Force true color output for ink so that snapshots always include color information. process.env.FORCE_COLOR = '3'; +// Force generic keybinding hints to ensure stable snapshots across different operating systems. +process.env.FORCE_GENERIC_KEYBINDING_HINTS = 'true'; + import './src/test-utils/customMatchers.js'; let consoleErrorSpy: vi.SpyInstance; diff --git a/scripts/generate-keybindings-doc.ts b/scripts/generate-keybindings-doc.ts index eea7ef9af3..19f07198ac 100644 --- a/scripts/generate-keybindings-doc.ts +++ b/scripts/generate-keybindings-doc.ts @@ -24,36 +24,7 @@ const START_MARKER = ''; const END_MARKER = ''; const OUTPUT_RELATIVE_PATH = ['docs', 'reference', 'keyboard-shortcuts.md']; -const KEY_NAME_OVERRIDES: Record = { - return: 'Enter', - escape: 'Esc', - 'double escape': 'Double Esc', - tab: 'Tab', - backspace: 'Backspace', - delete: 'Delete', - up: 'Up Arrow', - down: 'Down Arrow', - left: 'Left Arrow', - right: 'Right Arrow', - home: 'Home', - end: 'End', - pageup: 'Page Up', - pagedown: 'Page Down', - clear: 'Clear', - insert: 'Insert', - f1: 'F1', - f2: 'F2', - f3: 'F3', - f4: 'F4', - f5: 'F5', - f6: 'F6', - f7: 'F7', - f8: 'F8', - f9: 'F9', - f10: 'F10', - f11: 'F11', - f12: 'F12', -}; +import { formatKeyBinding } from '../packages/cli/src/ui/utils/keybindingUtils.js'; export interface KeybindingDocCommand { description: string; @@ -143,52 +114,16 @@ function formatBindings(bindings: readonly KeyBinding[]): string[] { const results: string[] = []; for (const binding of bindings) { - const label = formatBinding(binding); + const label = formatKeyBinding(binding, 'default'); if (label && !seen.has(label)) { seen.add(label); - results.push(label); + results.push(`\`${label}\``); } } return results; } -function formatBinding(binding: KeyBinding): string { - const modifiers: string[] = []; - if (binding.shift) modifiers.push('Shift'); - if (binding.alt) modifiers.push('Alt'); - if (binding.ctrl) modifiers.push('Ctrl'); - if (binding.cmd) modifiers.push('Cmd'); - - const keyName = formatKeyName(binding.key); - if (!keyName) { - return ''; - } - - const segments = [...modifiers, keyName].filter(Boolean); - let combo = segments.join(' + '); - - const restrictions: string[] = []; - if (binding.shift === false) restrictions.push('Shift'); - if (binding.alt === false) restrictions.push('Alt'); - if (binding.ctrl === false) restrictions.push('Ctrl'); - if (binding.cmd === false) restrictions.push('Cmd'); - - if (restrictions.length > 0) { - combo = `${combo} (no ${restrictions.join(', ')})`; - } - - return combo ? `\`${combo}\`` : ''; -} - -function formatKeyName(key: string): string { - const normalized = key.toLowerCase(); - if (KEY_NAME_OVERRIDES[normalized]) { - return KEY_NAME_OVERRIDES[normalized]; - } - return key.length === 1 ? key.toUpperCase() : key; -} - if (process.argv[1]) { const entryUrl = pathToFileURL(path.resolve(process.argv[1])).href; if (entryUrl === import.meta.url) { diff --git a/scripts/tests/generate-keybindings-doc.test.ts b/scripts/tests/generate-keybindings-doc.test.ts index 68a166609b..c669fed02e 100644 --- a/scripts/tests/generate-keybindings-doc.test.ts +++ b/scripts/tests/generate-keybindings-doc.test.ts @@ -57,12 +57,11 @@ describe('generate-keybindings-doc', () => { const markdown = renderDocumentation(sections); expect(markdown).toContain('#### Custom Controls'); expect(markdown).toContain('Trigger custom action.'); - expect(markdown).toContain('`Ctrl + X`'); + expect(markdown).toContain('`Ctrl+X`'); expect(markdown).toContain('Submit with Enter if no modifiers are held.'); - expect(markdown).toContain('`Enter (no Shift, Ctrl)`'); + expect(markdown).toContain('`Enter`'); expect(markdown).toContain('#### Navigation'); expect(markdown).toContain('Move up through results.'); - expect(markdown).toContain('`Up Arrow (no Shift)`'); - expect(markdown).toContain('`Ctrl + P (no Shift)`'); + expect(markdown).toContain('`Up`
`Ctrl+P`'); }); });