"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 = { board: "Kanban Board", agent: "Agent Runner", spec: "Spec Editor", context: "Context", settings: "Settings", profiles: "AI Profiles", terminal: "Terminal", 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", splitTerminalRight: "Split Right", splitTerminalDown: "Split Down", closeTerminal: "Close Terminal", }; // Categorize shortcuts for color coding const SHORTCUT_CATEGORIES: Record = { board: "navigation", agent: "navigation", spec: "navigation", context: "navigation", settings: "navigation", profiles: "navigation", terminal: "navigation", toggleSidebar: "ui", addFeature: "action", addContextFile: "action", startNext: "action", newSession: "action", openProject: "action", projectPicker: "action", cyclePrevProject: "action", cycleNextProject: "action", addProfile: "action", splitTerminalRight: "action", splitTerminalDown: "action", closeTerminal: "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(); // Merge with defaults to ensure new shortcuts are always shown const mergedShortcuts = React.useMemo(() => ({ ...DEFAULT_KEYBOARD_SHORTCUTS, ...keyboardShortcuts, }), [keyboardShortcuts]); // Create a reverse map: base key -> list of shortcut names (including info about modifiers) const keyToShortcuts = React.useMemo(() => { const map: Record> = {}; (Object.entries(mergedShortcuts) as [keyof KeyboardShortcuts, string][]).forEach( ([shortcutName, shortcutStr]) => { if (!shortcutStr) return; // Skip undefined shortcuts 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; }, [mergedShortcuts]); 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) => mergedShortcuts[s] !== DEFAULT_KEYBOARD_SHORTCUTS[s] ); // 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 = ( ); // Wrap in tooltip if bound if (isBound) { return ( {keyElement}
{shortcuts.map((shortcut) => { const shortcutStr = mergedShortcuts[shortcut]; const displayShortcut = formatShortcut(shortcutStr, true); return (
{SHORTCUT_LABELS[shortcut] ?? shortcut} {displayShortcut} {mergedShortcuts[shortcut] !== DEFAULT_KEYBOARD_SHORTCUTS[shortcut] && ( (custom) )}
); })}
); } return keyElement; }; return (
{/* Legend */}
{Object.entries(CATEGORY_COLORS).map(([key, colors]) => (
{colors.label}
))}
Available
Modified
{/* Keyboard layout */}
{KEYBOARD_ROWS.map((row, rowIndex) => (
{row.map(renderKey)}
))}
{/* Stats */}
{Object.keys(keyboardShortcuts).length} shortcuts configured {Object.keys(keyToShortcuts).length} {" "} keys in use {KEYBOARD_ROWS.flat().length - Object.keys(keyToShortcuts).length} {" "} keys available
); } // Full shortcut reference panel with editing capability interface ShortcutReferencePanelProps { editable?: boolean; } export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePanelProps) { const { keyboardShortcuts, setKeyboardShortcut, resetKeyboardShortcuts } = useAppStore(); const [editingShortcut, setEditingShortcut] = React.useState(null); const [keyValue, setKeyValue] = React.useState(""); const [modifiers, setModifiers] = React.useState({ shift: false, cmdCtrl: false, alt: false }); const [shortcutError, setShortcutError] = React.useState(null); // Merge with defaults to ensure new shortcuts are always shown const mergedShortcuts = React.useMemo(() => ({ ...DEFAULT_KEYBOARD_SHORTCUTS, ...keyboardShortcuts, }), [keyboardShortcuts]); const groupedShortcuts = React.useMemo(() => { const groups: Record> = { navigation: [], ui: [], action: [], }; (Object.entries(SHORTCUT_CATEGORIES) as [keyof KeyboardShortcuts, string][]).forEach( ([shortcut, category]) => { groups[category].push({ key: shortcut, label: SHORTCUT_LABELS[shortcut] ?? shortcut, value: mergedShortcuts[shortcut], }); } ); return groups; }, [mergedShortcuts]); // 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(mergedShortcuts).find( ([k, v]) => k !== currentKey && v?.toUpperCase() === shortcutStr.toUpperCase() ); return conflict ? (SHORTCUT_LABELS[conflict[0] as keyof KeyboardShortcuts] ?? conflict[0]) : null; }, [mergedShortcuts]); const handleStartEdit = (key: keyof KeyboardShortcuts) => { const currentValue = mergedShortcuts[key]; const parsed = parseShortcut(currentValue); setEditingShortcut(key); setKeyValue(parsed.key); setModifiers({ shift: parsed.shift || false, cmdCtrl: parsed.cmdCtrl || false, alt: parsed.alt || false, }); setShortcutError(null); }; const handleSaveShortcut = () => { if (!editingShortcut || shortcutError || !keyValue) return; const shortcutStr = buildShortcutString(keyValue, modifiers); setKeyboardShortcut(editingShortcut, shortcutStr); setEditingShortcut(null); setKeyValue(""); setModifiers({ shift: false, cmdCtrl: false, alt: false }); setShortcutError(null); }; const handleCancelEdit = () => { setEditingShortcut(null); setKeyValue(""); setModifiers({ shift: false, cmdCtrl: false, alt: false }); setShortcutError(null); }; const handleKeyChange = (value: string, currentKey: keyof KeyboardShortcuts) => { setKeyValue(value); // Check for conflicts with full shortcut string if (!value) { setShortcutError("Key cannot be empty"); } else { const shortcutStr = buildShortcutString(value, modifiers); const conflictLabel = checkConflict(shortcutStr, currentKey); if (conflictLabel) { setShortcutError(`Already used by "${conflictLabel}"`); } else { setShortcutError(null); } } }; const handleModifierChange = (modifier: keyof typeof modifiers, checked: boolean, currentKey: keyof KeyboardShortcuts) => { // Enforce single modifier: when checking, uncheck all others (radio-button behavior) const newModifiers = checked ? { shift: false, cmdCtrl: false, alt: false, [modifier]: true } : { ...modifiers, [modifier]: false }; setModifiers(newModifiers); // Recheck for conflicts if (keyValue) { const shortcutStr = buildShortcutString(keyValue, newModifiers); const conflictLabel = checkConflict(shortcutStr, currentKey); if (conflictLabel) { setShortcutError(`Already used by "${conflictLabel}"`); } else { setShortcutError(null); } } }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !shortcutError && keyValue) { handleSaveShortcut(); } else if (e.key === "Escape") { handleCancelEdit(); } }; const handleResetShortcut = (key: keyof KeyboardShortcuts) => { setKeyboardShortcut(key, DEFAULT_KEYBOARD_SHORTCUTS[key]); }; return (
{editable && (
)} {Object.entries(groupedShortcuts).map(([category, shortcuts]) => { const colors = CATEGORY_COLORS[category as keyof typeof CATEGORY_COLORS]; return (

{colors.label}

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

{shortcutError}

)}
); })}
); }