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.
This commit is contained in:
Kacper
2025-12-10 22:54:29 +01:00
parent 344651a981
commit a6da65e318
6 changed files with 1008 additions and 507 deletions

View File

@@ -1,7 +1,7 @@
const { execSync, spawn } = require('child_process'); const { execSync, spawn } = require("child_process");
const fs = require('fs'); const fs = require("fs");
const path = require('path'); const path = require("path");
const os = require('os'); const os = require("os");
/** /**
* Claude CLI Detector * Claude CLI Detector
@@ -21,41 +21,47 @@ class ClaudeCliDetector {
*/ */
static getUpdatedPathFromShellConfig() { static getUpdatedPathFromShellConfig() {
const homeDir = os.homedir(); const homeDir = os.homedir();
const shell = process.env.SHELL || '/bin/bash'; const shell = process.env.SHELL || "/bin/bash";
const shellName = path.basename(shell); const shellName = path.basename(shell);
// Common shell config files // Common shell config files
const configFiles = []; const configFiles = [];
if (shellName.includes('zsh')) { if (shellName.includes("zsh")) {
configFiles.push(path.join(homeDir, '.zshrc')); configFiles.push(path.join(homeDir, ".zshrc"));
configFiles.push(path.join(homeDir, '.zshenv')); configFiles.push(path.join(homeDir, ".zshenv"));
configFiles.push(path.join(homeDir, '.zprofile')); configFiles.push(path.join(homeDir, ".zprofile"));
} else if (shellName.includes('bash')) { } else if (shellName.includes("bash")) {
configFiles.push(path.join(homeDir, '.bashrc')); configFiles.push(path.join(homeDir, ".bashrc"));
configFiles.push(path.join(homeDir, '.bash_profile')); configFiles.push(path.join(homeDir, ".bash_profile"));
configFiles.push(path.join(homeDir, '.profile')); configFiles.push(path.join(homeDir, ".profile"));
} }
// Also check common locations // Also check common locations
const commonPaths = [ const commonPaths = [
path.join(homeDir, '.local', 'bin'), path.join(homeDir, ".local", "bin"),
path.join(homeDir, '.cargo', 'bin'), path.join(homeDir, ".cargo", "bin"),
'/usr/local/bin', "/usr/local/bin",
'/opt/homebrew/bin', "/opt/homebrew/bin",
path.join(homeDir, 'bin'), path.join(homeDir, "bin"),
]; ];
// Try to extract PATH additions from config files // Try to extract PATH additions from config files
for (const configFile of configFiles) { for (const configFile of configFiles) {
if (fs.existsSync(configFile)) { if (fs.existsSync(configFile)) {
try { 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 // 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) { if (pathMatches) {
for (const match of pathMatches) { for (const match of pathMatches) {
const pathValue = match.replace(/export\s+PATH=["']?/, '').replace(/["']?$/, ''); const pathValue = match
const paths = pathValue.split(':').filter(p => p && !p.includes('$')); .replace(/export\s+PATH=["']?/, "")
.replace(/["']?$/, "");
const paths = pathValue
.split(":")
.filter((p) => p && !p.includes("$"));
commonPaths.push(...paths); commonPaths.push(...paths);
} }
} }
@@ -64,26 +70,33 @@ class ClaudeCliDetector {
} }
} }
} }
return [...new Set(commonPaths)]; // Remove duplicates return [...new Set(commonPaths)]; // Remove duplicates
} }
static detectClaudeInstallation() { static detectClaudeInstallation() {
console.log('[ClaudeCliDetector] Detecting Claude installation...'); console.log("[ClaudeCliDetector] Detecting Claude installation...");
try { try {
// Method 1: Check if 'claude' command is in PATH (Unix) // Method 1: Check if 'claude' command is in PATH (Unix)
if (process.platform !== 'win32') { if (process.platform !== "win32") {
try { 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) { if (claudePath) {
const version = this.getClaudeVersion(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 { return {
installed: true, installed: true,
path: claudePath, path: claudePath,
version: version, version: version,
method: 'cli' method: "cli",
}; };
} }
} catch (error) { } catch (error) {
@@ -92,17 +105,26 @@ class ClaudeCliDetector {
} }
// Method 2: Check Windows path // Method 2: Check Windows path
if (process.platform === 'win32') { if (process.platform === "win32") {
try { 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) { if (claudePath) {
const version = this.getClaudeVersion(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 { return {
installed: true, installed: true,
path: claudePath, path: claudePath,
version: version, version: version,
method: 'cli' method: "cli",
}; };
} }
} catch (error) { } catch (error) {
@@ -111,34 +133,49 @@ class ClaudeCliDetector {
} }
// Method 3: Check for local installation // 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)) { if (fs.existsSync(localClaudePath)) {
const version = this.getClaudeVersion(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 { return {
installed: true, installed: true,
path: localClaudePath, path: localClaudePath,
version: version, version: version,
method: 'cli-local' method: "cli-local",
}; };
} }
// Method 4: Check common installation locations (including those from shell config) // Method 4: Check common installation locations (including those from shell config)
const commonPaths = this.getUpdatedPathFromShellConfig(); const commonPaths = this.getUpdatedPathFromShellConfig();
const binaryNames = ['claude', 'claude-code']; const binaryNames = ["claude", "claude-code"];
for (const basePath of commonPaths) { for (const basePath of commonPaths) {
for (const binaryName of binaryNames) { for (const binaryName of binaryNames) {
const claudePath = path.join(basePath, binaryName); const claudePath = path.join(basePath, binaryName);
if (fs.existsSync(claudePath)) { if (fs.existsSync(claudePath)) {
try { try {
const version = this.getClaudeVersion(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 { return {
installed: true, installed: true,
path: claudePath, path: claudePath,
version: version, version: version,
method: 'cli' method: "cli",
}; };
} catch (error) { } catch (error) {
// File exists but can't get version, might not be executable // 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) // Method 5: Try to source shell config and check PATH again (for Unix)
if (process.platform !== 'win32') { if (process.platform !== "win32") {
try { try {
const shell = process.env.SHELL || '/bin/bash'; const shell = process.env.SHELL || "/bin/bash";
const shellName = path.basename(shell); const shellName = path.basename(shell);
const homeDir = os.homedir(); const homeDir = os.homedir();
let sourceCmd = ''; let sourceCmd = "";
if (shellName.includes('zsh')) { if (shellName.includes("zsh")) {
sourceCmd = `source ${homeDir}/.zshrc 2>/dev/null && which claude`; 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`; sourceCmd = `source ${homeDir}/.bashrc 2>/dev/null && which claude`;
} }
if (sourceCmd) { if (sourceCmd) {
const claudePath = execSync(`bash -c "${sourceCmd}"`, { encoding: 'utf-8', timeout: 2000 }).trim(); const claudePath = execSync(`bash -c "${sourceCmd}"`, {
if (claudePath && claudePath.startsWith('/')) { encoding: "utf-8",
timeout: 2000,
}).trim();
if (claudePath && claudePath.startsWith("/")) {
const version = this.getClaudeVersion(claudePath); 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 { return {
installed: true, installed: true,
path: claudePath, path: claudePath,
version: version, 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 { return {
installed: false, installed: false,
path: null, path: null,
version: null, version: null,
method: 'none' method: "none",
}; };
} catch (error) { } catch (error) {
console.error('[ClaudeCliDetector] Error detecting Claude installation:', error); console.error(
"[ClaudeCliDetector] Error detecting Claude installation:",
error
);
return { return {
installed: false, installed: false,
path: null, path: null,
version: null, version: null,
method: 'none', method: "none",
error: error.message error: error.message,
}; };
} }
} }
@@ -206,8 +254,8 @@ class ClaudeCliDetector {
static getClaudeVersion(claudePath) { static getClaudeVersion(claudePath) {
try { try {
const version = execSync(`"${claudePath}" --version 2>/dev/null`, { const version = execSync(`"${claudePath}" --version 2>/dev/null`, {
encoding: 'utf-8', encoding: "utf-8",
timeout: 5000 timeout: 5000,
}).trim(); }).trim();
return version || null; return version || null;
} catch (error) { } catch (error) {
@@ -226,10 +274,10 @@ class ClaudeCliDetector {
* @returns {Object} Authentication status * @returns {Object} Authentication status
*/ */
static getAuthStatus(appCredentialsPath) { static getAuthStatus(appCredentialsPath) {
console.log('[ClaudeCliDetector] Checking auth status...'); console.log("[ClaudeCliDetector] Checking auth status...");
const envApiKey = process.env.ANTHROPIC_API_KEY; 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 // Check app's stored credentials
let storedOAuthToken = null; let storedOAuthToken = null;
@@ -237,38 +285,44 @@ class ClaudeCliDetector {
if (appCredentialsPath && fs.existsSync(appCredentialsPath)) { if (appCredentialsPath && fs.existsSync(appCredentialsPath)) {
try { try {
const content = fs.readFileSync(appCredentialsPath, 'utf-8'); const content = fs.readFileSync(appCredentialsPath, "utf-8");
const credentials = JSON.parse(content); const credentials = JSON.parse(content);
storedOAuthToken = credentials.anthropic_oauth_token || null; storedOAuthToken = credentials.anthropic_oauth_token || null;
storedApiKey = credentials.anthropic || credentials.anthropic_api_key || null; storedApiKey =
console.log('[ClaudeCliDetector] App credentials:', { credentials.anthropic || credentials.anthropic_api_key || null;
console.log("[ClaudeCliDetector] App credentials:", {
hasOAuthToken: !!storedOAuthToken, hasOAuthToken: !!storedOAuthToken,
hasApiKey: !!storedApiKey hasApiKey: !!storedApiKey,
}); });
} catch (error) { } catch (error) {
console.error('[ClaudeCliDetector] Error reading app credentials:', error); console.error(
"[ClaudeCliDetector] Error reading app credentials:",
error
);
} }
} }
// Determine authentication method // Determine authentication method
// Priority: Stored OAuth Token > Stored API Key > Env API Key // Priority: Stored OAuth Token > Stored API Key > Env API Key
let authenticated = false; let authenticated = false;
let method = 'none'; let method = "none";
if (storedOAuthToken) { if (storedOAuthToken) {
authenticated = true; authenticated = true;
method = 'oauth_token'; method = "oauth_token";
console.log('[ClaudeCliDetector] Using stored OAuth token (subscription)'); console.log(
"[ClaudeCliDetector] Using stored OAuth token (subscription)"
);
} else if (storedApiKey) { } else if (storedApiKey) {
authenticated = true; authenticated = true;
method = 'api_key'; method = "api_key";
console.log('[ClaudeCliDetector] Using stored API key'); console.log("[ClaudeCliDetector] Using stored API key");
} else if (envApiKey) { } else if (envApiKey) {
authenticated = true; authenticated = true;
method = 'api_key_env'; method = "api_key_env";
console.log('[ClaudeCliDetector] Using environment API key'); console.log("[ClaudeCliDetector] Using environment API key");
} else { } else {
console.log('[ClaudeCliDetector] No authentication found'); console.log("[ClaudeCliDetector] No authentication found");
} }
const result = { const result = {
@@ -276,12 +330,26 @@ class ClaudeCliDetector {
method, method,
hasStoredOAuthToken: !!storedOAuthToken, hasStoredOAuthToken: !!storedOAuthToken,
hasStoredApiKey: !!storedApiKey, hasStoredApiKey: !!storedApiKey,
hasEnvApiKey: !!envApiKey hasEnvApiKey: !!envApiKey,
}; };
console.log('[ClaudeCliDetector] Auth status result:', result); console.log("[ClaudeCliDetector] Auth status result:", result);
return 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 * Get full status including installation and auth
@@ -294,12 +362,12 @@ class ClaudeCliDetector {
return { return {
success: true, success: true,
status: installation.installed ? 'installed' : 'not_installed', status: installation.installed ? "installed" : "not_installed",
installed: installation.installed, installed: installation.installed,
path: installation.path, path: installation.path,
version: installation.version, version: installation.version,
method: installation.method, method: installation.method,
auth auth,
}; };
} }
@@ -309,9 +377,9 @@ class ClaudeCliDetector {
*/ */
static getInstallCommands() { static getInstallCommands() {
return { return {
macos: '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', windows: "irm https://claude.ai/install.ps1 | iex",
linux: 'curl -fsSL https://claude.ai/install.sh | bash' linux: "curl -fsSL https://claude.ai/install.sh | bash",
}; };
} }
@@ -325,64 +393,69 @@ class ClaudeCliDetector {
const platform = process.platform; const platform = process.platform;
let command, args; let command, args;
if (platform === 'win32') { if (platform === "win32") {
command = 'powershell'; command = "powershell";
args = ['-Command', 'irm https://claude.ai/install.ps1 | iex']; args = ["-Command", "irm https://claude.ai/install.ps1 | iex"];
} else { } else {
command = 'bash'; command = "bash";
args = ['-c', 'curl -fsSL https://claude.ai/install.sh | 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, { const proc = spawn(command, args, {
stdio: ['pipe', 'pipe', 'pipe'], stdio: ["pipe", "pipe", "pipe"],
shell: false shell: false,
}); });
let output = ''; let output = "";
let errorOutput = ''; let errorOutput = "";
proc.stdout.on('data', (data) => { proc.stdout.on("data", (data) => {
const text = data.toString(); const text = data.toString();
output += text; output += text;
if (onProgress) { 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(); const text = data.toString();
errorOutput += text; errorOutput += text;
if (onProgress) { if (onProgress) {
onProgress({ type: 'stderr', data: text }); onProgress({ type: "stderr", data: text });
} }
}); });
proc.on('close', (code) => { proc.on("close", (code) => {
if (code === 0) { if (code === 0) {
console.log('[ClaudeCliDetector] Installation completed successfully'); console.log(
"[ClaudeCliDetector] Installation completed successfully"
);
resolve({ resolve({
success: true, success: true,
output, output,
message: 'Claude CLI installed successfully' message: "Claude CLI installed successfully",
}); });
} else { } else {
console.error('[ClaudeCliDetector] Installation failed with code:', code); console.error(
"[ClaudeCliDetector] Installation failed with code:",
code
);
reject({ reject({
success: false, success: false,
error: errorOutput || `Installation failed with code ${code}`, error: errorOutput || `Installation failed with code ${code}`,
output output,
}); });
} }
}); });
proc.on('error', (error) => { proc.on("error", (error) => {
console.error('[ClaudeCliDetector] Installation error:', error); console.error("[ClaudeCliDetector] Installation error:", error);
reject({ reject({
success: false, success: false,
error: error.message, error: error.message,
output output,
}); });
}); });
}); });
@@ -398,22 +471,22 @@ class ClaudeCliDetector {
if (!detection.installed) { if (!detection.installed) {
return { return {
success: false, success: false,
error: 'Claude CLI is not installed. Please install it first.', error: "Claude CLI is not installed. Please install it first.",
installCommands: this.getInstallCommands() installCommands: this.getInstallCommands(),
}; };
} }
return { return {
success: true, success: true,
command: 'claude setup-token', command: "claude setup-token",
instructions: [ instructions: [
'1. Open your terminal', "1. Open your terminal",
'2. Run: claude setup-token', "2. Run: claude setup-token",
'3. Follow the prompts to authenticate', "3. Follow the prompts to authenticate",
'4. Copy the token that is displayed', "4. Copy the token that is displayed",
'5. Paste the token in the field below' "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.",
}; };
} }
} }

View File

@@ -2,7 +2,7 @@
import { useState, useMemo, useEffect, useCallback, useRef } from "react"; import { useState, useMemo, useEffect, useCallback, useRef } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useAppStore } from "@/store/app-store"; import { useAppStore, formatShortcut } from "@/store/app-store";
import { import {
FolderOpen, FolderOpen,
Plus, 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" 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" data-testid="sidebar-toggle-shortcut"
> >
{shortcuts.toggleSidebar} {formatShortcut(shortcuts.toggleSidebar, true)}
</span> </span>
</div> </div>
</button> </button>
@@ -779,8 +779,8 @@ export function Sidebar() {
data-testid="open-project-button" data-testid="open-project-button"
> >
<FolderOpen className="w-4 h-4 shrink-0" /> <FolderOpen className="w-4 h-4 shrink-0" />
<span className="hidden lg:flex items-center justify-center w-5 h-5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70 ml-2"> <span className="hidden lg:flex items-center justify-center min-w-5 h-5 px-1 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70 ml-2">
{shortcuts.openProject} {formatShortcut(shortcuts.openProject, true)}
</span> </span>
</button> </button>
<button <button
@@ -819,10 +819,10 @@ export function Sidebar() {
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span <span
className="hidden lg:flex items-center justify-center w-5 h-5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70" className="hidden lg:flex items-center justify-center min-w-5 h-5 px-1 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70"
data-testid="project-picker-shortcut" data-testid="project-picker-shortcut"
> >
{shortcuts.projectPicker} {formatShortcut(shortcuts.projectPicker, true)}
</span> </span>
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" /> <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
</div> </div>
@@ -958,14 +958,14 @@ export function Sidebar() {
<Undo2 className="w-4 h-4 mr-2" /> <Undo2 className="w-4 h-4 mr-2" />
<span className="flex-1">Previous</span> <span className="flex-1">Previous</span>
<span className="text-[10px] font-mono text-muted-foreground ml-2"> <span className="text-[10px] font-mono text-muted-foreground ml-2">
{shortcuts.cyclePrevProject} {formatShortcut(shortcuts.cyclePrevProject, true)}
</span> </span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={cycleNextProject} data-testid="cycle-next-project"> <DropdownMenuItem onClick={cycleNextProject} data-testid="cycle-next-project">
<Redo2 className="w-4 h-4 mr-2" /> <Redo2 className="w-4 h-4 mr-2" />
<span className="flex-1">Next</span> <span className="flex-1">Next</span>
<span className="text-[10px] font-mono text-muted-foreground ml-2"> <span className="text-[10px] font-mono text-muted-foreground ml-2">
{shortcuts.cycleNextProject} {formatShortcut(shortcuts.cycleNextProject, true)}
</span> </span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={clearProjectHistory} data-testid="clear-project-history"> <DropdownMenuItem onClick={clearProjectHistory} data-testid="clear-project-history">
@@ -1049,13 +1049,13 @@ export function Sidebar() {
{item.shortcut && sidebarOpen && ( {item.shortcut && sidebarOpen && (
<span <span
className={cn( className={cn(
"hidden lg:flex items-center justify-center w-5 h-5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70", "hidden lg:flex items-center justify-center min-w-5 h-5 px-1 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70",
isActive && isActive &&
"bg-brand-500/20 border-brand-500/50 text-brand-400" "bg-brand-500/20 border-brand-500/50 text-brand-400"
)} )}
data-testid={`shortcut-${item.id}`} data-testid={`shortcut-${item.id}`}
> >
{item.shortcut} {formatShortcut(item.shortcut, true)}
</span> </span>
)} )}
{/* Tooltip for collapsed state */} {/* Tooltip for collapsed state */}
@@ -1115,13 +1115,13 @@ export function Sidebar() {
{sidebarOpen && ( {sidebarOpen && (
<span <span
className={cn( className={cn(
"hidden lg:flex items-center justify-center w-5 h-5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70", "hidden lg:flex items-center justify-center min-w-5 h-5 px-1 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70",
isActiveRoute("settings") && isActiveRoute("settings") &&
"bg-brand-500/20 border-brand-500/50 text-brand-400" "bg-brand-500/20 border-brand-500/50 text-brand-400"
)} )}
data-testid="shortcut-settings" data-testid="shortcut-settings"
> >
{shortcuts.settings} {formatShortcut(shortcuts.settings, true)}
</span> </span>
)} )}
{!sidebarOpen && ( {!sidebarOpen && (

View File

@@ -0,0 +1,640 @@
"use client";
import * as React from "react";
import { useAppStore, DEFAULT_KEYBOARD_SHORTCUTS, parseShortcut, formatShortcut } from "@/store/app-store";
import type { KeyboardShortcuts } from "@/store/app-store";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { CheckCircle2, X, RotateCcw, Edit2 } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
// Detect if running on Mac
const isMac = typeof navigator !== 'undefined' && navigator.platform.toUpperCase().indexOf('MAC') >= 0;
// Keyboard layout - US QWERTY
const KEYBOARD_ROWS = [
// Number row
[
{ key: "`", label: "`", width: 1 },
{ key: "1", label: "1", width: 1 },
{ key: "2", label: "2", width: 1 },
{ key: "3", label: "3", width: 1 },
{ key: "4", label: "4", width: 1 },
{ key: "5", label: "5", width: 1 },
{ key: "6", label: "6", width: 1 },
{ key: "7", label: "7", width: 1 },
{ key: "8", label: "8", width: 1 },
{ key: "9", label: "9", width: 1 },
{ key: "0", label: "0", width: 1 },
{ key: "-", label: "-", width: 1 },
{ key: "=", label: "=", width: 1 },
],
// Top letter row
[
{ key: "Q", label: "Q", width: 1 },
{ key: "W", label: "W", width: 1 },
{ key: "E", label: "E", width: 1 },
{ key: "R", label: "R", width: 1 },
{ key: "T", label: "T", width: 1 },
{ key: "Y", label: "Y", width: 1 },
{ key: "U", label: "U", width: 1 },
{ key: "I", label: "I", width: 1 },
{ key: "O", label: "O", width: 1 },
{ key: "P", label: "P", width: 1 },
{ key: "[", label: "[", width: 1 },
{ key: "]", label: "]", width: 1 },
{ key: "\\", label: "\\", width: 1 },
],
// Home row
[
{ key: "A", label: "A", width: 1 },
{ key: "S", label: "S", width: 1 },
{ key: "D", label: "D", width: 1 },
{ key: "F", label: "F", width: 1 },
{ key: "G", label: "G", width: 1 },
{ key: "H", label: "H", width: 1 },
{ key: "J", label: "J", width: 1 },
{ key: "K", label: "K", width: 1 },
{ key: "L", label: "L", width: 1 },
{ key: ";", label: ";", width: 1 },
{ key: "'", label: "'", width: 1 },
],
// Bottom letter row
[
{ key: "Z", label: "Z", width: 1 },
{ key: "X", label: "X", width: 1 },
{ key: "C", label: "C", width: 1 },
{ key: "V", label: "V", width: 1 },
{ key: "B", label: "B", width: 1 },
{ key: "N", label: "N", width: 1 },
{ key: "M", label: "M", width: 1 },
{ key: ",", label: ",", width: 1 },
{ key: ".", label: ".", width: 1 },
{ key: "/", label: "/", width: 1 },
],
];
// Map shortcut names to human-readable labels
const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
board: "Kanban Board",
agent: "Agent Runner",
spec: "Spec Editor",
context: "Context",
tools: "Agent Tools",
settings: "Settings",
profiles: "AI Profiles",
toggleSidebar: "Toggle Sidebar",
addFeature: "Add Feature",
addContextFile: "Add Context File",
startNext: "Start Next",
newSession: "New Session",
openProject: "Open Project",
projectPicker: "Project Picker",
cyclePrevProject: "Prev Project",
cycleNextProject: "Next Project",
addProfile: "Add Profile",
};
// Categorize shortcuts for color coding
const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, "navigation" | "ui" | "action"> = {
board: "navigation",
agent: "navigation",
spec: "navigation",
context: "navigation",
tools: "navigation",
settings: "navigation",
profiles: "navigation",
toggleSidebar: "ui",
addFeature: "action",
addContextFile: "action",
startNext: "action",
newSession: "action",
openProject: "action",
projectPicker: "action",
cyclePrevProject: "action",
cycleNextProject: "action",
addProfile: "action",
};
// Category colors
const CATEGORY_COLORS = {
navigation: {
bg: "bg-blue-500/20",
border: "border-blue-500/50",
text: "text-blue-400",
label: "Navigation",
},
ui: {
bg: "bg-purple-500/20",
border: "border-purple-500/50",
text: "text-purple-400",
label: "UI Controls",
},
action: {
bg: "bg-green-500/20",
border: "border-green-500/50",
text: "text-green-400",
label: "Actions",
},
};
interface KeyboardMapProps {
onKeySelect?: (key: string) => void;
selectedKey?: string | null;
className?: string;
}
export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMapProps) {
const { keyboardShortcuts } = useAppStore();
// Create a reverse map: base key -> list of shortcut names (including info about modifiers)
const keyToShortcuts = React.useMemo(() => {
const map: Record<string, Array<{ name: keyof KeyboardShortcuts; hasModifiers: boolean }>> = {};
(Object.entries(keyboardShortcuts) as [keyof KeyboardShortcuts, string][]).forEach(
([shortcutName, shortcutStr]) => {
const parsed = parseShortcut(shortcutStr);
const normalizedKey = parsed.key.toUpperCase();
const hasModifiers = !!(parsed.shift || parsed.cmdCtrl || parsed.alt);
if (!map[normalizedKey]) {
map[normalizedKey] = [];
}
map[normalizedKey].push({ name: shortcutName, hasModifiers });
}
);
return map;
}, [keyboardShortcuts]);
const renderKey = (keyDef: { key: string; label: string; width: number }) => {
const normalizedKey = keyDef.key.toUpperCase();
const shortcutInfos = keyToShortcuts[normalizedKey] || [];
const shortcuts = shortcutInfos.map(s => s.name);
const isBound = shortcuts.length > 0;
const isSelected = selectedKey?.toUpperCase() === normalizedKey;
const isModified = shortcuts.some(
(s) => keyboardShortcuts[s] !== DEFAULT_KEYBOARD_SHORTCUTS[s]
);
const hasModifierShortcuts = shortcutInfos.some(s => s.hasModifiers);
// Get category for coloring (use first shortcut's category if multiple)
const category = shortcuts.length > 0 ? SHORTCUT_CATEGORIES[shortcuts[0]] : null;
const colors = category ? CATEGORY_COLORS[category] : null;
const keyElement = (
<button
key={keyDef.key}
onClick={() => onKeySelect?.(keyDef.key)}
className={cn(
"relative flex flex-col items-center justify-center rounded-lg border transition-all",
"h-12 min-w-[2.75rem] py-1",
keyDef.width > 1 && `w-[${keyDef.width * 2.75}rem]`,
// Base styles
!isBound && "bg-sidebar-accent/10 border-sidebar-border hover:bg-sidebar-accent/20",
// Bound key styles
isBound && colors && `${colors.bg} ${colors.border} hover:brightness-110`,
// Selected state
isSelected && "ring-2 ring-brand-500 ring-offset-2 ring-offset-background",
// Modified indicator
isModified && "ring-1 ring-yellow-500/50"
)}
data-testid={`keyboard-key-${keyDef.key}`}
>
{/* Key label - always at top */}
<span
className={cn(
"text-sm font-mono font-bold leading-none",
isBound && colors ? colors.text : "text-muted-foreground"
)}
>
{keyDef.label}
</span>
{/* Shortcut label - always takes up space to maintain consistent height */}
<span
className={cn(
"text-[9px] leading-tight text-center px-0.5 truncate max-w-full h-3 mt-0.5",
isBound && shortcuts.length > 0
? (colors ? colors.text : "text-muted-foreground")
: "opacity-0"
)}
>
{isBound && shortcuts.length > 0
? (shortcuts.length === 1
? SHORTCUT_LABELS[shortcuts[0]].split(" ")[0]
: `${shortcuts.length}x`)
: "\u00A0" // Non-breaking space to maintain height
}
</span>
{isModified && (
<span className="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-yellow-500" />
)}
</button>
);
// Wrap in tooltip if bound
if (isBound) {
return (
<Tooltip key={keyDef.key}>
<TooltipTrigger asChild>{keyElement}</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<div className="space-y-1">
{shortcuts.map((shortcut) => {
const shortcutStr = keyboardShortcuts[shortcut];
const displayShortcut = formatShortcut(shortcutStr, true);
return (
<div key={shortcut} className="flex items-center gap-2">
<span
className={cn(
"w-2 h-2 rounded-full",
CATEGORY_COLORS[SHORTCUT_CATEGORIES[shortcut]].bg.replace("/20", "")
)}
/>
<span className="text-sm">{SHORTCUT_LABELS[shortcut]}</span>
<kbd className="text-xs font-mono bg-sidebar-accent/30 px-1 rounded">
{displayShortcut}
</kbd>
{keyboardShortcuts[shortcut] !== DEFAULT_KEYBOARD_SHORTCUTS[shortcut] && (
<span className="text-xs text-yellow-400">(custom)</span>
)}
</div>
);
})}
</div>
</TooltipContent>
</Tooltip>
);
}
return keyElement;
};
return (
<TooltipProvider>
<div className={cn("space-y-4", className)} data-testid="keyboard-map">
{/* Legend */}
<div className="flex flex-wrap gap-4 justify-center text-xs">
{Object.entries(CATEGORY_COLORS).map(([key, colors]) => (
<div key={key} className="flex items-center gap-2">
<div
className={cn(
"w-4 h-4 rounded border",
colors.bg,
colors.border
)}
/>
<span className={colors.text}>{colors.label}</span>
</div>
))}
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-sidebar-accent/10 border border-sidebar-border" />
<span className="text-muted-foreground">Available</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-yellow-500" />
<span className="text-yellow-400">Modified</span>
</div>
</div>
{/* Keyboard layout */}
<div className="flex flex-col items-center gap-1.5 p-4 rounded-xl bg-sidebar-accent/5 border border-sidebar-border">
{KEYBOARD_ROWS.map((row, rowIndex) => (
<div key={rowIndex} className="flex gap-1.5 justify-center">
{row.map(renderKey)}
</div>
))}
</div>
{/* Stats */}
<div className="flex justify-center gap-6 text-xs text-muted-foreground">
<span>
<strong className="text-foreground">{Object.keys(keyboardShortcuts).length}</strong> shortcuts
configured
</span>
<span>
<strong className="text-foreground">
{Object.keys(keyToShortcuts).length}
</strong>{" "}
keys in use
</span>
<span>
<strong className="text-foreground">
{KEYBOARD_ROWS.flat().length - Object.keys(keyToShortcuts).length}
</strong>{" "}
keys available
</span>
</div>
</div>
</TooltipProvider>
);
}
// 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<keyof KeyboardShortcuts | null>(null);
const [keyValue, setKeyValue] = React.useState("");
const [modifiers, setModifiers] = React.useState({ shift: false, cmdCtrl: false, alt: false });
const [shortcutError, setShortcutError] = React.useState<string | null>(null);
const groupedShortcuts = React.useMemo(() => {
const groups: Record<string, Array<{ key: keyof KeyboardShortcuts; label: string; value: string }>> = {
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 (
<TooltipProvider>
<div className="space-y-4" data-testid="shortcut-reference-panel">
{editable && (
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => resetKeyboardShortcuts()}
className="gap-2 text-xs"
data-testid="reset-all-shortcuts-button"
>
<RotateCcw className="w-3 h-3" />
Reset All to Defaults
</Button>
</div>
)}
{Object.entries(groupedShortcuts).map(([category, shortcuts]) => {
const colors = CATEGORY_COLORS[category as keyof typeof CATEGORY_COLORS];
return (
<div key={category} className="space-y-2">
<h4 className={cn("text-sm font-semibold", colors.text)}>
{colors.label}
</h4>
<div className="grid grid-cols-2 gap-2">
{shortcuts.map(({ key, label, value }) => {
const isModified = keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key];
const isEditing = editingShortcut === key;
return (
<div
key={key}
className={cn(
"flex items-center justify-between p-2 rounded-lg bg-sidebar-accent/10 border transition-colors",
isEditing ? "border-brand-500" : "border-sidebar-border",
editable && !isEditing && "hover:bg-sidebar-accent/20 cursor-pointer"
)}
onClick={() => editable && !isEditing && handleStartEdit(key)}
data-testid={`shortcut-row-${key}`}
>
<span className="text-sm text-foreground">{label}</span>
<div className="flex items-center gap-2">
{isEditing ? (
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
{/* Modifier checkboxes */}
<div className="flex items-center gap-1.5 text-xs">
<div className="flex items-center gap-1">
<Checkbox
id={`mod-cmd-${key}`}
checked={modifiers.cmdCtrl}
onCheckedChange={(checked) => handleModifierChange("cmdCtrl", !!checked, key)}
className="h-3.5 w-3.5"
/>
<Label htmlFor={`mod-cmd-${key}`} className="text-xs text-muted-foreground cursor-pointer">
{isMac ? "⌘" : "Ctrl"}
</Label>
</div>
<div className="flex items-center gap-1">
<Checkbox
id={`mod-alt-${key}`}
checked={modifiers.alt}
onCheckedChange={(checked) => handleModifierChange("alt", !!checked, key)}
className="h-3.5 w-3.5"
/>
<Label htmlFor={`mod-alt-${key}`} className="text-xs text-muted-foreground cursor-pointer">
{isMac ? "⌥" : "Alt"}
</Label>
</div>
<div className="flex items-center gap-1">
<Checkbox
id={`mod-shift-${key}`}
checked={modifiers.shift}
onCheckedChange={(checked) => handleModifierChange("shift", !!checked, key)}
className="h-3.5 w-3.5"
/>
<Label htmlFor={`mod-shift-${key}`} className="text-xs text-muted-foreground cursor-pointer">
</Label>
</div>
</div>
<span className="text-muted-foreground">+</span>
<Input
value={keyValue}
onChange={(e) => 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}`}
/>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 hover:bg-green-500/20 hover:text-green-400"
onClick={(e) => {
e.stopPropagation();
handleSaveShortcut();
}}
disabled={!!shortcutError || !keyValue}
data-testid={`save-shortcut-${key}`}
>
<CheckCircle2 className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 hover:bg-red-500/20 hover:text-red-400"
onClick={(e) => {
e.stopPropagation();
handleCancelEdit();
}}
data-testid={`cancel-shortcut-${key}`}
>
<X className="w-4 h-4" />
</Button>
</div>
) : (
<>
<kbd
className={cn(
"px-2 py-1 text-xs font-mono rounded border",
colors.bg,
colors.border,
colors.text
)}
>
{formatShortcut(value, true)}
</kbd>
{isModified && editable && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 hover:bg-yellow-500/20 hover:text-yellow-400"
onClick={(e) => {
e.stopPropagation();
handleResetShortcut(key);
}}
data-testid={`reset-shortcut-${key}`}
>
<RotateCcw className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Reset to default ({DEFAULT_KEYBOARD_SHORTCUTS[key]})
</TooltipContent>
</Tooltip>
)}
{isModified && !editable && (
<span className="w-2 h-2 rounded-full bg-yellow-500" />
)}
{editable && !isModified && (
<Edit2 className="w-3 h-3 text-muted-foreground opacity-0 group-hover:opacity-100" />
)}
</>
)}
</div>
</div>
);
})}
</div>
{editingShortcut && shortcutError && SHORTCUT_CATEGORIES[editingShortcut] === category && (
<p className="text-xs text-red-400 mt-1">{shortcutError}</p>
)}
</div>
);
})}
</div>
</TooltipProvider>
);
}

View File

@@ -1,8 +1,7 @@
"use client"; "use client";
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { useAppStore, DEFAULT_KEYBOARD_SHORTCUTS } from "@/store/app-store"; import { useAppStore } from "@/store/app-store";
import type { KeyboardShortcuts } from "@/store/app-store";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -41,9 +40,9 @@ import {
Settings2, Settings2,
RefreshCw, RefreshCw,
Info, Info,
Keyboard,
} from "lucide-react"; } from "lucide-react";
import { getElectronAPI } from "@/lib/electron"; import { getElectronAPI } from "@/lib/electron";
import { Checkbox } from "@/components/ui/checkbox";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -53,6 +52,8 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { useSetupStore } from "@/store/setup-store"; 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 // Navigation items for the side panel
const NAV_ITEMS = [ const NAV_ITEMS = [
@@ -84,9 +85,6 @@ export function SettingsView() {
setShowProfilesOnly, setShowProfilesOnly,
currentProject, currentProject,
moveProjectToTrash, moveProjectToTrash,
keyboardShortcuts,
setKeyboardShortcut,
resetKeyboardShortcuts,
} = useAppStore(); } = useAppStore();
// Compute the effective theme for the current project // Compute the effective theme for the current project
@@ -108,6 +106,7 @@ export function SettingsView() {
const [showOpenaiKey, setShowOpenaiKey] = useState(false); const [showOpenaiKey, setShowOpenaiKey] = useState(false);
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
const [testingConnection, setTestingConnection] = useState(false); const [testingConnection, setTestingConnection] = useState(false);
const [testResult, setTestResult] = useState<{ const [testResult, setTestResult] = useState<{
success: boolean; success: boolean;
message: string; message: string;
@@ -155,6 +154,7 @@ export function SettingsView() {
} | null>(null); } | null>(null);
const [activeSection, setActiveSection] = useState("api-keys"); const [activeSection, setActiveSection] = useState("api-keys");
const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
const [isCheckingClaudeCli, setIsCheckingClaudeCli] = useState(false); const [isCheckingClaudeCli, setIsCheckingClaudeCli] = useState(false);
const [isCheckingCodexCli, setIsCheckingCodexCli] = useState(false); const [isCheckingCodexCli, setIsCheckingCodexCli] = useState(false);
const [apiKeyStatus, setApiKeyStatus] = useState<{ const [apiKeyStatus, setApiKeyStatus] = useState<{
@@ -1608,376 +1608,35 @@ export function SettingsView() {
</h2> </h2>
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Customize keyboard shortcuts for navigation and actions. Click Customize keyboard shortcuts for navigation and actions using the visual keyboard map.
on any shortcut to edit it.
</p> </p>
</div> </div>
<div className="p-6 space-y-6"> <div className="p-6">
{/* Navigation Shortcuts */} {/* Centered message directing to keyboard map */}
<div className="space-y-3"> <div className="flex flex-col items-center justify-center py-16 text-center space-y-4">
<div className="flex items-center justify-between"> <div className="relative">
<h3 className="text-sm font-semibold text-foreground"> <Keyboard className="w-16 h-16 text-brand-500/30" />
Navigation <div className="absolute inset-0 bg-brand-500/10 blur-xl rounded-full" />
</div>
<div className="space-y-2 max-w-md">
<h3 className="text-lg font-semibold text-foreground">
Use the Visual Keyboard Map
</h3> </h3>
<Button <p className="text-sm text-muted-foreground">
variant="ghost" Click the &quot;View Keyboard Map&quot; button above to customize your keyboard shortcuts.
size="sm" The visual interface shows all available keys and lets you easily edit shortcuts with
onClick={() => resetKeyboardShortcuts()} single-modifier restrictions.
className="text-xs h-7"
data-testid="reset-shortcuts-button"
>
<RotateCcw className="w-3 h-3 mr-1" />
Reset All to Defaults
</Button>
</div>
<div className="space-y-2">
{[
{ 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 }) => (
<div
key={key}
className="flex items-center justify-between p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border hover:bg-sidebar-accent/20 transition-colors"
>
<span className="text-sm text-foreground">{label}</span>
<div className="flex items-center gap-2">
{editingShortcut === key ? (
<>
<Input
value={shortcutValue}
onChange={(e) => {
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}`}
/>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0"
onClick={() => {
if (!shortcutError && shortcutValue) {
setKeyboardShortcut(key, shortcutValue);
setEditingShortcut(null);
setShortcutValue("");
setShortcutError(null);
}
}}
disabled={!!shortcutError || !shortcutValue}
data-testid={`save-shortcut-${key}`}
>
<CheckCircle2 className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0"
onClick={() => {
setEditingShortcut(null);
setShortcutValue("");
setShortcutError(null);
}}
data-testid={`cancel-shortcut-${key}`}
>
<AlertCircle className="w-4 h-4" />
</Button>
</>
) : (
<>
<button
onClick={() => {
setEditingShortcut(key);
setShortcutValue(keyboardShortcuts[key]);
setShortcutError(null);
}}
className={cn(
"px-3 py-1.5 text-sm font-mono rounded bg-sidebar-accent/20 border border-sidebar-border hover:bg-sidebar-accent/30 transition-colors",
keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] &&
"border-brand-500/50 bg-brand-500/10 text-brand-400"
)}
data-testid={`shortcut-${key}`}
>
{keyboardShortcuts[key]}
</button>
{keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] && (
<span className="text-xs text-brand-400">(modified)</span>
)}
</>
)}
</div>
</div>
))}
</div>
{shortcutError && (
<p className="text-xs text-red-400">{shortcutError}</p>
)}
</div>
{/* UI Shortcuts */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-foreground">
UI Controls
</h3>
<div className="space-y-2">
{[
{ key: "toggleSidebar" as keyof KeyboardShortcuts, label: "Toggle Sidebar" },
].map(({ key, label }) => (
<div
key={key}
className="flex items-center justify-between p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border hover:bg-sidebar-accent/20 transition-colors"
>
<span className="text-sm text-foreground">{label}</span>
<div className="flex items-center gap-2">
{editingShortcut === key ? (
<>
<Input
value={shortcutValue}
onChange={(e) => {
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}`}
/>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0"
onClick={() => {
if (!shortcutError && shortcutValue) {
setKeyboardShortcut(key, shortcutValue);
setEditingShortcut(null);
setShortcutValue("");
setShortcutError(null);
}
}}
disabled={!!shortcutError || !shortcutValue}
>
<CheckCircle2 className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0"
onClick={() => {
setEditingShortcut(null);
setShortcutValue("");
setShortcutError(null);
}}
>
<AlertCircle className="w-4 h-4" />
</Button>
</>
) : (
<>
<button
onClick={() => {
setEditingShortcut(key);
setShortcutValue(keyboardShortcuts[key]);
setShortcutError(null);
}}
className={cn(
"px-3 py-1.5 text-sm font-mono rounded bg-sidebar-accent/20 border border-sidebar-border hover:bg-sidebar-accent/30 transition-colors",
keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] &&
"border-brand-500/50 bg-brand-500/10 text-brand-400"
)}
data-testid={`shortcut-${key}`}
>
{keyboardShortcuts[key]}
</button>
{keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] && (
<span className="text-xs text-brand-400">(modified)</span>
)}
</>
)}
</div>
</div>
))}
</div>
</div>
{/* Action Shortcuts */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-foreground">
Actions
</h3>
<div className="space-y-2">
{[
{ 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 }) => (
<div
key={key}
className="flex items-center justify-between p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border hover:bg-sidebar-accent/20 transition-colors"
>
<span className="text-sm text-foreground">{label}</span>
<div className="flex items-center gap-2">
{editingShortcut === key ? (
<>
<Input
value={shortcutValue}
onChange={(e) => {
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}`}
/>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0"
onClick={() => {
if (!shortcutError && shortcutValue) {
setKeyboardShortcut(key, shortcutValue);
setEditingShortcut(null);
setShortcutValue("");
setShortcutError(null);
}
}}
disabled={!!shortcutError || !shortcutValue}
>
<CheckCircle2 className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0"
onClick={() => {
setEditingShortcut(null);
setShortcutValue("");
setShortcutError(null);
}}
>
<AlertCircle className="w-4 h-4" />
</Button>
</>
) : (
<>
<button
onClick={() => {
setEditingShortcut(key);
setShortcutValue(keyboardShortcuts[key]);
setShortcutError(null);
}}
className={cn(
"px-3 py-1.5 text-sm font-mono rounded bg-sidebar-accent/20 border border-sidebar-border hover:bg-sidebar-accent/30 transition-colors",
keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] &&
"border-brand-500/50 bg-brand-500/10 text-brand-400"
)}
data-testid={`shortcut-${key}`}
>
{keyboardShortcuts[key]}
</button>
{keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] && (
<span className="text-xs text-brand-400">(modified)</span>
)}
</>
)}
</div>
</div>
))}
</div>
</div>
{/* Information */}
<div className="flex items-start gap-3 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
<AlertCircle className="w-5 h-5 text-blue-500 mt-0.5 shrink-0" />
<div className="text-sm">
<p className="font-medium text-blue-400">
About Keyboard Shortcuts
</p>
<p className="text-blue-400/80 text-xs mt-1">
Shortcuts won&apos;t trigger when typing in input fields. Use
single keys (A-Z, 0-9) or special keys like ` (backtick).
Changes take effect immediately.
</p> </p>
</div> </div>
<Button
variant="default"
size="lg"
onClick={() => setShowKeyboardMapDialog(true)}
className="gap-2 mt-4"
>
<Keyboard className="w-5 h-5" />
Open Keyboard Map
</Button>
</div> </div>
</div> </div>
</div> </div>
@@ -2169,6 +1828,44 @@ export function SettingsView() {
</div> </div>
</div> </div>
{/* Keyboard Map Dialog */}
<Dialog open={showKeyboardMapDialog} onOpenChange={setShowKeyboardMapDialog}>
<DialogContent className="bg-popover border-border max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Keyboard className="w-5 h-5 text-brand-500" />
Keyboard Shortcut Map
</DialogTitle>
<DialogDescription className="text-muted-foreground">
Visual overview of all keyboard shortcuts. Keys in color are bound to shortcuts.
Click on any shortcut below to edit it.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-6 py-4">
{/* Visual Keyboard Map */}
<KeyboardMap />
{/* Shortcut Reference - Editable */}
<div className="border-t border-border pt-4">
<h3 className="text-sm font-semibold text-foreground mb-4">
All Shortcuts Reference (Click to Edit)
</h3>
<ShortcutReferencePanel editable />
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowKeyboardMapDialog(false)}
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Project Confirmation Dialog */} {/* Delete Project Confirmation Dialog */}
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}> <Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogContent className="bg-popover border-border max-w-md"> <DialogContent className="bg-popover border-border max-w-md">

View File

@@ -1,10 +1,10 @@
"use client"; "use client";
import { useEffect, useCallback } from "react"; import { useEffect, useCallback } from "react";
import { useAppStore } from "@/store/app-store"; import { useAppStore, parseShortcut } from "@/store/app-store";
export interface KeyboardShortcut { export interface KeyboardShortcut {
key: string; key: string; // Can be simple "K" or with modifiers "Shift+N", "Cmd+K"
action: () => void; action: () => void;
description?: string; description?: string;
} }
@@ -59,9 +59,44 @@ function isInputFocused(): boolean {
return false; 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 * Hook to manage keyboard shortcuts
* Shortcuts won't fire when user is typing in inputs, textareas, or when dialogs are open * 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[]) { export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
@@ -71,14 +106,9 @@ export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
return; 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 // Find matching shortcut
const matchingShortcut = shortcuts.find( const matchingShortcut = shortcuts.find(
(shortcut) => shortcut.key.toLowerCase() === event.key.toLowerCase() (shortcut) => matchesShortcut(event, shortcut.key)
); );
if (matchingShortcut) { if (matchingShortcut) {

View File

@@ -37,7 +37,68 @@ export interface ApiKeys {
openai: string; 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 { export interface KeyboardShortcuts {
// Navigation shortcuts // Navigation shortcuts
board: string; board: string;
@@ -47,10 +108,10 @@ export interface KeyboardShortcuts {
tools: string; tools: string;
settings: string; settings: string;
profiles: string; profiles: string;
// UI shortcuts // UI shortcuts
toggleSidebar: string; toggleSidebar: string;
// Action shortcuts // Action shortcuts
addFeature: string; addFeature: string;
addContextFile: string; addContextFile: string;