From aa8428d60bdebf1b4a05148f6132a6512d5bb3d9 Mon Sep 17 00:00:00 2001 From: Br1an67 <932039080@qq.com> Date: Thu, 19 Mar 2026 13:49:37 +0800 Subject: [PATCH] fix: adapt keyboard shortcut display for macOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #2227 On macOS, keyboard shortcut hints now use native modifier symbols (⌃ for Ctrl, ⌘ for Cmd, ⌥ for Alt, ⇧ for Shift) instead of the generic "ctrl+", "cmd+" text format. - Add formatShortcut() utility in shortcutFormatter.ts - Apply to KeyboardShortcuts panel and retry hint messages - Non-macOS platforms are unaffected --- .../src/ui/components/KeyboardShortcuts.tsx | 4 +- packages/cli/src/ui/hooks/useGeminiStream.ts | 14 +++-- .../src/ui/utils/shortcutFormatter.test.ts | 38 ++++++++++++++ .../cli/src/ui/utils/shortcutFormatter.ts | 51 +++++++++++++++++++ 4 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/ui/utils/shortcutFormatter.test.ts create mode 100644 packages/cli/src/ui/utils/shortcutFormatter.ts diff --git a/packages/cli/src/ui/components/KeyboardShortcuts.tsx b/packages/cli/src/ui/components/KeyboardShortcuts.tsx index 860342c481b..c2090a72900 100644 --- a/packages/cli/src/ui/components/KeyboardShortcuts.tsx +++ b/packages/cli/src/ui/components/KeyboardShortcuts.tsx @@ -9,6 +9,7 @@ import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { t } from '../../i18n/index.js'; +import { formatShortcut } from '../utils/shortcutFormatter.js'; interface Shortcut { key: string; @@ -47,7 +48,8 @@ const getShortcuts = (): Shortcut[] => [ const ShortcutItem: React.FC<{ shortcut: Shortcut }> = ({ shortcut }) => ( - {shortcut.key} {shortcut.description} + {formatShortcut(shortcut.key)}{' '} + {shortcut.description} ); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 9d415615928..6ac7f1a9b92 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -77,6 +77,7 @@ import { useSessionStats } from '../contexts/SessionContext.js'; import type { LoadedSettings } from '../../config/settings.js'; import { t } from '../../i18n/index.js'; import { useDualOutput } from '../../dualOutput/DualOutputContext.js'; +import { formatShortcut } from '../utils/shortcutFormatter.js'; const debugLogger = createDebugLogger('GEMINI_STREAM'); @@ -867,8 +868,11 @@ export const useGeminiStream = ( ); if (!isShowingAutoRetry) { - const retryHint = t('Press Ctrl+Y to retry'); - // Store error with hint as a pending item (not in history). + const retryKey = formatShortcut('ctrl+y'); + const retryHint = t('Press Ctrl+Y to retry').replace( + 'Ctrl+Y', + retryKey, + ); // This allows the hint to be removed when the user retries with Ctrl+Y, // since pending items are in the dynamic rendering area (not ). setPendingRetryErrorItem({ @@ -1475,8 +1479,10 @@ export const useGeminiStream = ( onAuthError('Session expired or is unauthorized.'); } else if (!isNodeError(error) || error.name !== 'AbortError') { lastPromptErroredRef.current = true; - const retryHint = t('Press Ctrl+Y to retry'); - // Store error with hint as a pending item (same as handleErrorEvent) + const retryHint = t('Press Ctrl+Y to retry').replace( + 'Ctrl+Y', + formatShortcut('ctrl+y'), + ); setPendingRetryErrorItem({ type: 'error' as const, text: parseAndFormatApiError( diff --git a/packages/cli/src/ui/utils/shortcutFormatter.test.ts b/packages/cli/src/ui/utils/shortcutFormatter.test.ts new file mode 100644 index 00000000000..3f871552ea2 --- /dev/null +++ b/packages/cli/src/ui/utils/shortcutFormatter.test.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; + +describe('formatShortcut', () => { + const originalPlatform = process.platform; + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + vi.resetModules(); + }); + + it('converts modifier keys to Mac symbols on darwin', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + const { formatShortcut } = await import('./shortcutFormatter.js'); + expect(formatShortcut('ctrl+y')).toBe('⌃Y'); + expect(formatShortcut('cmd+v')).toBe('⌘V'); + expect(formatShortcut('alt+v')).toBe('⌥V'); + expect(formatShortcut('shift+tab')).toBe('⇧TAB'); + }); + + it('handles multi-part shortcuts on darwin', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + const { formatShortcut } = await import('./shortcutFormatter.js'); + expect(formatShortcut('esc esc')).toBe('ESC ESC'); + }); + + it('returns input unchanged on non-darwin', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + const { formatShortcut } = await import('./shortcutFormatter.js'); + expect(formatShortcut('ctrl+y')).toBe('ctrl+y'); + expect(formatShortcut('ctrl+c')).toBe('ctrl+c'); + }); +}); diff --git a/packages/cli/src/ui/utils/shortcutFormatter.ts b/packages/cli/src/ui/utils/shortcutFormatter.ts new file mode 100644 index 00000000000..57c25ab9af8 --- /dev/null +++ b/packages/cli/src/ui/utils/shortcutFormatter.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +const isMac = process.platform === 'darwin'; + +/** + * Maps modifier names to macOS symbol equivalents. + */ +const MAC_MODIFIERS: Record = { + ctrl: '⌃', + cmd: '⌘', + alt: '⌥', + shift: '⇧', +}; + +/** + * Formats a keyboard shortcut string for display, using macOS symbols when + * running on Darwin. + * + * Examples (on macOS): + * "ctrl+y" → "⌃Y" + * "cmd+v" → "⌘V" + * "ctrl+c" → "⌃C" + * + * On other platforms the input is returned unchanged. + */ +export function formatShortcut(shortcut: string): string { + if (!isMac) { + return shortcut; + } + + return shortcut + .split(/\s+/) + .map((combo) => { + const parts = combo.split('+'); + let result = ''; + for (const part of parts) { + const lower = part.toLowerCase(); + if (MAC_MODIFIERS[lower]) { + result += MAC_MODIFIERS[lower]; + } else { + result += part.toUpperCase(); + } + } + return result; + }) + .join(' '); +}