From 45a792d6576d507fea57d01a35526195f6c7bdcb Mon Sep 17 00:00:00 2001 From: ChangeHow Date: Thu, 6 Aug 2026 08:49:44 +0800 Subject: [PATCH] feat(ui): show platform-specific shortcut labels --- .../chat/composer/ui/FocusModeButton.tsx | 17 ++++++-- .../comments/InlineCommentInput.tsx | 6 ++- .../openchamber/OpenChamberVisualSettings.tsx | 6 ++- .../session/DirectoryExplorerDialog.tsx | 5 +-- packages/ui/src/components/ui/HelpDialog.tsx | 6 +-- .../ui/src/components/views/FilesView.tsx | 9 ++-- .../ui/src/components/views/SettingsView.tsx | 20 +++++++-- .../ui/src/components/views/TerminalView.tsx | 5 ++- .../ui/src/lib/i18n/messages/de.settings.ts | 4 +- packages/ui/src/lib/i18n/messages/de.ts | 2 +- .../ui/src/lib/i18n/messages/en.settings.ts | 4 +- packages/ui/src/lib/i18n/messages/en.ts | 2 +- .../ui/src/lib/i18n/messages/es.settings.ts | 4 +- packages/ui/src/lib/i18n/messages/es.ts | 2 +- .../ui/src/lib/i18n/messages/fr.settings.ts | 4 +- packages/ui/src/lib/i18n/messages/fr.ts | 2 +- .../ui/src/lib/i18n/messages/ja.settings.ts | 4 +- packages/ui/src/lib/i18n/messages/ja.ts | 2 +- .../ui/src/lib/i18n/messages/ko.settings.ts | 4 +- packages/ui/src/lib/i18n/messages/ko.ts | 2 +- .../ui/src/lib/i18n/messages/pl.settings.ts | 4 +- packages/ui/src/lib/i18n/messages/pl.ts | 2 +- .../src/lib/i18n/messages/pt-BR.settings.ts | 4 +- packages/ui/src/lib/i18n/messages/pt-BR.ts | 2 +- .../ui/src/lib/i18n/messages/uk.settings.ts | 4 +- packages/ui/src/lib/i18n/messages/uk.ts | 2 +- .../src/lib/i18n/messages/zh-CN.settings.ts | 4 +- packages/ui/src/lib/i18n/messages/zh-CN.ts | 2 +- .../src/lib/i18n/messages/zh-TW.settings.ts | 4 +- packages/ui/src/lib/i18n/messages/zh-TW.ts | 2 +- .../ui/src/lib/shortcuts/DOCUMENTATION.md | 2 +- .../ui/src/lib/shortcuts/bindings.test.ts | 20 +++++++++ packages/ui/src/lib/shortcuts/bindings.ts | 43 +++++++++++++------ packages/ui/src/lib/shortcuts/index.ts | 1 - 34 files changed, 135 insertions(+), 71 deletions(-) diff --git a/packages/ui/src/components/chat/composer/ui/FocusModeButton.tsx b/packages/ui/src/components/chat/composer/ui/FocusModeButton.tsx index 74251911..d740de34 100644 --- a/packages/ui/src/components/chat/composer/ui/FocusModeButton.tsx +++ b/packages/ui/src/components/chat/composer/ui/FocusModeButton.tsx @@ -5,7 +5,12 @@ import React from 'react'; import { Icon } from '@/components/icon/Icon'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useI18n } from '@/lib/i18n'; -import { cn, isMacOS } from '@/lib/utils'; +import { + formatShortcutForDisplay, + getEffectiveShortcutCombo, +} from '@/lib/shortcuts'; +import { cn } from '@/lib/utils'; +import { useUIStore } from '@/stores/useUIStore'; type FocusModeButtonProps = { footerIconButtonClass: string; @@ -17,6 +22,12 @@ type FocusModeButtonProps = { export const FocusModeButton = React.memo(function FocusModeButton(props: FocusModeButtonProps) { const { footerIconButtonClass, iconSizeClass, isExpandedInput, onToggle } = props; const { t } = useI18n(); + const expandInputShortcutOverride = useUIStore((state) => state.shortcutOverrides.expand_input); + const expandInputCombo = getEffectiveShortcutCombo( + 'expand_input', + expandInputShortcutOverride === undefined ? undefined : { expand_input: expandInputShortcutOverride }, + ); + const shortcut = expandInputCombo ? formatShortcutForDisplay(expandInputCombo) : null; return ( @@ -43,9 +54,7 @@ export const FocusModeButton = React.memo(function FocusModeButton(props: FocusM
{t('chat.chatInput.focusMode.label')} - - {isMacOS() ? '⌘⇧E' : 'Ctrl+Shift+E'} - + {shortcut ? {shortcut} : null}
diff --git a/packages/ui/src/components/comments/InlineCommentInput.tsx b/packages/ui/src/components/comments/InlineCommentInput.tsx index 7b76b5aa..bfe8a573 100644 --- a/packages/ui/src/components/comments/InlineCommentInput.tsx +++ b/packages/ui/src/components/comments/InlineCommentInput.tsx @@ -5,6 +5,7 @@ import { Textarea } from '@/components/ui/textarea'; import { cn } from '@/lib/utils'; import { useDeviceInfo } from '@/lib/device'; import { useI18n } from '@/lib/i18n'; +import { formatShortcutForDisplay } from '@/lib/shortcuts'; export interface InlineCommentInputProps { initialText?: string; @@ -35,6 +36,7 @@ export function InlineCommentInput({ const { isMobile } = useDeviceInfo(); const [text, setText] = React.useState(initialText); const textareaRef = useRef(null); + const saveShortcut = formatShortcutForDisplay('mod+enter'); const handleTextChange = (value: string) => { setText(value); @@ -154,7 +156,9 @@ export function InlineCommentInput({ value={text} onChange={(e) => handleTextChange(e.target.value)} onKeyDown={handleKeyDown} - placeholder={isMobile ? t('inlineComment.input.placeholderShort') : t('inlineComment.input.placeholder')} + placeholder={isMobile + ? t('inlineComment.input.placeholderShort') + : t('inlineComment.input.placeholder', { shortcut: saveShortcut })} outerClassName="rounded-[var(--radius-xl)] bg-[var(--surface-subtle)] ring-1 ring-inset ring-border/60 focus-within:ring-2 focus-within:ring-[var(--interactive-focus-ring)]" className="min-h-[80px] px-3 py-2.5 text-sm resize-y" /> diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index 73d76470..820fde9d 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -64,6 +64,7 @@ import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import type { TerminalShellOption } from '@/lib/api/types'; import { isTerminalShell } from '@/lib/terminalShell'; import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; +import { formatShortcutForDisplay } from '@/lib/shortcuts'; interface Option { id: T; @@ -1543,7 +1544,10 @@ export const OpenChamberVisualSettings: React.FC label={t('settings.openchamber.visual.field.terminalQuickKeys')} ariaLabel={t('settings.openchamber.visual.field.terminalQuickKeysAria')} settingsItem="appearance.terminal-quick-keys" - info={t('settings.openchamber.visual.field.terminalQuickKeysTooltip')} + info={t('settings.openchamber.visual.field.terminalQuickKeysTooltip', { + control: formatShortcutForDisplay('ctrl'), + alt: formatShortcutForDisplay('alt'), + })} /> )} {showTerminalShellSetting && ( diff --git a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx index 81d998a4..0035e44b 100644 --- a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx +++ b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx @@ -22,6 +22,7 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { Icon } from "@/components/icon/Icon"; import { opencodeClient } from '@/lib/opencode/client'; import { useI18n } from '@/lib/i18n'; +import { formatShortcutForDisplay } from '@/lib/shortcuts'; interface DirectoryExplorerDialogProps { open: boolean; @@ -338,9 +339,7 @@ export const DirectoryExplorerDialog: React.FC = ( const hasHighlightedBrowseItem = Boolean( highlightedRow && (highlightedRow.type === 'up' || (highlightedRow.type === 'directory' && !highlightedRow.disabled)) ); - const submitModifierLabel = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform) - ? '⌘' - : 'Ctrl'; + const submitModifierLabel = formatShortcutForDisplay('mod'); const submitActionLabel = isAlreadyAdded ? t('directoryExplorerDialog.actions.alreadyAdded') : isCloneMode diff --git a/packages/ui/src/components/ui/HelpDialog.tsx b/packages/ui/src/components/ui/HelpDialog.tsx index 7ce7df29..6080ac29 100644 --- a/packages/ui/src/components/ui/HelpDialog.tsx +++ b/packages/ui/src/components/ui/HelpDialog.tsx @@ -11,7 +11,6 @@ import { useUIStore } from "@/stores/useUIStore"; import { getEffectiveShortcutCombo, getShortcutAction, - getModifierLabel, formatShortcutForDisplay, type ShortcutActionId, } from "@/lib/shortcuts"; @@ -44,7 +43,6 @@ export const HelpDialog: React.FC = () => { const isHelpDialogOpen = useUIStore((state) => state.isHelpDialogOpen); const setHelpDialogOpen = useUIStore((state) => state.setHelpDialogOpen); const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); - const mod = getModifierLabel(); const isVSCode = isVSCodeRuntime(); const shortcuts: ShortcutSection[] = [ @@ -104,7 +102,7 @@ export const HelpDialog: React.FC = () => { keys: '', }, { - keys: [`Shift + Alt + ${mod} + N`], + keys: [formatShortcutForDisplay('mod+shift+alt+n')], descriptionKey: "helpDialog.item.newWindow", icon: "window", }, @@ -195,7 +193,7 @@ export const HelpDialog: React.FC = () => { keys: '', }, { - keys: [`${mod} + 1...0`], + keys: [`${formatShortcutForDisplay('mod')} + 1...0`], descriptionKey: "helpDialog.item.switchContextSurface", icon: "layout-right", }, diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index 4de4f8f2..8ac76a40 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -53,7 +53,7 @@ import { getOutsideFileGrant } from '@/lib/outsideFileGrants'; import { DiagramEditor } from '@/components/diagram'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useKeybind, useKeybinds } from '@/hooks/useKeybind'; -import { getModifierLabel } from '@/lib/shortcuts'; +import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts'; import { EditorView } from '@codemirror/view'; import type { Extension } from '@codemirror/state'; import { useThemeSystem } from '@/contexts/useThemeSystem'; @@ -3167,6 +3167,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { } const docked = layout === 'docked'; + const saveShortcut = formatShortcutForDisplay(getEffectiveShortcutCombo('save_file')); const wrapperCls = docked ? 'pointer-events-auto flex flex-wrap items-center gap-1' : 'pointer-events-auto flex items-center gap-1 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-1 shadow-sm'; @@ -3196,14 +3197,14 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { {t('filesView.editor.saved')} - ) : isDirty ? withTooltip(t(autoSaveEnabled ? 'filesView.editor.saveNowTitle' : 'filesView.editor.saveNowManualTitle', { shortcut: `${getModifierLabel()}+S` }), + ) : isDirty ? withTooltip(t(autoSaveEnabled ? 'filesView.editor.saveNowTitle' : 'filesView.editor.saveNowManualTitle', { shortcut: saveShortcut }), diff --git a/packages/ui/src/components/views/SettingsView.tsx b/packages/ui/src/components/views/SettingsView.tsx index 325e73c3..9d706cc1 100644 --- a/packages/ui/src/components/views/SettingsView.tsx +++ b/packages/ui/src/components/views/SettingsView.tsx @@ -1,6 +1,9 @@ import React from 'react'; import { cn } from '@/lib/utils'; -import { getModifierLabel } from '@/lib/shortcuts'; +import { + formatShortcutForDisplay, + getEffectiveShortcutCombo, +} from '@/lib/shortcuts'; import { useUIStore } from '@/stores/useUIStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useAgentsStore } from '@/stores/useAgentsStore'; @@ -240,6 +243,7 @@ export const SettingsView: React.FC = ({ onClose, forceMobile const settingsPageRaw = useUIStore((state) => state.settingsPage); const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen); const setSettingsPage = useUIStore((state) => state.setSettingsPage); + const openSettingsShortcutOverride = useUIStore((state) => state.shortcutOverrides.open_settings); const settingsSlug = resolveSettingsSlug(settingsPageRaw); const [mobileStage, setMobileStage] = React.useState(initialMobileStage); @@ -751,7 +755,15 @@ export const SettingsView: React.FC = ({ onClose, forceMobile : showBackButton ? t('settings.view.actions.backToSettings') : t('settings.view.actions.closeSettings'); - const shortcutKey = getModifierLabel(); + const openSettingsCombo = getEffectiveShortcutCombo( + 'open_settings', + openSettingsShortcutOverride === undefined ? undefined : { open_settings: openSettingsShortcutOverride }, + ); + const closeSettingsTitle = openSettingsCombo + ? t('settings.view.actions.closeSettingsWithShortcut', { + shortcut: formatShortcutForDisplay(openSettingsCombo), + }) + : t('settings.view.actions.closeSettings'); const pushMobileSplitDetailHistory = React.useCallback((slug: SettingsPageSlug) => { if (typeof window === 'undefined' || runtimeCtx.isVSCode) { @@ -1119,7 +1131,7 @@ export const SettingsView: React.FC = ({ onClose, forceMobile type="button" onClick={onClose} aria-label={t('settings.view.actions.closeSettings')} - title={t('settings.view.actions.closeSettingsWithShortcut', { shortcut: shortcutKey })} + title={closeSettingsTitle} className="inline-flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg p-2 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary" > @@ -1147,7 +1159,7 @@ export const SettingsView: React.FC = ({ onClose, forceMobile type="button" onClick={onClose} aria-label={t('settings.view.actions.closeSettings')} - title={t('settings.view.actions.closeSettingsWithShortcut', { shortcut: shortcutKey })} + title={closeSettingsTitle} className="inline-flex h-7 w-7 items-center justify-center rounded-md p-0.5 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary" > diff --git a/packages/ui/src/components/views/TerminalView.tsx b/packages/ui/src/components/views/TerminalView.tsx index c97f608e..fce15637 100644 --- a/packages/ui/src/components/views/TerminalView.tsx +++ b/packages/ui/src/components/views/TerminalView.tsx @@ -21,6 +21,7 @@ import { useI18n } from '@/lib/i18n'; import { PROJECT_ACTION_ICON_MAP, type ProjectActionIconKey } from '@/lib/projectActions'; import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore'; import { applyTerminalModifier, terminalControlCharacter, terminalSequenceForKey, type TerminalModifier as Modifier, type TerminalQuickKey as MobileKey } from '@/lib/terminalInput'; +import { formatShortcutForDisplay } from '@/lib/shortcuts'; type TerminalViewProps = { visible?: boolean; @@ -924,7 +925,7 @@ export const TerminalView: React.FC = ({ visible }) => { onClick={() => handleModifierToggle('ctrl')} disabled={quickKeysDisabled} > - {t('terminalView.quickKeys.controlLabel')} + {formatShortcutForDisplay('ctrl')} {t('terminalView.quickKeys.controlModifierAria')}