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(' ');
+}