feat: update the shortcuts in the desktop app for macOS (#96)

* feat(ui): update keyboard shortcuts to use platform-appropriate modifier keys

* fix(macos): update keyboard shortcuts to use Cmd modifier

Change Command Palette shortcut from Ctrl+X to Cmd+K
Update all menu shortcuts to use Cmd instead of Ctrl on macOS

* Fix linter issue
This commit is contained in:
Michael Sakhniuk
2026-01-03 02:18:57 +02:00
committed by GitHub
parent 025a9a6b80
commit ff825c7651
9 changed files with 215 additions and 88 deletions
+2 -1
View File
@@ -13,6 +13,7 @@ import { useSessionStatusBootstrap } from '@/hooks/useSessionStatusBootstrap';
import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup';
import { GitPollingProvider } from '@/hooks/useGitPolling';
import { useConfigStore } from '@/stores/useConfigStore';
import { hasModifier } from '@/lib/utils';
import { useSessionStore } from '@/stores/useSessionStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { opencodeClient } from '@/lib/opencode/client';
@@ -156,7 +157,7 @@ function App({ apis }: AppProps) {
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'M') {
if (hasModifier(e) && e.shiftKey && e.key === 'M') {
e.preventDefault();
setShowMemoryDebug(prev => !prev);
}
+4 -4
View File
@@ -12,7 +12,7 @@ import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
import { useDeviceInfo } from '@/lib/device';
import { cn } from '@/lib/utils';
import { cn, getModifierLabel, hasModifier } from '@/lib/utils';
import { useDiffFileCount } from '@/components/views/DiffView';
interface TabConfig {
@@ -277,7 +277,7 @@ export const Header: React.FC = () => {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) {
if (hasModifier(e) && !e.shiftKey && !e.altKey) {
const num = parseInt(e.key, 10);
if (num >= 1 && num <= tabs.length) {
e.preventDefault();
@@ -410,7 +410,7 @@ export const Header: React.FC = () => {
</button>
</TooltipTrigger>
<TooltipContent>
<p>Command Palette (Ctrl+X)</p>
<p>Command Palette ({getModifierLabel()}+K)</p>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={500}>
@@ -425,7 +425,7 @@ export const Header: React.FC = () => {
</button>
</TooltipTrigger>
<TooltipContent>
<p>Keyboard Shortcuts (Ctrl+H)</p>
<p>Keyboard Shortcuts ({getModifierLabel()}+H)</p>
</TooltipContent>
</Tooltip>
</div>
@@ -5,7 +5,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
import type { ThemeMode } from '@/types/theme';
import { useUIStore } from '@/stores/useUIStore';
import { useMessageQueueStore } from '@/stores/messageQueueStore';
import { cn } from '@/lib/utils';
import { cn, getModifierLabel } from '@/lib/utils';
import { ButtonSmall } from '@/components/ui/button-small';
import { NumberInput } from '@/components/ui/number-input';
import { isVSCodeRuntime } from '@/lib/desktop';
@@ -355,8 +355,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</label>
<p className="typography-meta text-muted-foreground pl-5">
{queueModeEnabled
? 'Enter queues messages, Ctrl+Enter sends immediately.'
: 'Enter sends immediately, Ctrl+Enter queues messages.'}
? `Enter queues messages, ${getModifierLabel()}+Enter sends immediately.`
: `Enter sends immediately, ${getModifierLabel()}+Enter queues messages.`}
</p>
</div>
)}
@@ -16,6 +16,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useDeviceInfo } from '@/lib/device';
import { RiAddLine, RiChatAi3Line, RiCheckLine, RiCodeLine, RiComputerLine, RiGitBranchLine, RiLayoutLeftLine, RiMoonLine, RiQuestionLine, RiRestartLine, RiSettings3Line, RiSunLine, RiTerminalBoxLine } from '@remixicon/react';
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
import { getModifierLabel } from '@/lib/utils';
export const CommandPalette: React.FC = () => {
const {
@@ -121,42 +122,42 @@ export const CommandPalette: React.FC = () => {
<CommandItem onSelect={handleOpenSessionList}>
<RiLayoutLeftLine className="mr-2 h-4 w-4" />
<span>Open Session List</span>
<CommandShortcut>Ctrl + L</CommandShortcut>
<CommandShortcut>{getModifierLabel()} + L</CommandShortcut>
</CommandItem>
<CommandItem onSelect={handleCreateSession}>
<RiAddLine className="mr-2 h-4 w-4" />
<span>New Session</span>
<CommandShortcut>Ctrl + N</CommandShortcut>
<CommandShortcut>{getModifierLabel()} + N</CommandShortcut>
</CommandItem>
<CommandItem onSelect={handleOpenAdvancedSession}>
<RiGitBranchLine className="mr-2 h-4 w-4" />
<span>New Session with Worktree</span>
<CommandShortcut>Shift + Ctrl + N</CommandShortcut>
<CommandShortcut>Shift + {getModifierLabel()} + N</CommandShortcut>
</CommandItem>
<CommandItem onSelect={handleShowHelp}>
<RiQuestionLine className="mr-2 h-4 w-4" />
<span>Keyboard Shortcuts</span>
<CommandShortcut>Ctrl + H</CommandShortcut>
<CommandShortcut>{getModifierLabel()} + H</CommandShortcut>
</CommandItem>
<CommandItem onSelect={handleOpenDiffPanel}>
<RiCodeLine className="mr-2 h-4 w-4" />
<span>Open Diff Panel</span>
<CommandShortcut>Ctrl + E</CommandShortcut>
<CommandShortcut>{getModifierLabel()} + E</CommandShortcut>
</CommandItem>
<CommandItem onSelect={handleOpenGitPanel}>
<RiGitBranchLine className="mr-2 h-4 w-4" />
<span>Open Git Panel</span>
<CommandShortcut>Ctrl + G</CommandShortcut>
<CommandShortcut>{getModifierLabel()} + G</CommandShortcut>
</CommandItem>
<CommandItem onSelect={handleOpenTerminal}>
<RiTerminalBoxLine className="mr-2 h-4 w-4" />
<span>Open Terminal</span>
<CommandShortcut>Ctrl + T</CommandShortcut>
<CommandShortcut>{getModifierLabel()} + T</CommandShortcut>
</CommandItem>
<CommandItem onSelect={handleOpenSettings}>
<RiSettings3Line className="mr-2 h-4 w-4" />
<span>Open Settings</span>
<CommandShortcut>Ctrl + ,</CommandShortcut>
<CommandShortcut>{getModifierLabel()} + ,</CommandShortcut>
</CommandItem>
<CommandItem onSelect={handleReloadConfiguration}>
<RiRestartLine className="mr-2 h-4 w-4" />
+129 -38
View File
@@ -1,41 +1,66 @@
import React from 'react';
import React from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useUIStore } from '@/stores/useUIStore';
import { RiAddLine, RiArrowUpSLine, RiArrowUpWideLine, RiBrainAi3Line, RiCloseCircleLine, RiCodeLine, RiCommandLine, RiGitBranchLine, RiLayoutLeftLine, RiPaletteLine, RiQuestionLine, RiSettings3Line, RiTerminalBoxLine, RiText } from '@remixicon/react';
} from "@/components/ui/dialog";
import { useUIStore } from "@/stores/useUIStore";
import {
RiAddLine,
RiArrowUpSLine,
RiBrainAi3Line,
RiCloseCircleLine,
RiCodeLine,
RiCommandLine,
RiGitBranchLine,
RiLayoutLeftLine,
RiPaletteLine,
RiQuestionLine,
RiSettings3Line,
RiTerminalBoxLine,
RiText,
} from "@remixicon/react";
import { getModifierLabel } from "@/lib/utils";
const renderKeyToken = (token: string, index: number) => {
const normalized = token.trim().toLowerCase();
if (normalized === 'ctrl' || normalized === 'control') {
if (normalized === "ctrl" || normalized === "control") {
return <RiArrowUpSLine key={`ctrl-${index}`} className="h-3.5 w-3.5" />;
}
if (normalized === 'shift' || normalized === '⇧') {
return <RiArrowUpWideLine key={`shift-${index}`} className="h-3.5 w-3.5" />;
}
if (normalized === '⌘' || normalized === 'cmd' || normalized === 'command' || normalized === 'meta') {
if (
normalized === "⌘" ||
normalized === "cmd" ||
normalized === "command" ||
normalized === "meta"
) {
return <RiCommandLine key={`cmd-${index}`} className="h-3.5 w-3.5" />;
}
return <span key={`key-${index}`} className="text-xs font-medium">{token.trim()}</span>;
return (
<span key={`key-${index}`} className="text-xs font-medium">
{token.trim()}
</span>
);
};
const renderKeyCombo = (combo: string) => {
const tokens = combo.split('+').map((token) => token.trim()).filter(Boolean);
const tokens = combo
.split("+")
.map((token) => token.trim())
.filter(Boolean);
if (tokens.length === 0) {
return combo.trim();
}
return tokens.map((token, index) => (
<React.Fragment key={`${token}-${index}`}>
{index > 0 && <span className="text-muted-foreground text-[10px]">+</span>}
{index > 0 && (
<span className="text-muted-foreground text-[10px]">+</span>
)}
{renderKeyToken(token, index)}
</React.Fragment>
));
@@ -57,35 +82,85 @@ type ShortcutSection = {
export const HelpDialog: React.FC = () => {
const { isHelpDialogOpen, setHelpDialogOpen } = useUIStore();
const mod = getModifierLabel();
const shortcuts: ShortcutSection[] = [
{
category: "Navigation & Commands",
items: [
{ keys: ["Ctrl + X"], description: "Open Command Palette", icon: RiCommandLine },
{ keys: ["Ctrl + H"], description: "Show Keyboard Shortcuts (this dialog)", icon: RiQuestionLine },
{ keys: ["Ctrl + L"], description: "Toggle Session Sidebar", icon: RiLayoutLeftLine },
{ keys: ["Ctrl + M"], description: "Open Model Selector", icon: RiBrainAi3Line },
]
{
keys: [`${mod} + K`],
description: "Open Command Palette",
icon: RiCommandLine,
},
{
keys: [`${mod} + H`],
description: "Show Keyboard Shortcuts (this dialog)",
icon: RiQuestionLine,
},
{
keys: [`${mod} + L`],
description: "Toggle Session Sidebar",
icon: RiLayoutLeftLine,
},
{
keys: [`${mod} + M`],
description: "Open Model Selector",
icon: RiBrainAi3Line,
},
],
},
{
category: "Session Management",
items: [
{ keys: ["Ctrl + N"], description: "Create New Session", icon: RiAddLine },
{ keys: ["Shift + Ctrl + N"], description: "Open Worktree Creator", icon: RiGitBranchLine },
{ keys: ["Ctrl + I"], description: "Focus Chat Input", icon: RiText },
{ keys: ["Esc + Esc"], description: "Abort active run (double press)", icon: RiCloseCircleLine },
]
{
keys: [`${mod} + N`],
description: "Create New Session",
icon: RiAddLine,
},
{
keys: [`Shift + ${mod} + N`],
description: "Open Worktree Creator",
icon: RiGitBranchLine,
},
{ keys: [`${mod} + I`], description: "Focus Chat Input", icon: RiText },
{
keys: ["Esc + Esc"],
description: "Abort active run (double press)",
icon: RiCloseCircleLine,
},
],
},
{
category: "Interface",
items: [
{ keys: ["⌘ + /", "Ctrl + /"], description: "Cycle Theme (Light → Dark → System)", icon: RiPaletteLine },
{ keys: ["Ctrl + E"], description: "Open Diff Panel", icon: RiCodeLine },
{ keys: ["Ctrl + G"], description: "Open Git Panel", icon: RiGitBranchLine },
{ keys: ["Ctrl + T"], description: "Open Terminal", icon: RiTerminalBoxLine },
{ keys: ["Ctrl + ,"], description: "Open Settings", icon: RiSettings3Line },
]
}
{
keys: [`${mod} + /`],
description: "Cycle Theme (Light → Dark → System)",
icon: RiPaletteLine,
},
{
keys: [`${mod} + E`],
description: "Open Diff Panel",
icon: RiCodeLine,
},
{
keys: [`${mod} + G`],
description: "Open Git Panel",
icon: RiGitBranchLine,
},
{
keys: [`${mod} + T`],
description: "Open Terminal",
icon: RiTerminalBoxLine,
},
{
keys: [`${mod} + ,`],
description: "Open Settings",
icon: RiSettings3Line,
},
],
},
];
return (
@@ -117,12 +192,21 @@ export const HelpDialog: React.FC = () => {
{shortcut.icon && (
<shortcut.icon className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="typography-meta">{shortcut.description}</span>
<span className="typography-meta">
{shortcut.description}
</span>
</div>
<div className="flex items-center gap-1">
{(Array.isArray(shortcut.keys) ? shortcut.keys : shortcut.keys.split(' / ')).map((keyCombo: string, i: number) => (
{(Array.isArray(shortcut.keys)
? shortcut.keys
: shortcut.keys.split(" / ")
).map((keyCombo: string, i: number) => (
<React.Fragment key={`${keyCombo}-${i}`}>
{i > 0 && <span className="typography-meta text-muted-foreground mx-1">or</span>}
{i > 0 && (
<span className="typography-meta text-muted-foreground mx-1">
or
</span>
)}
<kbd className="inline-flex items-center gap-1 px-1.5 py-0.5 typography-meta font-mono bg-muted rounded border border-border/20">
{renderKeyCombo(keyCombo)}
</kbd>
@@ -141,11 +225,18 @@ export const HelpDialog: React.FC = () => {
<RiQuestionLine className="h-3.5 w-3.5 text-muted-foreground mt-0.5" />
<div className="typography-meta text-muted-foreground">
<p className="font-medium mb-1">Pro Tips:</p>
<ul className="space-y-0.5 typography-meta">
<li> Use Command Palette (Ctrl + X) to quickly access all actions</li>
<li> The 5 most recent sessions appear in the Command Palette</li>
<li> Theme cycling remembers your preference across sessions</li>
</ul>
<ul className="space-y-0.5 typography-meta">
<li>
Use Command Palette ({mod} + K) to quickly access all
actions
</li>
<li>
The 5 most recent sessions appear in the Command Palette
</li>
<li>
Theme cycling remembers your preference across sessions
</li>
</ul>
</div>
</div>
</div>
@@ -1,5 +1,5 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { cn, getModifierLabel } from '@/lib/utils';
import { SIDEBAR_SECTIONS } from '@/constants/sidebar';
import type { SidebarSection } from '@/constants/sidebar';
import { RiArrowLeftSLine, RiCloseLine } from '@remixicon/react';
@@ -244,7 +244,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
};
// Keyboard shortcut display based on platform
const shortcutKey = isMacPlatform ? '⌘' : 'Ctrl';
const shortcutKey = getModifierLabel();
// Desktop padding for Mac titlebar area
const desktopPaddingClass = React.useMemo(() => {
+13 -12
View File
@@ -4,6 +4,7 @@ import { useUIStore } from '@/stores/useUIStore';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { hasModifier } from '@/lib/utils';
export const useKeyboardShortcuts = () => {
const { openNewSessionDraft, abortCurrentOperation, armAbortPrompt, clearAbortPrompt, currentSessionId } = useSessionStore();
@@ -35,12 +36,12 @@ export const useKeyboardShortcuts = () => {
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.ctrlKey && e.key === 'x') {
if (hasModifier(e) && e.key === 'k') {
e.preventDefault();
toggleCommandPalette();
}
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'l') {
if (hasModifier(e) && e.shiftKey && e.key.toLowerCase() === 'l') {
const runtimeAPIs = getRegisteredRuntimeAPIs();
const diagnostics = runtimeAPIs?.diagnostics;
if (!diagnostics) {
@@ -73,12 +74,12 @@ export const useKeyboardShortcuts = () => {
return;
}
if (e.ctrlKey && e.key === 'h') {
if (hasModifier(e) && e.key === 'h') {
e.preventDefault();
toggleHelpDialog();
}
if (e.ctrlKey && !e.metaKey && e.key.toLowerCase() === 'n') {
if (hasModifier(e) && e.key.toLowerCase() === 'n') {
e.preventDefault();
if (e.shiftKey) {
setSessionCreateDialogOpen(true);
@@ -90,7 +91,7 @@ export const useKeyboardShortcuts = () => {
openNewSessionDraft();
}
if ((e.metaKey || e.ctrlKey) && e.key === '/') {
if (hasModifier(e) && e.key === '/') {
e.preventDefault();
const modes: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system'];
const currentIndex = modes.indexOf(themeMode);
@@ -98,35 +99,35 @@ export const useKeyboardShortcuts = () => {
setThemeMode(modes[nextIndex]);
}
if (e.ctrlKey && !e.metaKey && !e.shiftKey && e.key.toLowerCase() === 'g') {
if (hasModifier(e) && !e.shiftKey && e.key.toLowerCase() === 'g') {
e.preventDefault();
const { activeMainTab } = useUIStore.getState();
setActiveMainTab(activeMainTab === 'git' ? 'chat' : 'git');
return;
}
if (e.ctrlKey && !e.metaKey && !e.shiftKey && e.key.toLowerCase() === 'e') {
if (hasModifier(e) && !e.shiftKey && e.key.toLowerCase() === 'e') {
e.preventDefault();
const { activeMainTab } = useUIStore.getState();
setActiveMainTab(activeMainTab === 'diff' ? 'chat' : 'diff');
return;
}
if (e.ctrlKey && !e.metaKey && !e.shiftKey && e.key.toLowerCase() === 't') {
if (hasModifier(e) && !e.shiftKey && e.key.toLowerCase() === 't') {
e.preventDefault();
const { activeMainTab } = useUIStore.getState();
setActiveMainTab(activeMainTab === 'terminal' ? 'chat' : 'terminal');
return;
}
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === ',') {
if (hasModifier(e) && !e.shiftKey && e.key === ',') {
e.preventDefault();
const { isSettingsDialogOpen } = useUIStore.getState();
setSettingsDialogOpen(!isSettingsDialogOpen);
return;
}
if (e.ctrlKey && !e.metaKey && !e.shiftKey && e.key.toLowerCase() === 'l') {
if (hasModifier(e) && !e.shiftKey && e.key.toLowerCase() === 'l') {
e.preventDefault();
const { isMobile, isSessionSwitcherOpen } = useUIStore.getState();
if (isMobile) {
@@ -137,7 +138,7 @@ export const useKeyboardShortcuts = () => {
return;
}
if (e.ctrlKey && !e.metaKey && !e.shiftKey && e.key.toLowerCase() === 'i') {
if (hasModifier(e) && !e.shiftKey && e.key.toLowerCase() === 'i') {
e.preventDefault();
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[data-chat-input="true"]');
textarea?.focus();
@@ -145,7 +146,7 @@ export const useKeyboardShortcuts = () => {
}
// Ctrl+M: Open model selector (same conditions as double-ESC: chat tab, no overlays)
if (e.ctrlKey && !e.metaKey && !e.shiftKey && e.key.toLowerCase() === 'm') {
if (hasModifier(e) && !e.shiftKey && e.key.toLowerCase() === 'm') {
const {
isSettingsDialogOpen,
isCommandPaletteOpen,
+28
View File
@@ -1,10 +1,38 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { isDesktopRuntime } from "@/lib/desktop";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* Detects if the current platform is macOS.
* Uses navigator.userAgent in browser environments.
*/
export const isMacOS = (): boolean => {
if (typeof navigator === 'undefined') return false;
return /Macintosh|Mac OS X/.test(navigator.userAgent || '');
};
/**
* Checks if the platform-appropriate modifier key is pressed.
* On macOS desktop app: Cmd (metaKey), on other platforms or web: Ctrl (ctrlKey).
* Browser intercepts Cmd shortcuts, so we only use Cmd in Tauri desktop app.
*/
export const hasModifier = (e: KeyboardEvent | React.KeyboardEvent): boolean => {
return isMacOS() && isDesktopRuntime() ? e.metaKey : e.ctrlKey;
};
/**
* Returns the platform-appropriate modifier key label.
* On macOS desktop app: "⌘", on other platforms or web: "Ctrl"
* Browser intercepts Cmd shortcuts, so we only show Cmd in Tauri desktop app.
*/
export const getModifierLabel = (): string => {
return isMacOS() && isDesktopRuntime() ? '⌘' : 'Ctrl';
};
export const truncatePathMiddle = (
value: string,
options?: { maxLength?: number }