From a6da65e318eebd7980b0ed70a756b01905952c3a Mon Sep 17 00:00:00 2001 From: Kacper Date: Wed, 10 Dec 2025 22:54:29 +0100 Subject: [PATCH] feat(keyboard): introduce visual keyboard map and shortcut customization - Added a new `KeyboardMap` component for a visual representation of keyboard shortcuts, allowing users to easily customize their shortcuts with single-modifier support. - Integrated `ShortcutReferencePanel` for editing shortcuts directly within the settings view. - Updated the settings view to include a button for opening the keyboard map dialog, enhancing user experience in managing keyboard shortcuts. - Refactored keyboard shortcut handling to support modifier keys and improve shortcut parsing and formatting. --- app/electron/services/claude-cli-detector.js | 299 +++++---- app/src/components/layout/sidebar.tsx | 24 +- app/src/components/ui/keyboard-map.tsx | 640 +++++++++++++++++++ app/src/components/views/settings-view.tsx | 439 ++----------- app/src/hooks/use-keyboard-shortcuts.ts | 46 +- app/src/store/app-store.ts | 67 +- 6 files changed, 1008 insertions(+), 507 deletions(-) create mode 100644 app/src/components/ui/keyboard-map.tsx diff --git a/app/electron/services/claude-cli-detector.js b/app/electron/services/claude-cli-detector.js index f8f4739e..f18f9d0e 100644 --- a/app/electron/services/claude-cli-detector.js +++ b/app/electron/services/claude-cli-detector.js @@ -1,7 +1,7 @@ -const { execSync, spawn } = require('child_process'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); +const { execSync, spawn } = require("child_process"); +const fs = require("fs"); +const path = require("path"); +const os = require("os"); /** * Claude CLI Detector @@ -21,41 +21,47 @@ class ClaudeCliDetector { */ static getUpdatedPathFromShellConfig() { const homeDir = os.homedir(); - const shell = process.env.SHELL || '/bin/bash'; + const shell = process.env.SHELL || "/bin/bash"; const shellName = path.basename(shell); - + // Common shell config files const configFiles = []; - if (shellName.includes('zsh')) { - configFiles.push(path.join(homeDir, '.zshrc')); - configFiles.push(path.join(homeDir, '.zshenv')); - configFiles.push(path.join(homeDir, '.zprofile')); - } else if (shellName.includes('bash')) { - configFiles.push(path.join(homeDir, '.bashrc')); - configFiles.push(path.join(homeDir, '.bash_profile')); - configFiles.push(path.join(homeDir, '.profile')); + if (shellName.includes("zsh")) { + configFiles.push(path.join(homeDir, ".zshrc")); + configFiles.push(path.join(homeDir, ".zshenv")); + configFiles.push(path.join(homeDir, ".zprofile")); + } else if (shellName.includes("bash")) { + configFiles.push(path.join(homeDir, ".bashrc")); + configFiles.push(path.join(homeDir, ".bash_profile")); + configFiles.push(path.join(homeDir, ".profile")); } - + // Also check common locations const commonPaths = [ - path.join(homeDir, '.local', 'bin'), - path.join(homeDir, '.cargo', 'bin'), - '/usr/local/bin', - '/opt/homebrew/bin', - path.join(homeDir, 'bin'), + path.join(homeDir, ".local", "bin"), + path.join(homeDir, ".cargo", "bin"), + "/usr/local/bin", + "/opt/homebrew/bin", + path.join(homeDir, "bin"), ]; - + // Try to extract PATH additions from config files for (const configFile of configFiles) { if (fs.existsSync(configFile)) { try { - const content = fs.readFileSync(configFile, 'utf-8'); + const content = fs.readFileSync(configFile, "utf-8"); // Look for PATH exports that might include claude installation paths - const pathMatches = content.match(/export\s+PATH=["']?([^"'\n]+)["']?/g); + const pathMatches = content.match( + /export\s+PATH=["']?([^"'\n]+)["']?/g + ); if (pathMatches) { for (const match of pathMatches) { - const pathValue = match.replace(/export\s+PATH=["']?/, '').replace(/["']?$/, ''); - const paths = pathValue.split(':').filter(p => p && !p.includes('$')); + const pathValue = match + .replace(/export\s+PATH=["']?/, "") + .replace(/["']?$/, ""); + const paths = pathValue + .split(":") + .filter((p) => p && !p.includes("$")); commonPaths.push(...paths); } } @@ -64,26 +70,33 @@ class ClaudeCliDetector { } } } - + return [...new Set(commonPaths)]; // Remove duplicates } static detectClaudeInstallation() { - console.log('[ClaudeCliDetector] Detecting Claude installation...'); + console.log("[ClaudeCliDetector] Detecting Claude installation..."); try { // Method 1: Check if 'claude' command is in PATH (Unix) - if (process.platform !== 'win32') { + if (process.platform !== "win32") { try { - const claudePath = execSync('which claude 2>/dev/null', { encoding: 'utf-8' }).trim(); + const claudePath = execSync("which claude 2>/dev/null", { + encoding: "utf-8", + }).trim(); if (claudePath) { const version = this.getClaudeVersion(claudePath); - console.log('[ClaudeCliDetector] Found claude at:', claudePath, 'version:', version); + console.log( + "[ClaudeCliDetector] Found claude at:", + claudePath, + "version:", + version + ); return { installed: true, path: claudePath, version: version, - method: 'cli' + method: "cli", }; } } catch (error) { @@ -92,17 +105,26 @@ class ClaudeCliDetector { } // Method 2: Check Windows path - if (process.platform === 'win32') { + if (process.platform === "win32") { try { - const claudePath = execSync('where claude 2>nul', { encoding: 'utf-8' }).trim().split('\n')[0]; + const claudePath = execSync("where claude 2>nul", { + encoding: "utf-8", + }) + .trim() + .split("\n")[0]; if (claudePath) { const version = this.getClaudeVersion(claudePath); - console.log('[ClaudeCliDetector] Found claude at:', claudePath, 'version:', version); + console.log( + "[ClaudeCliDetector] Found claude at:", + claudePath, + "version:", + version + ); return { installed: true, path: claudePath, version: version, - method: 'cli' + method: "cli", }; } } catch (error) { @@ -111,34 +133,49 @@ class ClaudeCliDetector { } // Method 3: Check for local installation - const localClaudePath = path.join(os.homedir(), '.claude', 'local', 'claude'); + const localClaudePath = path.join( + os.homedir(), + ".claude", + "local", + "claude" + ); if (fs.existsSync(localClaudePath)) { const version = this.getClaudeVersion(localClaudePath); - console.log('[ClaudeCliDetector] Found local claude at:', localClaudePath, 'version:', version); + console.log( + "[ClaudeCliDetector] Found local claude at:", + localClaudePath, + "version:", + version + ); return { installed: true, path: localClaudePath, version: version, - method: 'cli-local' + method: "cli-local", }; } // Method 4: Check common installation locations (including those from shell config) const commonPaths = this.getUpdatedPathFromShellConfig(); - const binaryNames = ['claude', 'claude-code']; - + const binaryNames = ["claude", "claude-code"]; + for (const basePath of commonPaths) { for (const binaryName of binaryNames) { const claudePath = path.join(basePath, binaryName); if (fs.existsSync(claudePath)) { try { const version = this.getClaudeVersion(claudePath); - console.log('[ClaudeCliDetector] Found claude at:', claudePath, 'version:', version); + console.log( + "[ClaudeCliDetector] Found claude at:", + claudePath, + "version:", + version + ); return { installed: true, path: claudePath, version: version, - method: 'cli' + method: "cli", }; } catch (error) { // File exists but can't get version, might not be executable @@ -148,29 +185,37 @@ class ClaudeCliDetector { } // Method 5: Try to source shell config and check PATH again (for Unix) - if (process.platform !== 'win32') { + if (process.platform !== "win32") { try { - const shell = process.env.SHELL || '/bin/bash'; + const shell = process.env.SHELL || "/bin/bash"; const shellName = path.basename(shell); const homeDir = os.homedir(); - - let sourceCmd = ''; - if (shellName.includes('zsh')) { + + let sourceCmd = ""; + if (shellName.includes("zsh")) { sourceCmd = `source ${homeDir}/.zshrc 2>/dev/null && which claude`; - } else if (shellName.includes('bash')) { + } else if (shellName.includes("bash")) { sourceCmd = `source ${homeDir}/.bashrc 2>/dev/null && which claude`; } - + if (sourceCmd) { - const claudePath = execSync(`bash -c "${sourceCmd}"`, { encoding: 'utf-8', timeout: 2000 }).trim(); - if (claudePath && claudePath.startsWith('/')) { + const claudePath = execSync(`bash -c "${sourceCmd}"`, { + encoding: "utf-8", + timeout: 2000, + }).trim(); + if (claudePath && claudePath.startsWith("/")) { const version = this.getClaudeVersion(claudePath); - console.log('[ClaudeCliDetector] Found claude via shell config at:', claudePath, 'version:', version); + console.log( + "[ClaudeCliDetector] Found claude via shell config at:", + claudePath, + "version:", + version + ); return { installed: true, path: claudePath, version: version, - method: 'cli' + method: "cli", }; } } @@ -179,21 +224,24 @@ class ClaudeCliDetector { } } - console.log('[ClaudeCliDetector] Claude CLI not found'); + console.log("[ClaudeCliDetector] Claude CLI not found"); return { installed: false, path: null, version: null, - method: 'none' + method: "none", }; } catch (error) { - console.error('[ClaudeCliDetector] Error detecting Claude installation:', error); + console.error( + "[ClaudeCliDetector] Error detecting Claude installation:", + error + ); return { installed: false, path: null, version: null, - method: 'none', - error: error.message + method: "none", + error: error.message, }; } } @@ -206,8 +254,8 @@ class ClaudeCliDetector { static getClaudeVersion(claudePath) { try { const version = execSync(`"${claudePath}" --version 2>/dev/null`, { - encoding: 'utf-8', - timeout: 5000 + encoding: "utf-8", + timeout: 5000, }).trim(); return version || null; } catch (error) { @@ -226,10 +274,10 @@ class ClaudeCliDetector { * @returns {Object} Authentication status */ static getAuthStatus(appCredentialsPath) { - console.log('[ClaudeCliDetector] Checking auth status...'); + console.log("[ClaudeCliDetector] Checking auth status..."); const envApiKey = process.env.ANTHROPIC_API_KEY; - console.log('[ClaudeCliDetector] Env ANTHROPIC_API_KEY:', !!envApiKey); + console.log("[ClaudeCliDetector] Env ANTHROPIC_API_KEY:", !!envApiKey); // Check app's stored credentials let storedOAuthToken = null; @@ -237,38 +285,44 @@ class ClaudeCliDetector { if (appCredentialsPath && fs.existsSync(appCredentialsPath)) { try { - const content = fs.readFileSync(appCredentialsPath, 'utf-8'); + const content = fs.readFileSync(appCredentialsPath, "utf-8"); const credentials = JSON.parse(content); storedOAuthToken = credentials.anthropic_oauth_token || null; - storedApiKey = credentials.anthropic || credentials.anthropic_api_key || null; - console.log('[ClaudeCliDetector] App credentials:', { + storedApiKey = + credentials.anthropic || credentials.anthropic_api_key || null; + console.log("[ClaudeCliDetector] App credentials:", { hasOAuthToken: !!storedOAuthToken, - hasApiKey: !!storedApiKey + hasApiKey: !!storedApiKey, }); } catch (error) { - console.error('[ClaudeCliDetector] Error reading app credentials:', error); + console.error( + "[ClaudeCliDetector] Error reading app credentials:", + error + ); } } // Determine authentication method // Priority: Stored OAuth Token > Stored API Key > Env API Key let authenticated = false; - let method = 'none'; + let method = "none"; if (storedOAuthToken) { authenticated = true; - method = 'oauth_token'; - console.log('[ClaudeCliDetector] Using stored OAuth token (subscription)'); + method = "oauth_token"; + console.log( + "[ClaudeCliDetector] Using stored OAuth token (subscription)" + ); } else if (storedApiKey) { authenticated = true; - method = 'api_key'; - console.log('[ClaudeCliDetector] Using stored API key'); + method = "api_key"; + console.log("[ClaudeCliDetector] Using stored API key"); } else if (envApiKey) { authenticated = true; - method = 'api_key_env'; - console.log('[ClaudeCliDetector] Using environment API key'); + method = "api_key_env"; + console.log("[ClaudeCliDetector] Using environment API key"); } else { - console.log('[ClaudeCliDetector] No authentication found'); + console.log("[ClaudeCliDetector] No authentication found"); } const result = { @@ -276,12 +330,26 @@ class ClaudeCliDetector { method, hasStoredOAuthToken: !!storedOAuthToken, hasStoredApiKey: !!storedApiKey, - hasEnvApiKey: !!envApiKey + hasEnvApiKey: !!envApiKey, }; - console.log('[ClaudeCliDetector] Auth status result:', result); + console.log("[ClaudeCliDetector] Auth status result:", result); return result; } + /** + * Get installation info (installation status only, no auth) + * @returns {Object} Installation info with status property + */ + static getInstallationInfo() { + const installation = this.detectClaudeInstallation(); + return { + status: installation.installed ? "installed" : "not_installed", + installed: installation.installed, + path: installation.path, + version: installation.version, + method: installation.method, + }; + } /** * Get full status including installation and auth @@ -294,12 +362,12 @@ class ClaudeCliDetector { return { success: true, - status: installation.installed ? 'installed' : 'not_installed', + status: installation.installed ? "installed" : "not_installed", installed: installation.installed, path: installation.path, version: installation.version, method: installation.method, - auth + auth, }; } @@ -309,9 +377,9 @@ class ClaudeCliDetector { */ static getInstallCommands() { return { - macos: 'curl -fsSL https://claude.ai/install.sh | bash', - windows: 'irm https://claude.ai/install.ps1 | iex', - linux: 'curl -fsSL https://claude.ai/install.sh | bash' + macos: "curl -fsSL https://claude.ai/install.sh | bash", + windows: "irm https://claude.ai/install.ps1 | iex", + linux: "curl -fsSL https://claude.ai/install.sh | bash", }; } @@ -325,64 +393,69 @@ class ClaudeCliDetector { const platform = process.platform; let command, args; - if (platform === 'win32') { - command = 'powershell'; - args = ['-Command', 'irm https://claude.ai/install.ps1 | iex']; + if (platform === "win32") { + command = "powershell"; + args = ["-Command", "irm https://claude.ai/install.ps1 | iex"]; } else { - command = 'bash'; - args = ['-c', 'curl -fsSL https://claude.ai/install.sh | bash']; + command = "bash"; + args = ["-c", "curl -fsSL https://claude.ai/install.sh | bash"]; } - console.log('[ClaudeCliDetector] Installing Claude CLI...'); + console.log("[ClaudeCliDetector] Installing Claude CLI..."); const proc = spawn(command, args, { - stdio: ['pipe', 'pipe', 'pipe'], - shell: false + stdio: ["pipe", "pipe", "pipe"], + shell: false, }); - let output = ''; - let errorOutput = ''; + let output = ""; + let errorOutput = ""; - proc.stdout.on('data', (data) => { + proc.stdout.on("data", (data) => { const text = data.toString(); output += text; if (onProgress) { - onProgress({ type: 'stdout', data: text }); + onProgress({ type: "stdout", data: text }); } }); - proc.stderr.on('data', (data) => { + proc.stderr.on("data", (data) => { const text = data.toString(); errorOutput += text; if (onProgress) { - onProgress({ type: 'stderr', data: text }); + onProgress({ type: "stderr", data: text }); } }); - proc.on('close', (code) => { + proc.on("close", (code) => { if (code === 0) { - console.log('[ClaudeCliDetector] Installation completed successfully'); + console.log( + "[ClaudeCliDetector] Installation completed successfully" + ); resolve({ success: true, output, - message: 'Claude CLI installed successfully' + message: "Claude CLI installed successfully", }); } else { - console.error('[ClaudeCliDetector] Installation failed with code:', code); + console.error( + "[ClaudeCliDetector] Installation failed with code:", + code + ); reject({ success: false, error: errorOutput || `Installation failed with code ${code}`, - output + output, }); } }); - proc.on('error', (error) => { - console.error('[ClaudeCliDetector] Installation error:', error); + proc.on("error", (error) => { + console.error("[ClaudeCliDetector] Installation error:", error); reject({ success: false, error: error.message, - output + output, }); }); }); @@ -398,22 +471,22 @@ class ClaudeCliDetector { if (!detection.installed) { return { success: false, - error: 'Claude CLI is not installed. Please install it first.', - installCommands: this.getInstallCommands() + error: "Claude CLI is not installed. Please install it first.", + installCommands: this.getInstallCommands(), }; } return { success: true, - command: 'claude setup-token', + command: "claude setup-token", instructions: [ - '1. Open your terminal', - '2. Run: claude setup-token', - '3. Follow the prompts to authenticate', - '4. Copy the token that is displayed', - '5. Paste the token in the field below' + "1. Open your terminal", + "2. Run: claude setup-token", + "3. Follow the prompts to authenticate", + "4. Copy the token that is displayed", + "5. Paste the token in the field below", ], - note: 'This token is from your Claude subscription and allows you to use Claude without API charges.' + note: "This token is from your Claude subscription and allows you to use Claude without API charges.", }; } } diff --git a/app/src/components/layout/sidebar.tsx b/app/src/components/layout/sidebar.tsx index e40c72dd..ddc06f2e 100644 --- a/app/src/components/layout/sidebar.tsx +++ b/app/src/components/layout/sidebar.tsx @@ -2,7 +2,7 @@ import { useState, useMemo, useEffect, useCallback, useRef } from "react"; import { cn } from "@/lib/utils"; -import { useAppStore } from "@/store/app-store"; +import { useAppStore, formatShortcut } from "@/store/app-store"; import { FolderOpen, Plus, @@ -722,7 +722,7 @@ export function Sidebar() { className="ml-1 px-1 py-0.5 bg-brand-500/10 border border-brand-500/30 rounded text-[10px] font-mono text-brand-400/70" data-testid="sidebar-toggle-shortcut" > - {shortcuts.toggleSidebar} + {formatShortcut(shortcuts.toggleSidebar, true)} @@ -779,8 +779,8 @@ export function Sidebar() { data-testid="open-project-button" > - - {shortcuts.openProject} + + {formatShortcut(shortcuts.openProject, true)} + ); + + // Wrap in tooltip if bound + if (isBound) { + return ( + + {keyElement} + +
+ {shortcuts.map((shortcut) => { + const shortcutStr = keyboardShortcuts[shortcut]; + const displayShortcut = formatShortcut(shortcutStr, true); + return ( +
+ + {SHORTCUT_LABELS[shortcut]} + + {displayShortcut} + + {keyboardShortcuts[shortcut] !== DEFAULT_KEYBOARD_SHORTCUTS[shortcut] && ( + (custom) + )} +
+ ); + })} +
+
+
+ ); + } + + return keyElement; + }; + + return ( + +
+ {/* Legend */} +
+ {Object.entries(CATEGORY_COLORS).map(([key, colors]) => ( +
+
+ {colors.label} +
+ ))} +
+
+ Available +
+
+
+ Modified +
+
+ + {/* Keyboard layout */} +
+ {KEYBOARD_ROWS.map((row, rowIndex) => ( +
+ {row.map(renderKey)} +
+ ))} +
+ + {/* Stats */} +
+ + {Object.keys(keyboardShortcuts).length} shortcuts + configured + + + + {Object.keys(keyToShortcuts).length} + {" "} + keys in use + + + + {KEYBOARD_ROWS.flat().length - Object.keys(keyToShortcuts).length} + {" "} + keys available + +
+
+ + ); +} + +// Full shortcut reference panel with editing capability +interface ShortcutReferencePanelProps { + editable?: boolean; +} + +export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePanelProps) { + const { keyboardShortcuts, setKeyboardShortcut, resetKeyboardShortcuts } = useAppStore(); + const [editingShortcut, setEditingShortcut] = React.useState(null); + const [keyValue, setKeyValue] = React.useState(""); + const [modifiers, setModifiers] = React.useState({ shift: false, cmdCtrl: false, alt: false }); + const [shortcutError, setShortcutError] = React.useState(null); + + const groupedShortcuts = React.useMemo(() => { + const groups: Record> = { + navigation: [], + ui: [], + action: [], + }; + + (Object.entries(SHORTCUT_CATEGORIES) as [keyof KeyboardShortcuts, string][]).forEach( + ([shortcut, category]) => { + groups[category].push({ + key: shortcut, + label: SHORTCUT_LABELS[shortcut], + value: keyboardShortcuts[shortcut], + }); + } + ); + + return groups; + }, [keyboardShortcuts]); + + // Build the full shortcut string from key + modifiers + const buildShortcutString = React.useCallback((key: string, mods: typeof modifiers) => { + const parts: string[] = []; + if (mods.cmdCtrl) parts.push(isMac ? "Cmd" : "Ctrl"); + if (mods.alt) parts.push(isMac ? "Opt" : "Alt"); + if (mods.shift) parts.push("Shift"); + parts.push(key.toUpperCase()); + return parts.join("+"); + }, []); + + // Check for conflicts with other shortcuts + const checkConflict = React.useCallback((shortcutStr: string, currentKey: keyof KeyboardShortcuts) => { + const conflict = Object.entries(keyboardShortcuts).find( + ([k, v]) => k !== currentKey && v.toUpperCase() === shortcutStr.toUpperCase() + ); + return conflict ? SHORTCUT_LABELS[conflict[0] as keyof KeyboardShortcuts] : null; + }, [keyboardShortcuts]); + + const handleStartEdit = (key: keyof KeyboardShortcuts) => { + const currentValue = keyboardShortcuts[key]; + const parsed = parseShortcut(currentValue); + setEditingShortcut(key); + setKeyValue(parsed.key); + setModifiers({ + shift: parsed.shift || false, + cmdCtrl: parsed.cmdCtrl || false, + alt: parsed.alt || false, + }); + setShortcutError(null); + }; + + const handleSaveShortcut = () => { + if (!editingShortcut || shortcutError || !keyValue) return; + const shortcutStr = buildShortcutString(keyValue, modifiers); + setKeyboardShortcut(editingShortcut, shortcutStr); + setEditingShortcut(null); + setKeyValue(""); + setModifiers({ shift: false, cmdCtrl: false, alt: false }); + setShortcutError(null); + }; + + const handleCancelEdit = () => { + setEditingShortcut(null); + setKeyValue(""); + setModifiers({ shift: false, cmdCtrl: false, alt: false }); + setShortcutError(null); + }; + + const handleKeyChange = (value: string, currentKey: keyof KeyboardShortcuts) => { + setKeyValue(value); + // Check for conflicts with full shortcut string + if (!value) { + setShortcutError("Key cannot be empty"); + } else { + const shortcutStr = buildShortcutString(value, modifiers); + const conflictLabel = checkConflict(shortcutStr, currentKey); + if (conflictLabel) { + setShortcutError(`Already used by "${conflictLabel}"`); + } else { + setShortcutError(null); + } + } + }; + + const handleModifierChange = (modifier: keyof typeof modifiers, checked: boolean, currentKey: keyof KeyboardShortcuts) => { + // Enforce single modifier: when checking, uncheck all others (radio-button behavior) + const newModifiers = checked + ? { shift: false, cmdCtrl: false, alt: false, [modifier]: true } + : { ...modifiers, [modifier]: false }; + + setModifiers(newModifiers); + + // Recheck for conflicts + if (keyValue) { + const shortcutStr = buildShortcutString(keyValue, newModifiers); + const conflictLabel = checkConflict(shortcutStr, currentKey); + if (conflictLabel) { + setShortcutError(`Already used by "${conflictLabel}"`); + } else { + setShortcutError(null); + } + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !shortcutError && keyValue) { + handleSaveShortcut(); + } else if (e.key === "Escape") { + handleCancelEdit(); + } + }; + + const handleResetShortcut = (key: keyof KeyboardShortcuts) => { + setKeyboardShortcut(key, DEFAULT_KEYBOARD_SHORTCUTS[key]); + }; + + return ( + +
+ {editable && ( +
+ +
+ )} + {Object.entries(groupedShortcuts).map(([category, shortcuts]) => { + const colors = CATEGORY_COLORS[category as keyof typeof CATEGORY_COLORS]; + return ( +
+

+ {colors.label} +

+
+ {shortcuts.map(({ key, label, value }) => { + const isModified = keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key]; + const isEditing = editingShortcut === key; + + return ( +
editable && !isEditing && handleStartEdit(key)} + data-testid={`shortcut-row-${key}`} + > + {label} +
+ {isEditing ? ( +
e.stopPropagation()}> + {/* Modifier checkboxes */} +
+
+ handleModifierChange("cmdCtrl", !!checked, key)} + className="h-3.5 w-3.5" + /> + +
+
+ handleModifierChange("alt", !!checked, key)} + className="h-3.5 w-3.5" + /> + +
+
+ handleModifierChange("shift", !!checked, key)} + className="h-3.5 w-3.5" + /> + +
+
+ + + handleKeyChange(e.target.value, key)} + onKeyDown={handleKeyDown} + className={cn( + "w-12 h-7 text-center font-mono text-xs uppercase", + shortcutError && "border-red-500 focus-visible:ring-red-500" + )} + placeholder="Key" + maxLength={1} + autoFocus + data-testid={`edit-shortcut-input-${key}`} + /> + + +
+ ) : ( + <> + + {formatShortcut(value, true)} + + {isModified && editable && ( + + + + + + Reset to default ({DEFAULT_KEYBOARD_SHORTCUTS[key]}) + + + )} + {isModified && !editable && ( + + )} + {editable && !isModified && ( + + )} + + )} +
+
+ ); + })} +
+ {editingShortcut && shortcutError && SHORTCUT_CATEGORIES[editingShortcut] === category && ( +

{shortcutError}

+ )} +
+ ); + })} +
+
+ ); +} diff --git a/app/src/components/views/settings-view.tsx b/app/src/components/views/settings-view.tsx index b8c386b7..120cb554 100644 --- a/app/src/components/views/settings-view.tsx +++ b/app/src/components/views/settings-view.tsx @@ -1,8 +1,7 @@ "use client"; import { useState, useEffect, useRef, useCallback } from "react"; -import { useAppStore, DEFAULT_KEYBOARD_SHORTCUTS } from "@/store/app-store"; -import type { KeyboardShortcuts } from "@/store/app-store"; +import { useAppStore } from "@/store/app-store"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -41,9 +40,9 @@ import { Settings2, RefreshCw, Info, + Keyboard, } from "lucide-react"; import { getElectronAPI } from "@/lib/electron"; -import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, @@ -53,6 +52,8 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { useSetupStore } from "@/store/setup-store"; +import { KeyboardMap, ShortcutReferencePanel } from "@/components/ui/keyboard-map"; +import { Checkbox } from "../ui/checkbox"; // Navigation items for the side panel const NAV_ITEMS = [ @@ -84,9 +85,6 @@ export function SettingsView() { setShowProfilesOnly, currentProject, moveProjectToTrash, - keyboardShortcuts, - setKeyboardShortcut, - resetKeyboardShortcuts, } = useAppStore(); // Compute the effective theme for the current project @@ -108,6 +106,7 @@ export function SettingsView() { const [showOpenaiKey, setShowOpenaiKey] = useState(false); const [saved, setSaved] = useState(false); const [testingConnection, setTestingConnection] = useState(false); + const [testResult, setTestResult] = useState<{ success: boolean; message: string; @@ -155,6 +154,7 @@ export function SettingsView() { } | null>(null); const [activeSection, setActiveSection] = useState("api-keys"); const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false); const [isCheckingClaudeCli, setIsCheckingClaudeCli] = useState(false); const [isCheckingCodexCli, setIsCheckingCodexCli] = useState(false); const [apiKeyStatus, setApiKeyStatus] = useState<{ @@ -1608,376 +1608,35 @@ export function SettingsView() {

- Customize keyboard shortcuts for navigation and actions. Click - on any shortcut to edit it. + Customize keyboard shortcuts for navigation and actions using the visual keyboard map.

-
- {/* Navigation Shortcuts */} -
-
-

- Navigation +
+ {/* Centered message directing to keyboard map */} +
+
+ +
+
+
+

+ Use the Visual Keyboard Map

- -
-
- {[ - { key: "board" as keyof KeyboardShortcuts, label: "Kanban Board" }, - { key: "agent" as keyof KeyboardShortcuts, label: "Agent Runner" }, - { key: "spec" as keyof KeyboardShortcuts, label: "Spec Editor" }, - { key: "context" as keyof KeyboardShortcuts, label: "Context" }, - { key: "tools" as keyof KeyboardShortcuts, label: "Agent Tools" }, - { key: "profiles" as keyof KeyboardShortcuts, label: "AI Profiles" }, - { key: "settings" as keyof KeyboardShortcuts, label: "Settings" }, - ].map(({ key, label }) => ( -
- {label} -
- {editingShortcut === key ? ( - <> - { - const value = e.target.value.toUpperCase(); - setShortcutValue(value); - // Check for conflicts - const conflict = Object.entries(keyboardShortcuts).find( - ([k, v]) => k !== key && v.toUpperCase() === value - ); - if (conflict) { - setShortcutError(`Already used by ${conflict[0]}`); - } else { - setShortcutError(null); - } - }} - onKeyDown={(e) => { - if (e.key === "Enter" && !shortcutError && shortcutValue) { - setKeyboardShortcut(key, shortcutValue); - setEditingShortcut(null); - setShortcutValue(""); - setShortcutError(null); - } else if (e.key === "Escape") { - setEditingShortcut(null); - setShortcutValue(""); - setShortcutError(null); - } - }} - className="w-24 h-8 text-center font-mono" - placeholder="Key" - maxLength={2} - autoFocus - data-testid={`edit-shortcut-${key}`} - /> - - - - ) : ( - <> - - {keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] && ( - (modified) - )} - - )} -
-
- ))} -
- {shortcutError && ( -

{shortcutError}

- )} -
- - {/* UI Shortcuts */} -
-

- UI Controls -

-
- {[ - { key: "toggleSidebar" as keyof KeyboardShortcuts, label: "Toggle Sidebar" }, - ].map(({ key, label }) => ( -
- {label} -
- {editingShortcut === key ? ( - <> - { - const value = e.target.value; - setShortcutValue(value); - // Check for conflicts - const conflict = Object.entries(keyboardShortcuts).find( - ([k, v]) => k !== key && v === value - ); - if (conflict) { - setShortcutError(`Already used by ${conflict[0]}`); - } else { - setShortcutError(null); - } - }} - onKeyDown={(e) => { - if (e.key === "Enter" && !shortcutError && shortcutValue) { - setKeyboardShortcut(key, shortcutValue); - setEditingShortcut(null); - setShortcutValue(""); - setShortcutError(null); - } else if (e.key === "Escape") { - setEditingShortcut(null); - setShortcutValue(""); - setShortcutError(null); - } - }} - className="w-24 h-8 text-center font-mono" - placeholder="Key" - maxLength={2} - autoFocus - data-testid={`edit-shortcut-${key}`} - /> - - - - ) : ( - <> - - {keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] && ( - (modified) - )} - - )} -
-
- ))} -
-
- - {/* Action Shortcuts */} -
-

- Actions -

-
- {[ - { key: "addFeature" as keyof KeyboardShortcuts, label: "Add Feature" }, - { key: "addContextFile" as keyof KeyboardShortcuts, label: "Add Context File" }, - { key: "startNext" as keyof KeyboardShortcuts, label: "Start Next Features" }, - { key: "newSession" as keyof KeyboardShortcuts, label: "New Session" }, - { key: "openProject" as keyof KeyboardShortcuts, label: "Open Project" }, - { key: "projectPicker" as keyof KeyboardShortcuts, label: "Project Picker" }, - { key: "cyclePrevProject" as keyof KeyboardShortcuts, label: "Previous Project" }, - { key: "cycleNextProject" as keyof KeyboardShortcuts, label: "Next Project" }, - { key: "addProfile" as keyof KeyboardShortcuts, label: "Add Profile" }, - ].map(({ key, label }) => ( -
- {label} -
- {editingShortcut === key ? ( - <> - { - const value = e.target.value.toUpperCase(); - setShortcutValue(value); - // Check for conflicts - const conflict = Object.entries(keyboardShortcuts).find( - ([k, v]) => k !== key && v.toUpperCase() === value - ); - if (conflict) { - setShortcutError(`Already used by ${conflict[0]}`); - } else { - setShortcutError(null); - } - }} - onKeyDown={(e) => { - if (e.key === "Enter" && !shortcutError && shortcutValue) { - setKeyboardShortcut(key, shortcutValue); - setEditingShortcut(null); - setShortcutValue(""); - setShortcutError(null); - } else if (e.key === "Escape") { - setEditingShortcut(null); - setShortcutValue(""); - setShortcutError(null); - } - }} - className="w-24 h-8 text-center font-mono" - placeholder="Key" - maxLength={2} - autoFocus - data-testid={`edit-shortcut-${key}`} - /> - - - - ) : ( - <> - - {keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] && ( - (modified) - )} - - )} -
-
- ))} -
-
- - {/* Information */} -
- -
-

- About Keyboard Shortcuts -

-

- Shortcuts won't trigger when typing in input fields. Use - single keys (A-Z, 0-9) or special keys like ` (backtick). - Changes take effect immediately. +

+ Click the "View Keyboard Map" button above to customize your keyboard shortcuts. + The visual interface shows all available keys and lets you easily edit shortcuts with + single-modifier restrictions.

+
@@ -2169,6 +1828,44 @@ export function SettingsView() {

+ {/* Keyboard Map Dialog */} + + + + + + Keyboard Shortcut Map + + + Visual overview of all keyboard shortcuts. Keys in color are bound to shortcuts. + Click on any shortcut below to edit it. + + + +
+ {/* Visual Keyboard Map */} + + + {/* Shortcut Reference - Editable */} +
+

+ All Shortcuts Reference (Click to Edit) +

+ +
+
+ + + + +
+
+ {/* Delete Project Confirmation Dialog */} diff --git a/app/src/hooks/use-keyboard-shortcuts.ts b/app/src/hooks/use-keyboard-shortcuts.ts index a9b901ff..8e12c2f5 100644 --- a/app/src/hooks/use-keyboard-shortcuts.ts +++ b/app/src/hooks/use-keyboard-shortcuts.ts @@ -1,10 +1,10 @@ "use client"; import { useEffect, useCallback } from "react"; -import { useAppStore } from "@/store/app-store"; +import { useAppStore, parseShortcut } from "@/store/app-store"; export interface KeyboardShortcut { - key: string; + key: string; // Can be simple "K" or with modifiers "Shift+N", "Cmd+K" action: () => void; description?: string; } @@ -59,9 +59,44 @@ function isInputFocused(): boolean { return false; } +/** + * Check if a keyboard event matches a shortcut definition + */ +function matchesShortcut(event: KeyboardEvent, shortcutStr: string): boolean { + const shortcut = parseShortcut(shortcutStr); + + // Check if the key matches (case-insensitive) + if (event.key.toLowerCase() !== shortcut.key.toLowerCase()) { + return false; + } + + // Check modifier keys + const cmdCtrlPressed = event.metaKey || event.ctrlKey; + const shiftPressed = event.shiftKey; + const altPressed = event.altKey; + + // If shortcut requires cmdCtrl, it must be pressed + if (shortcut.cmdCtrl && !cmdCtrlPressed) return false; + // If shortcut doesn't require cmdCtrl, it shouldn't be pressed + if (!shortcut.cmdCtrl && cmdCtrlPressed) return false; + + // If shortcut requires shift, it must be pressed + if (shortcut.shift && !shiftPressed) return false; + // If shortcut doesn't require shift, it shouldn't be pressed + if (!shortcut.shift && shiftPressed) return false; + + // If shortcut requires alt, it must be pressed + if (shortcut.alt && !altPressed) return false; + // If shortcut doesn't require alt, it shouldn't be pressed + if (!shortcut.alt && altPressed) return false; + + return true; +} + /** * Hook to manage keyboard shortcuts * Shortcuts won't fire when user is typing in inputs, textareas, or when dialogs are open + * Supports modifier keys: Shift, Cmd/Ctrl, Alt/Option */ export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) { const handleKeyDown = useCallback( @@ -71,14 +106,9 @@ export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) { return; } - // Don't trigger if any modifier keys are pressed (except for specific combos we want) - if (event.ctrlKey || event.altKey || event.metaKey) { - return; - } - // Find matching shortcut const matchingShortcut = shortcuts.find( - (shortcut) => shortcut.key.toLowerCase() === event.key.toLowerCase() + (shortcut) => matchesShortcut(event, shortcut.key) ); if (matchingShortcut) { diff --git a/app/src/store/app-store.ts b/app/src/store/app-store.ts index 7c9ced56..a6f86a78 100644 --- a/app/src/store/app-store.ts +++ b/app/src/store/app-store.ts @@ -37,7 +37,68 @@ export interface ApiKeys { openai: string; } -// Keyboard Shortcuts +// Keyboard Shortcut with optional modifiers +export interface ShortcutKey { + key: string; // The main key (e.g., "K", "N", "1") + shift?: boolean; // Shift key modifier + cmdCtrl?: boolean; // Cmd on Mac, Ctrl on Windows/Linux + alt?: boolean; // Alt/Option key modifier +} + +// Helper to parse shortcut string to ShortcutKey object +export function parseShortcut(shortcut: string): ShortcutKey { + const parts = shortcut.split("+").map(p => p.trim()); + const result: ShortcutKey = { key: parts[parts.length - 1] }; + + for (let i = 0; i < parts.length - 1; i++) { + const modifier = parts[i].toLowerCase(); + if (modifier === "shift") result.shift = true; + else if (modifier === "cmd" || modifier === "ctrl" || modifier === "win" || modifier === "super" || modifier === "⌘" || modifier === "^" || modifier === "⊞" || modifier === "◆") result.cmdCtrl = true; + else if (modifier === "alt" || modifier === "opt" || modifier === "option" || modifier === "⌥") result.alt = true; + } + + return result; +} + +// Helper to format ShortcutKey to display string +export function formatShortcut(shortcut: string, forDisplay = false): string { + const parsed = parseShortcut(shortcut); + const parts: string[] = []; + + // Improved OS detection + let platform: 'darwin' | 'win32' | 'linux' = 'linux'; + if (typeof navigator !== 'undefined') { + const p = navigator.platform.toLowerCase(); + if (p.includes('mac')) platform = 'darwin'; + else if (p.includes('win')) platform = 'win32'; + } + + // Primary modifier - OS-specific + if (parsed.cmdCtrl) { + if (forDisplay) { + parts.push(platform === 'darwin' ? '⌘' : platform === 'win32' ? '⊞' : '◆'); + } else { + parts.push(platform === 'darwin' ? 'Cmd' : platform === 'win32' ? 'Win' : 'Super'); + } + } + + // Alt/Option + if (parsed.alt) { + parts.push(forDisplay ? (platform === 'darwin' ? '⌥' : 'Alt') : (platform === 'darwin' ? 'Opt' : 'Alt')); + } + + // Shift + if (parsed.shift) { + parts.push(forDisplay ? '⇧' : 'Shift'); + } + + parts.push(parsed.key.toUpperCase()); + + // Add spacing when displaying symbols + return parts.join(forDisplay ? " " : "+"); +} + +// Keyboard Shortcuts - stored as strings like "K", "Shift+N", "Cmd+K" export interface KeyboardShortcuts { // Navigation shortcuts board: string; @@ -47,10 +108,10 @@ export interface KeyboardShortcuts { tools: string; settings: string; profiles: string; - + // UI shortcuts toggleSidebar: string; - + // Action shortcuts addFeature: string; addContextFile: string;