@@ -117,16 +141,29 @@ export default function PersonalizePage({ onBack, onComplete, onSkip }: Props) {
Keymap
- {KEYMAPS.map(k => setSelectedKeymap(k.id)}> )}
+ {KEYMAPS.map(k => (
+ setSelectedKeymap(k.id)}>
+
+
+ ))}
{/* Import */}
-
Import settings from
+
Import keybindings from
- {IMPORTS.map(imp => setSelectedImport(selectedImport === imp.id ? null : imp.id)}> )}
+ {IMPORTS.map(imp => (
+ setSelectedImport(selectedImport === imp.id ? null : imp.id)}>
+
+
+ ))}
+ {selectedImport && (
+
+ Your keybindings.json from {IMPORTS.find(i => i.id === selectedImport)?.label} will be imported. You can always adjust them later in Settings → Keyboard Shortcuts.
+
+ )}
{/* Footer */}
@@ -142,6 +179,7 @@ export default function PersonalizePage({ onBack, onComplete, onSkip }: Props) {
{ (e.currentTarget as HTMLElement).style.color = 'var(--origin-fg-default)'; (e.currentTarget as HTMLElement).style.borderColor = 'var(--origin-fg-muted)'; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.color = 'var(--origin-fg-muted)'; (e.currentTarget as HTMLElement).style.borderColor = 'var(--origin-border-default)'; }}
@@ -149,12 +187,17 @@ export default function PersonalizePage({ onBack, onComplete, onSkip }: Props) {
Back
{ (e.currentTarget as HTMLElement).style.opacity = '0.88'; }}
- onMouseLeave={e => { (e.currentTarget as HTMLElement).style.opacity = '1'; }}
+ onClick={handleEnterOrigin}
+ disabled={importing}
+ style={{ ...navBtn(), opacity: importing ? 0.7 : 1 }}
+ onMouseEnter={e => { if (!importing) (e.currentTarget as HTMLElement).style.opacity = '0.88'; }}
+ onMouseLeave={e => { (e.currentTarget as HTMLElement).style.opacity = importing ? '0.7' : '1'; }}
>
- Enter Origin
+ {importing ? (
+ <> Importing…>
+ ) : (
+ <>Enter Origin >
+ )}
diff --git a/src/components/settings/KeybindingsSection.tsx b/src/components/settings/KeybindingsSection.tsx
new file mode 100644
index 00000000..65e695af
--- /dev/null
+++ b/src/components/settings/KeybindingsSection.tsx
@@ -0,0 +1,347 @@
+import { useState, useEffect, useRef } from "react";
+import { RotateCcw, Search, Download, Check, AlertCircle } from "lucide-react";
+import {
+ COMMANDS,
+ loadKeybindings,
+ setKeybinding,
+ resetKeybindings,
+ getEffectiveKey,
+ detectInstalledEditors,
+ applyKeybindingsFromEditor,
+} from "../../lib/keybindings";
+import { useToast } from "../ui/Toast";
+
+// ── Key recorder ─────────────────────────────────────────────────────────────
+
+function formatEvent(e: KeyboardEvent): string {
+ const parts: string[] = [];
+ if (e.ctrlKey || e.metaKey) parts.push("ctrl");
+ if (e.shiftKey) parts.push("shift");
+ if (e.altKey) parts.push("alt");
+ const k = e.key === " " ? "space" : e.key.toLowerCase();
+ // Ignore bare modifiers
+ if (["control", "shift", "alt", "meta", "os"].includes(k)) return "";
+ parts.push(k);
+ return parts.join("+");
+}
+
+// ── Import row ────────────────────────────────────────────────────────────────
+
+interface ImportEditorRowProps {
+ detected: string[];
+ onImported: () => void;
+}
+
+const EDITOR_LABELS: Record
= {
+ vscode: "VS Code",
+ cursor: "Cursor",
+ windsurf: "Windsurf",
+};
+
+function ImportEditorRow({ detected, onImported }: ImportEditorRowProps) {
+ const { showToast } = useToast();
+ const [importing, setImporting] = useState(null);
+ const [done, setDone] = useState(null);
+
+ async function doImport(id: string) {
+ setImporting(id);
+ try {
+ const count = await applyKeybindingsFromEditor(id);
+ setDone(id);
+ setTimeout(() => setDone(null), 2000);
+ showToast(`Imported ${count} keybinding${count !== 1 ? "s" : ""} from ${EDITOR_LABELS[id]}`, "success");
+ onImported();
+ } catch (err) {
+ showToast(`Could not import from ${EDITOR_LABELS[id]}: ${err}`, "error");
+ } finally {
+ setImporting(null);
+ }
+ }
+
+ const candidates = Object.keys(EDITOR_LABELS);
+
+ return (
+
+ {candidates.map(id => {
+ const available = detected.includes(id);
+ const isImporting = importing === id;
+ const isDone = done === id;
+ return (
+ available && doImport(id)}
+ disabled={!available || isImporting !== false}
+ title={available ? `Import keybindings from ${EDITOR_LABELS[id]}` : `${EDITOR_LABELS[id]} not detected`}
+ style={{
+ display: "flex", alignItems: "center", gap: "6px",
+ padding: "5px 12px",
+ borderRadius: "6px",
+ border: "1px solid var(--origin-border-default)",
+ background: isDone
+ ? "color-mix(in srgb, var(--origin-semantic-success) 12%, transparent)"
+ : "transparent",
+ color: isDone
+ ? "var(--origin-semantic-success)"
+ : available
+ ? "var(--origin-fg-default)"
+ : "var(--origin-fg-subtle)",
+ fontSize: "12px",
+ cursor: available ? "pointer" : "not-allowed",
+ opacity: available ? 1 : 0.45,
+ transition: "all 0.15s",
+ fontFamily: "inherit",
+ }}
+ >
+ {isDone ? : isImporting ? : }
+ {isImporting ? "Importing…" : EDITOR_LABELS[id]}
+
+ );
+ })}
+
+ );
+}
+
+// ── Conflict badge ────────────────────────────────────────────────────────────
+
+function conflictFor(commandId: string, key: string): string | null {
+ const user = loadKeybindings();
+ for (const cmd of COMMANDS) {
+ if (cmd.id === commandId) continue;
+ const effective = user.find(b => b.command === cmd.id)?.key ?? cmd.defaultKey;
+ if (effective.toLowerCase() === key.toLowerCase()) return cmd.label;
+ }
+ return null;
+}
+
+// ── Keybinding row ────────────────────────────────────────────────────────────
+
+interface RowProps {
+ commandId: string;
+ label: string;
+ category: string;
+ defaultKey: string;
+ onChange: () => void;
+}
+
+function KeybindingRow({ commandId, label, defaultKey, onChange }: RowProps) {
+ const effectiveKey = getEffectiveKey(commandId) ?? defaultKey;
+ const isCustom = loadKeybindings().some(b => b.command === commandId);
+ const [recording, setRecording] = useState(false);
+ const [conflict, setConflict] = useState(null);
+ const btnRef = useRef(null);
+
+ useEffect(() => {
+ if (!recording) return;
+ function onKey(e: KeyboardEvent) {
+ e.preventDefault();
+ e.stopPropagation();
+ if (e.key === "Escape") { setRecording(false); setConflict(null); return; }
+ const combo = formatEvent(e);
+ if (!combo) return;
+ const c = conflictFor(commandId, combo);
+ setConflict(c);
+ setKeybinding(commandId, combo);
+ setRecording(false);
+ onChange();
+ }
+ window.addEventListener("keydown", onKey, { capture: true });
+ return () => window.removeEventListener("keydown", onKey, { capture: true });
+ }, [recording, commandId, onChange]);
+
+ function resetThis() {
+ setKeybinding(commandId, null);
+ setConflict(null);
+ onChange();
+ }
+
+ return (
+
+
{label}
+
+ {conflict && (
+
+
+
+ )}
+
setRecording(r => !r)}
+ title={recording ? "Press a key combination (Escape to cancel)" : "Click to rebind"}
+ style={{
+ padding: "3px 10px",
+ borderRadius: "5px",
+ border: `1px solid ${recording ? "var(--origin-accent-blue)" : "var(--origin-border-default)"}`,
+ background: recording
+ ? "color-mix(in srgb, var(--origin-accent-blue) 10%, transparent)"
+ : "var(--origin-bg-base)",
+ color: recording ? "var(--origin-accent-blue)" : "var(--origin-fg-default)",
+ fontSize: "11px",
+ fontFamily: "var(--font-mono)",
+ cursor: "pointer",
+ minWidth: "80px",
+ textAlign: "center",
+ transition: "all 0.12s",
+ whiteSpace: "nowrap",
+ }}
+ >
+ {recording ? "Press key…" : effectiveKey}
+
+ {isCustom && (
+
{ (e.currentTarget as HTMLElement).style.color = "var(--origin-fg-muted)"; }}
+ onMouseLeave={e => { (e.currentTarget as HTMLElement).style.color = "var(--origin-fg-subtle)"; }}
+ >
+
+
+ )}
+
+
+ );
+}
+
+// ── Main section ─────────────────────────────────────────────────────────────
+
+export default function KeybindingsSection() {
+ const { showToast } = useToast();
+ const [query, setQuery] = useState("");
+ const [detected, setDetected] = useState([]);
+ const [tick, setTick] = useState(0); // bump to re-render after changes
+
+ useEffect(() => {
+ detectInstalledEditors().then(setDetected).catch(() => setDetected([]));
+ }, []);
+
+ function refresh() { setTick(t => t + 1); }
+
+ const userCount = loadKeybindings().length;
+
+ const filtered = COMMANDS.filter(cmd => {
+ if (!query) return true;
+ const q = query.toLowerCase();
+ return (
+ cmd.label.toLowerCase().includes(q) ||
+ cmd.category.toLowerCase().includes(q) ||
+ cmd.id.toLowerCase().includes(q) ||
+ (getEffectiveKey(cmd.id) ?? "").includes(q)
+ );
+ });
+
+ // Group by category
+ const categories = [...new Set(filtered.map(c => c.category))];
+
+ function handleResetAll() {
+ resetKeybindings();
+ refresh();
+ showToast("All keybindings reset to defaults", "info");
+ }
+
+ return (
+
+
+ {/* Import row */}
+
+
+ Import from
+
+ {detected.length === 0 ? (
+
+ No VS Code, Cursor, or Windsurf installation detected on this machine.
+
+ ) : (
+
+ )}
+
+
+ {/* Divider */}
+
+
+ {/* Search + reset */}
+
+
+
+ setQuery(e.target.value)}
+ placeholder="Search commands or keys…"
+ style={{
+ width: "100%", boxSizing: "border-box",
+ padding: "6px 10px 6px 28px",
+ borderRadius: "6px",
+ border: "1px solid var(--origin-border-default)",
+ background: "var(--origin-bg-base)",
+ color: "var(--origin-fg-default)",
+ fontSize: "12px",
+ fontFamily: "inherit",
+ outline: "none",
+ }}
+ />
+
+ {userCount > 0 && (
+
+ Reset all
+
+ )}
+
+
+ {/* Column headers */}
+
+ Command
+ Keybinding
+
+
+ {/* Command list grouped by category */}
+ {categories.length === 0 ? (
+
No commands match "{query}".
+ ) : (
+ categories.map(cat => (
+
+
+ {cat}
+
+ {filtered.filter(c => c.category === cat).map(cmd => (
+
+ ))}
+
+ ))
+ )}
+
+
+ {userCount > 0 ? `${userCount} custom binding${userCount !== 1 ? "s" : ""}` : "Using all defaults"}
+ {" · "}Click a keybinding to rebind · Esc to cancel
+
+
+ );
+}
diff --git a/src/components/settings/SettingsPanel.tsx b/src/components/settings/SettingsPanel.tsx
index 1b3261cd..57d25d6e 100644
--- a/src/components/settings/SettingsPanel.tsx
+++ b/src/components/settings/SettingsPanel.tsx
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
-import { X, Eye, EyeOff, Bot, Palette, Check, MessageSquareDot, SlidersHorizontal, Terminal, Copy } from "lucide-react";
+import { X, Eye, EyeOff, Bot, Palette, Check, MessageSquareDot, SlidersHorizontal, Terminal, Copy, Keyboard } from "lucide-react";
+import KeybindingsSection from "./KeybindingsSection";
import { PROVIDERS } from "../ai/providers";
import { loadApiKey, saveApiKey, deleteApiKey } from "../../lib/secrets";
import { useTheme } from "../../themes/ThemeContext";
@@ -11,7 +12,7 @@ import { DEFAULT_SYSTEM_PROMPT, DEFAULT_ASK_PROMPT, DEFAULT_PLAN_PROMPT } from "
const LOCAL_IDS = new Set(["ollama", "lmstudio", "vllm"]);
const API_PROVIDERS = PROVIDERS.filter(p => !LOCAL_IDS.has(p.id));
-type Section = "general" | "ai" | "prompts" | "appearance" | "terminal";
+type Section = "general" | "ai" | "prompts" | "appearance" | "terminal" | "keybindings";
// ── Nav ──────────────────────────────────────────────────────────────────────
@@ -879,6 +880,12 @@ export default function SettingsPanel({ onClose }: SettingsPanelProps) {
label="Terminal"
onClick={() => setSection("terminal")}
/>
+ }
+ label="Keyboard Shortcuts"
+ onClick={() => setSection("keybindings")}
+ />
{/* Content */}
@@ -888,13 +895,14 @@ export default function SettingsPanel({ onClose }: SettingsPanelProps) {
color: "var(--origin-fg-default)",
marginBottom: "14px",
}}>
- {section === "general" ? "General" : section === "ai" ? "AI Providers" : section === "prompts" ? "System Prompts" : section === "appearance" ? "Appearance" : "Terminal"}
+ {section === "general" ? "General" : section === "ai" ? "AI Providers" : section === "prompts" ? "System Prompts" : section === "appearance" ? "Appearance" : section === "keybindings" ? "Keyboard Shortcuts" : "Terminal"}
{section === "general" &&