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 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.",
};
}
}

View File

@@ -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)}
</span>
</div>
</button>
@@ -779,8 +779,8 @@ export function Sidebar() {
data-testid="open-project-button"
>
<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">
{shortcuts.openProject}
<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">
{formatShortcut(shortcuts.openProject, true)}
</span>
</button>
<button
@@ -819,10 +819,10 @@ export function Sidebar() {
</div>
<div className="flex items-center gap-1">
<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"
>
{shortcuts.projectPicker}
{formatShortcut(shortcuts.projectPicker, true)}
</span>
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
</div>
@@ -958,14 +958,14 @@ export function Sidebar() {
<Undo2 className="w-4 h-4 mr-2" />
<span className="flex-1">Previous</span>
<span className="text-[10px] font-mono text-muted-foreground ml-2">
{shortcuts.cyclePrevProject}
{formatShortcut(shortcuts.cyclePrevProject, true)}
</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={cycleNextProject} data-testid="cycle-next-project">
<Redo2 className="w-4 h-4 mr-2" />
<span className="flex-1">Next</span>
<span className="text-[10px] font-mono text-muted-foreground ml-2">
{shortcuts.cycleNextProject}
{formatShortcut(shortcuts.cycleNextProject, true)}
</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={clearProjectHistory} data-testid="clear-project-history">
@@ -1049,13 +1049,13 @@ export function Sidebar() {
{item.shortcut && sidebarOpen && (
<span
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 &&
"bg-brand-500/20 border-brand-500/50 text-brand-400"
)}
data-testid={`shortcut-${item.id}`}
>
{item.shortcut}
{formatShortcut(item.shortcut, true)}
</span>
)}
{/* Tooltip for collapsed state */}
@@ -1115,13 +1115,13 @@ export function Sidebar() {
{sidebarOpen && (
<span
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") &&
"bg-brand-500/20 border-brand-500/50 text-brand-400"
)}
data-testid="shortcut-settings"
>
{shortcuts.settings}
{formatShortcut(shortcuts.settings, true)}
</span>
)}
{!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";
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() {
</h2>
</div>
<p className="text-sm text-muted-foreground">
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.
</p>
</div>
<div className="p-6 space-y-6">
{/* Navigation Shortcuts */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">
Navigation
<div className="p-6">
{/* Centered message directing to keyboard map */}
<div className="flex flex-col items-center justify-center py-16 text-center space-y-4">
<div className="relative">
<Keyboard className="w-16 h-16 text-brand-500/30" />
<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>
<Button
variant="ghost"
size="sm"
onClick={() => resetKeyboardShortcuts()}
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 className="text-sm text-muted-foreground">
Click the &quot;View Keyboard Map&quot; button above to customize your keyboard shortcuts.
The visual interface shows all available keys and lets you easily edit shortcuts with
single-modifier restrictions.
</p>
</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>
@@ -2169,6 +1828,44 @@ export function SettingsView() {
</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 */}
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogContent className="bg-popover border-border max-w-md">

View File

@@ -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) {

View File

@@ -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;