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
+25 -20
View File
@@ -391,7 +391,7 @@ fn build_macos_menu<R: tauri::Runtime>(
MENU_ITEM_COMMAND_PALETTE_ID, MENU_ITEM_COMMAND_PALETTE_ID,
"Command Palette", "Command Palette",
true, true,
Some("Ctrl+X"), Some("Cmd+K"),
)?; )?;
// File menu items // File menu items
@@ -400,7 +400,7 @@ fn build_macos_menu<R: tauri::Runtime>(
MENU_ITEM_NEW_SESSION_ID, MENU_ITEM_NEW_SESSION_ID,
"New Session", "New Session",
true, true,
Some("Ctrl+N"), Some("Cmd+N"),
)?; )?;
let worktree_creator = MenuItem::with_id( let worktree_creator = MenuItem::with_id(
@@ -408,7 +408,7 @@ fn build_macos_menu<R: tauri::Runtime>(
MENU_ITEM_WORKTREE_CREATOR_ID, MENU_ITEM_WORKTREE_CREATOR_ID,
"New Worktree", "New Worktree",
true, true,
Some("Ctrl+Shift+N"), Some("Cmd+Shift+N"),
)?; )?;
let change_workspace = MenuItem::with_id( let change_workspace = MenuItem::with_id(
@@ -420,15 +420,20 @@ fn build_macos_menu<R: tauri::Runtime>(
)?; )?;
// View menu items // View menu items
let open_git_tab = let open_git_tab = MenuItem::with_id(
MenuItem::with_id(app, MENU_ITEM_OPEN_GIT_TAB_ID, "Git", true, Some("Ctrl+G"))?; app,
MENU_ITEM_OPEN_GIT_TAB_ID,
"Git",
true,
Some("Cmd+G"),
)?;
let open_diff_tab = MenuItem::with_id( let open_diff_tab = MenuItem::with_id(
app, app,
MENU_ITEM_OPEN_DIFF_TAB_ID, MENU_ITEM_OPEN_DIFF_TAB_ID,
"Diff", "Diff",
true, true,
Some("Ctrl+E"), Some("Cmd+E"),
)?; )?;
let open_terminal_tab = MenuItem::with_id( let open_terminal_tab = MenuItem::with_id(
@@ -436,7 +441,7 @@ fn build_macos_menu<R: tauri::Runtime>(
MENU_ITEM_OPEN_TERMINAL_TAB_ID, MENU_ITEM_OPEN_TERMINAL_TAB_ID,
"Terminal", "Terminal",
true, true,
Some("Ctrl+T"), Some("Cmd+T"),
)?; )?;
let theme_light = MenuItem::with_id( let theme_light = MenuItem::with_id(
@@ -468,7 +473,7 @@ fn build_macos_menu<R: tauri::Runtime>(
MENU_ITEM_TOGGLE_SIDEBAR_ID, MENU_ITEM_TOGGLE_SIDEBAR_ID,
"Toggle Session Sidebar", "Toggle Session Sidebar",
true, true,
Some("Ctrl+L"), Some("Cmd+L"),
)?; )?;
let toggle_memory_debug = MenuItem::with_id( let toggle_memory_debug = MenuItem::with_id(
@@ -485,7 +490,7 @@ fn build_macos_menu<R: tauri::Runtime>(
MENU_ITEM_HELP_DIALOG_ID, MENU_ITEM_HELP_DIALOG_ID,
"Keyboard Shortcuts", "Keyboard Shortcuts",
true, true,
Some("Ctrl+H"), Some("Cmd+H"),
)?; )?;
let download_logs = MenuItem::with_id( let download_logs = MenuItem::with_id(
@@ -493,7 +498,7 @@ fn build_macos_menu<R: tauri::Runtime>(
MENU_ITEM_DOWNLOAD_LOGS_ID, MENU_ITEM_DOWNLOAD_LOGS_ID,
"Download Logs", "Download Logs",
true, true,
Some("Ctrl+Shift+L"), Some("Cmd+Shift+L"),
)?; )?;
let report_bug = MenuItem::with_id( let report_bug = MenuItem::with_id(
@@ -1237,6 +1242,7 @@ async fn handle_agent_route(
// Get working directory for project-level agent detection // Get working directory for project-level agent detection
let working_directory = state.opencode.get_working_directory(); let working_directory = state.opencode.get_working_directory();
match method { match method {
Method::GET => { Method::GET => {
match opencode_config::get_agent_sources(&name, Some(&working_directory)).await { match opencode_config::get_agent_sources(&name, Some(&working_directory)).await {
@@ -1270,6 +1276,7 @@ async fn handle_agent_route(
Err(resp) => return Ok(resp), Err(resp) => return Ok(resp),
}; };
// Extract scope from payload if present // Extract scope from payload if present
let scope = payload let scope = payload
.get("scope") .get("scope")
@@ -1418,6 +1425,7 @@ async fn handle_skill_list_route(state: &ServerState) -> Result<Response<Body>,
let working_directory = state.opencode.get_working_directory(); let working_directory = state.opencode.get_working_directory();
let discovered = opencode_config::discover_skills(Some(&working_directory)); let discovered = opencode_config::discover_skills(Some(&working_directory));
let mut skills = Vec::new(); let mut skills = Vec::new();
for skill in discovered { for skill in discovered {
match opencode_config::get_skill_sources(&skill.name, Some(&working_directory)).await { match opencode_config::get_skill_sources(&skill.name, Some(&working_directory)).await {
@@ -1439,10 +1447,7 @@ async fn handle_skill_list_route(state: &ServerState) -> Result<Response<Body>,
} }
} }
Ok(json_response( Ok(json_response(StatusCode::OK, serde_json::json!({ "skills": skills })))
StatusCode::OK,
serde_json::json!({ "skills": skills }),
))
} }
async fn handle_skill_route( async fn handle_skill_route(
@@ -1454,6 +1459,7 @@ async fn handle_skill_route(
) -> Result<Response<Body>, StatusCode> { ) -> Result<Response<Body>, StatusCode> {
let working_directory = state.opencode.get_working_directory(); let working_directory = state.opencode.get_working_directory();
// Handle file operations: /api/config/skills/:name/files/* // Handle file operations: /api/config/skills/:name/files/*
if let Some(ref fp) = file_path { if let Some(ref fp) = file_path {
match method { match method {
@@ -1502,10 +1508,7 @@ async fn handle_skill_route(
Ok(data) => data, Ok(data) => data,
Err(resp) => return Ok(resp), Err(resp) => return Ok(resp),
}; };
let content = payload let content = payload.get("content").and_then(|v| v.as_str()).unwrap_or("");
.get("content")
.and_then(|v| v.as_str())
.unwrap_or("");
match opencode_config::get_skill_sources(&name, Some(&working_directory)).await { match opencode_config::get_skill_sources(&name, Some(&working_directory)).await {
Ok(sources) => { Ok(sources) => {
@@ -1618,8 +1621,7 @@ async fn handle_skill_route(
Err(resp) => return Ok(resp), Err(resp) => return Ok(resp),
}; };
let scope = payload let scope = payload.get("scope")
.get("scope")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.and_then(|s| match s { .and_then(|s| match s {
"project" => Some(opencode_config::SkillScope::Project), "project" => Some(opencode_config::SkillScope::Project),
@@ -1748,6 +1750,7 @@ async fn handle_command_route(
// Get working directory for project-level command detection // Get working directory for project-level command detection
let working_directory = state.opencode.get_working_directory(); let working_directory = state.opencode.get_working_directory();
match method { match method {
Method::GET => { Method::GET => {
match opencode_config::get_command_sources(&name, Some(&working_directory)).await { match opencode_config::get_command_sources(&name, Some(&working_directory)).await {
@@ -1781,6 +1784,7 @@ async fn handle_command_route(
Err(resp) => return Ok(resp), Err(resp) => return Ok(resp),
}; };
// Extract scope from payload if present // Extract scope from payload if present
let scope = payload let scope = payload
.get("scope") .get("scope")
@@ -2055,6 +2059,7 @@ async fn handle_config_routes(
.await; .await;
} }
let trimmed = rest.trim(); let trimmed = rest.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
return Ok(config_error_response( return Ok(config_error_response(
+2 -1
View File
@@ -13,6 +13,7 @@ import { useSessionStatusBootstrap } from '@/hooks/useSessionStatusBootstrap';
import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup'; import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup';
import { GitPollingProvider } from '@/hooks/useGitPolling'; import { GitPollingProvider } from '@/hooks/useGitPolling';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { hasModifier } from '@/lib/utils';
import { useSessionStore } from '@/stores/useSessionStore'; import { useSessionStore } from '@/stores/useSessionStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { opencodeClient } from '@/lib/opencode/client'; import { opencodeClient } from '@/lib/opencode/client';
@@ -156,7 +157,7 @@ function App({ apis }: AppProps) {
React.useEffect(() => { React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'M') { if (hasModifier(e) && e.shiftKey && e.key === 'M') {
e.preventDefault(); e.preventDefault();
setShowMemoryDebug(prev => !prev); setShowMemoryDebug(prev => !prev);
} }
+4 -4
View File
@@ -12,7 +12,7 @@ import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionStore } from '@/stores/useSessionStore'; import { useSessionStore } from '@/stores/useSessionStore';
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay'; import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
import { useDeviceInfo } from '@/lib/device'; import { useDeviceInfo } from '@/lib/device';
import { cn } from '@/lib/utils'; import { cn, getModifierLabel, hasModifier } from '@/lib/utils';
import { useDiffFileCount } from '@/components/views/DiffView'; import { useDiffFileCount } from '@/components/views/DiffView';
interface TabConfig { interface TabConfig {
@@ -277,7 +277,7 @@ export const Header: React.FC = () => {
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { 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); const num = parseInt(e.key, 10);
if (num >= 1 && num <= tabs.length) { if (num >= 1 && num <= tabs.length) {
e.preventDefault(); e.preventDefault();
@@ -410,7 +410,7 @@ export const Header: React.FC = () => {
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Command Palette (Ctrl+X)</p> <p>Command Palette ({getModifierLabel()}+K)</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Tooltip delayDuration={500}> <Tooltip delayDuration={500}>
@@ -425,7 +425,7 @@ export const Header: React.FC = () => {
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Keyboard Shortcuts (Ctrl+H)</p> <p>Keyboard Shortcuts ({getModifierLabel()}+H)</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
@@ -5,7 +5,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
import type { ThemeMode } from '@/types/theme'; import type { ThemeMode } from '@/types/theme';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useMessageQueueStore } from '@/stores/messageQueueStore'; import { useMessageQueueStore } from '@/stores/messageQueueStore';
import { cn } from '@/lib/utils'; import { cn, getModifierLabel } from '@/lib/utils';
import { ButtonSmall } from '@/components/ui/button-small'; import { ButtonSmall } from '@/components/ui/button-small';
import { NumberInput } from '@/components/ui/number-input'; import { NumberInput } from '@/components/ui/number-input';
import { isVSCodeRuntime } from '@/lib/desktop'; import { isVSCodeRuntime } from '@/lib/desktop';
@@ -355,8 +355,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</label> </label>
<p className="typography-meta text-muted-foreground pl-5"> <p className="typography-meta text-muted-foreground pl-5">
{queueModeEnabled {queueModeEnabled
? 'Enter queues messages, Ctrl+Enter sends immediately.' ? `Enter queues messages, ${getModifierLabel()}+Enter sends immediately.`
: 'Enter sends immediately, Ctrl+Enter queues messages.'} : `Enter sends immediately, ${getModifierLabel()}+Enter queues messages.`}
</p> </p>
</div> </div>
)} )}
@@ -16,6 +16,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useDeviceInfo } from '@/lib/device'; import { useDeviceInfo } from '@/lib/device';
import { RiAddLine, RiChatAi3Line, RiCheckLine, RiCodeLine, RiComputerLine, RiGitBranchLine, RiLayoutLeftLine, RiMoonLine, RiQuestionLine, RiRestartLine, RiSettings3Line, RiSunLine, RiTerminalBoxLine } from '@remixicon/react'; import { RiAddLine, RiChatAi3Line, RiCheckLine, RiCodeLine, RiComputerLine, RiGitBranchLine, RiLayoutLeftLine, RiMoonLine, RiQuestionLine, RiRestartLine, RiSettings3Line, RiSunLine, RiTerminalBoxLine } from '@remixicon/react';
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore'; import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
import { getModifierLabel } from '@/lib/utils';
export const CommandPalette: React.FC = () => { export const CommandPalette: React.FC = () => {
const { const {
@@ -121,42 +122,42 @@ export const CommandPalette: React.FC = () => {
<CommandItem onSelect={handleOpenSessionList}> <CommandItem onSelect={handleOpenSessionList}>
<RiLayoutLeftLine className="mr-2 h-4 w-4" /> <RiLayoutLeftLine className="mr-2 h-4 w-4" />
<span>Open Session List</span> <span>Open Session List</span>
<CommandShortcut>Ctrl + L</CommandShortcut> <CommandShortcut>{getModifierLabel()} + L</CommandShortcut>
</CommandItem> </CommandItem>
<CommandItem onSelect={handleCreateSession}> <CommandItem onSelect={handleCreateSession}>
<RiAddLine className="mr-2 h-4 w-4" /> <RiAddLine className="mr-2 h-4 w-4" />
<span>New Session</span> <span>New Session</span>
<CommandShortcut>Ctrl + N</CommandShortcut> <CommandShortcut>{getModifierLabel()} + N</CommandShortcut>
</CommandItem> </CommandItem>
<CommandItem onSelect={handleOpenAdvancedSession}> <CommandItem onSelect={handleOpenAdvancedSession}>
<RiGitBranchLine className="mr-2 h-4 w-4" /> <RiGitBranchLine className="mr-2 h-4 w-4" />
<span>New Session with Worktree</span> <span>New Session with Worktree</span>
<CommandShortcut>Shift + Ctrl + N</CommandShortcut> <CommandShortcut>Shift + {getModifierLabel()} + N</CommandShortcut>
</CommandItem> </CommandItem>
<CommandItem onSelect={handleShowHelp}> <CommandItem onSelect={handleShowHelp}>
<RiQuestionLine className="mr-2 h-4 w-4" /> <RiQuestionLine className="mr-2 h-4 w-4" />
<span>Keyboard Shortcuts</span> <span>Keyboard Shortcuts</span>
<CommandShortcut>Ctrl + H</CommandShortcut> <CommandShortcut>{getModifierLabel()} + H</CommandShortcut>
</CommandItem> </CommandItem>
<CommandItem onSelect={handleOpenDiffPanel}> <CommandItem onSelect={handleOpenDiffPanel}>
<RiCodeLine className="mr-2 h-4 w-4" /> <RiCodeLine className="mr-2 h-4 w-4" />
<span>Open Diff Panel</span> <span>Open Diff Panel</span>
<CommandShortcut>Ctrl + E</CommandShortcut> <CommandShortcut>{getModifierLabel()} + E</CommandShortcut>
</CommandItem> </CommandItem>
<CommandItem onSelect={handleOpenGitPanel}> <CommandItem onSelect={handleOpenGitPanel}>
<RiGitBranchLine className="mr-2 h-4 w-4" /> <RiGitBranchLine className="mr-2 h-4 w-4" />
<span>Open Git Panel</span> <span>Open Git Panel</span>
<CommandShortcut>Ctrl + G</CommandShortcut> <CommandShortcut>{getModifierLabel()} + G</CommandShortcut>
</CommandItem> </CommandItem>
<CommandItem onSelect={handleOpenTerminal}> <CommandItem onSelect={handleOpenTerminal}>
<RiTerminalBoxLine className="mr-2 h-4 w-4" /> <RiTerminalBoxLine className="mr-2 h-4 w-4" />
<span>Open Terminal</span> <span>Open Terminal</span>
<CommandShortcut>Ctrl + T</CommandShortcut> <CommandShortcut>{getModifierLabel()} + T</CommandShortcut>
</CommandItem> </CommandItem>
<CommandItem onSelect={handleOpenSettings}> <CommandItem onSelect={handleOpenSettings}>
<RiSettings3Line className="mr-2 h-4 w-4" /> <RiSettings3Line className="mr-2 h-4 w-4" />
<span>Open Settings</span> <span>Open Settings</span>
<CommandShortcut>Ctrl + ,</CommandShortcut> <CommandShortcut>{getModifierLabel()} + ,</CommandShortcut>
</CommandItem> </CommandItem>
<CommandItem onSelect={handleReloadConfiguration}> <CommandItem onSelect={handleReloadConfiguration}>
<RiRestartLine className="mr-2 h-4 w-4" /> <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 { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from "@/components/ui/dialog";
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from "@/stores/useUIStore";
import { RiAddLine, RiArrowUpSLine, RiArrowUpWideLine, RiBrainAi3Line, RiCloseCircleLine, RiCodeLine, RiCommandLine, RiGitBranchLine, RiLayoutLeftLine, RiPaletteLine, RiQuestionLine, RiSettings3Line, RiTerminalBoxLine, RiText } from '@remixicon/react'; 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 renderKeyToken = (token: string, index: number) => {
const normalized = token.trim().toLowerCase(); 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" />; return <RiArrowUpSLine key={`ctrl-${index}`} className="h-3.5 w-3.5" />;
} }
if (normalized === 'shift' || normalized === '⇧') { if (
return <RiArrowUpWideLine key={`shift-${index}`} className="h-3.5 w-3.5" />; normalized === "⌘" ||
} normalized === "cmd" ||
normalized === "command" ||
if (normalized === '⌘' || normalized === 'cmd' || normalized === 'command' || normalized === 'meta') { normalized === "meta"
) {
return <RiCommandLine key={`cmd-${index}`} className="h-3.5 w-3.5" />; 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 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) { if (tokens.length === 0) {
return combo.trim(); return combo.trim();
} }
return tokens.map((token, index) => ( return tokens.map((token, index) => (
<React.Fragment key={`${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)} {renderKeyToken(token, index)}
</React.Fragment> </React.Fragment>
)); ));
@@ -57,35 +82,85 @@ type ShortcutSection = {
export const HelpDialog: React.FC = () => { export const HelpDialog: React.FC = () => {
const { isHelpDialogOpen, setHelpDialogOpen } = useUIStore(); const { isHelpDialogOpen, setHelpDialogOpen } = useUIStore();
const mod = getModifierLabel();
const shortcuts: ShortcutSection[] = [ const shortcuts: ShortcutSection[] = [
{ {
category: "Navigation & Commands", category: "Navigation & Commands",
items: [ items: [
{ keys: ["Ctrl + X"], description: "Open Command Palette", icon: RiCommandLine }, {
{ keys: ["Ctrl + H"], description: "Show Keyboard Shortcuts (this dialog)", icon: RiQuestionLine }, keys: [`${mod} + K`],
{ keys: ["Ctrl + L"], description: "Toggle Session Sidebar", icon: RiLayoutLeftLine }, description: "Open Command Palette",
{ keys: ["Ctrl + M"], description: "Open Model Selector", icon: RiBrainAi3Line }, 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", category: "Session Management",
items: [ items: [
{ keys: ["Ctrl + N"], description: "Create New Session", icon: RiAddLine }, {
{ keys: ["Shift + Ctrl + N"], description: "Open Worktree Creator", icon: RiGitBranchLine }, keys: [`${mod} + N`],
{ keys: ["Ctrl + I"], description: "Focus Chat Input", icon: RiText }, description: "Create New Session",
{ keys: ["Esc + Esc"], description: "Abort active run (double press)", icon: RiCloseCircleLine }, 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", category: "Interface",
items: [ items: [
{ keys: ["⌘ + /", "Ctrl + /"], description: "Cycle Theme (Light → Dark → System)", icon: RiPaletteLine }, {
{ keys: ["Ctrl + E"], description: "Open Diff Panel", icon: RiCodeLine }, keys: [`${mod} + /`],
{ keys: ["Ctrl + G"], description: "Open Git Panel", icon: RiGitBranchLine }, description: "Cycle Theme (Light → Dark → System)",
{ keys: ["Ctrl + T"], description: "Open Terminal", icon: RiTerminalBoxLine }, icon: RiPaletteLine,
{ keys: ["Ctrl + ,"], description: "Open Settings", icon: RiSettings3Line }, },
] {
} 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 ( return (
@@ -117,12 +192,21 @@ export const HelpDialog: React.FC = () => {
{shortcut.icon && ( {shortcut.icon && (
<shortcut.icon className="h-3.5 w-3.5 text-muted-foreground" /> <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>
<div className="flex items-center gap-1"> <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}`}> <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"> <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)} {renderKeyCombo(keyCombo)}
</kbd> </kbd>
@@ -141,11 +225,18 @@ export const HelpDialog: React.FC = () => {
<RiQuestionLine className="h-3.5 w-3.5 text-muted-foreground mt-0.5" /> <RiQuestionLine className="h-3.5 w-3.5 text-muted-foreground mt-0.5" />
<div className="typography-meta text-muted-foreground"> <div className="typography-meta text-muted-foreground">
<p className="font-medium mb-1">Pro Tips:</p> <p className="font-medium mb-1">Pro Tips:</p>
<ul className="space-y-0.5 typography-meta"> <ul className="space-y-0.5 typography-meta">
<li>• Use Command Palette (Ctrl + X) to quickly access all actions</li> <li>
<li>• The 5 most recent sessions appear in the Command Palette</li> • Use Command Palette ({mod} + K) to quickly access all
<li>• Theme cycling remembers your preference across sessions</li> actions
</ul> </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> </div>
</div> </div>
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { cn } from '@/lib/utils'; import { cn, getModifierLabel } from '@/lib/utils';
import { SIDEBAR_SECTIONS } from '@/constants/sidebar'; import { SIDEBAR_SECTIONS } from '@/constants/sidebar';
import type { SidebarSection } from '@/constants/sidebar'; import type { SidebarSection } from '@/constants/sidebar';
import { RiArrowLeftSLine, RiCloseLine } from '@remixicon/react'; import { RiArrowLeftSLine, RiCloseLine } from '@remixicon/react';
@@ -244,7 +244,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
}; };
// Keyboard shortcut display based on platform // Keyboard shortcut display based on platform
const shortcutKey = isMacPlatform ? '⌘' : 'Ctrl'; const shortcutKey = getModifierLabel();
// Desktop padding for Mac titlebar area // Desktop padding for Mac titlebar area
const desktopPaddingClass = React.useMemo(() => { const desktopPaddingClass = React.useMemo(() => {
+13 -12
View File
@@ -4,6 +4,7 @@ import { useUIStore } from '@/stores/useUIStore';
import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useAssistantStatus } from '@/hooks/useAssistantStatus'; import { useAssistantStatus } from '@/hooks/useAssistantStatus';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { hasModifier } from '@/lib/utils';
export const useKeyboardShortcuts = () => { export const useKeyboardShortcuts = () => {
const { openNewSessionDraft, abortCurrentOperation, armAbortPrompt, clearAbortPrompt, currentSessionId } = useSessionStore(); const { openNewSessionDraft, abortCurrentOperation, armAbortPrompt, clearAbortPrompt, currentSessionId } = useSessionStore();
@@ -35,12 +36,12 @@ export const useKeyboardShortcuts = () => {
React.useEffect(() => { React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.ctrlKey && e.key === 'x') { if (hasModifier(e) && e.key === 'k') {
e.preventDefault(); e.preventDefault();
toggleCommandPalette(); toggleCommandPalette();
} }
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'l') { if (hasModifier(e) && e.shiftKey && e.key.toLowerCase() === 'l') {
const runtimeAPIs = getRegisteredRuntimeAPIs(); const runtimeAPIs = getRegisteredRuntimeAPIs();
const diagnostics = runtimeAPIs?.diagnostics; const diagnostics = runtimeAPIs?.diagnostics;
if (!diagnostics) { if (!diagnostics) {
@@ -73,12 +74,12 @@ export const useKeyboardShortcuts = () => {
return; return;
} }
if (e.ctrlKey && e.key === 'h') { if (hasModifier(e) && e.key === 'h') {
e.preventDefault(); e.preventDefault();
toggleHelpDialog(); toggleHelpDialog();
} }
if (e.ctrlKey && !e.metaKey && e.key.toLowerCase() === 'n') { if (hasModifier(e) && e.key.toLowerCase() === 'n') {
e.preventDefault(); e.preventDefault();
if (e.shiftKey) { if (e.shiftKey) {
setSessionCreateDialogOpen(true); setSessionCreateDialogOpen(true);
@@ -90,7 +91,7 @@ export const useKeyboardShortcuts = () => {
openNewSessionDraft(); openNewSessionDraft();
} }
if ((e.metaKey || e.ctrlKey) && e.key === '/') { if (hasModifier(e) && e.key === '/') {
e.preventDefault(); e.preventDefault();
const modes: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system']; const modes: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system'];
const currentIndex = modes.indexOf(themeMode); const currentIndex = modes.indexOf(themeMode);
@@ -98,35 +99,35 @@ export const useKeyboardShortcuts = () => {
setThemeMode(modes[nextIndex]); 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(); e.preventDefault();
const { activeMainTab } = useUIStore.getState(); const { activeMainTab } = useUIStore.getState();
setActiveMainTab(activeMainTab === 'git' ? 'chat' : 'git'); setActiveMainTab(activeMainTab === 'git' ? 'chat' : 'git');
return; return;
} }
if (e.ctrlKey && !e.metaKey && !e.shiftKey && e.key.toLowerCase() === 'e') { if (hasModifier(e) && !e.shiftKey && e.key.toLowerCase() === 'e') {
e.preventDefault(); e.preventDefault();
const { activeMainTab } = useUIStore.getState(); const { activeMainTab } = useUIStore.getState();
setActiveMainTab(activeMainTab === 'diff' ? 'chat' : 'diff'); setActiveMainTab(activeMainTab === 'diff' ? 'chat' : 'diff');
return; return;
} }
if (e.ctrlKey && !e.metaKey && !e.shiftKey && e.key.toLowerCase() === 't') { if (hasModifier(e) && !e.shiftKey && e.key.toLowerCase() === 't') {
e.preventDefault(); e.preventDefault();
const { activeMainTab } = useUIStore.getState(); const { activeMainTab } = useUIStore.getState();
setActiveMainTab(activeMainTab === 'terminal' ? 'chat' : 'terminal'); setActiveMainTab(activeMainTab === 'terminal' ? 'chat' : 'terminal');
return; return;
} }
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === ',') { if (hasModifier(e) && !e.shiftKey && e.key === ',') {
e.preventDefault(); e.preventDefault();
const { isSettingsDialogOpen } = useUIStore.getState(); const { isSettingsDialogOpen } = useUIStore.getState();
setSettingsDialogOpen(!isSettingsDialogOpen); setSettingsDialogOpen(!isSettingsDialogOpen);
return; return;
} }
if (e.ctrlKey && !e.metaKey && !e.shiftKey && e.key.toLowerCase() === 'l') { if (hasModifier(e) && !e.shiftKey && e.key.toLowerCase() === 'l') {
e.preventDefault(); e.preventDefault();
const { isMobile, isSessionSwitcherOpen } = useUIStore.getState(); const { isMobile, isSessionSwitcherOpen } = useUIStore.getState();
if (isMobile) { if (isMobile) {
@@ -137,7 +138,7 @@ export const useKeyboardShortcuts = () => {
return; return;
} }
if (e.ctrlKey && !e.metaKey && !e.shiftKey && e.key.toLowerCase() === 'i') { if (hasModifier(e) && !e.shiftKey && e.key.toLowerCase() === 'i') {
e.preventDefault(); e.preventDefault();
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[data-chat-input="true"]'); const textarea = document.querySelector<HTMLTextAreaElement>('textarea[data-chat-input="true"]');
textarea?.focus(); textarea?.focus();
@@ -145,7 +146,7 @@ export const useKeyboardShortcuts = () => {
} }
// Ctrl+M: Open model selector (same conditions as double-ESC: chat tab, no overlays) // 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 { const {
isSettingsDialogOpen, isSettingsDialogOpen,
isCommandPaletteOpen, isCommandPaletteOpen,
+28
View File
@@ -1,10 +1,38 @@
import { clsx, type ClassValue } from "clsx"; import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { isDesktopRuntime } from "@/lib/desktop";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); 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 = ( export const truncatePathMiddle = (
value: string, value: string,
options?: { maxLength?: number } options?: { maxLength?: number }