Adam 1 месяц назад
Родитель
Сommit
df094a10ff

+ 7 - 3
packages/app/src/components/settings-general.tsx

@@ -37,10 +37,14 @@ export const SettingsGeneral: Component = () => {
 
   return (
     <div class="flex flex-col h-full overflow-y-auto no-scrollbar">
-      <div class="flex flex-col gap-8 p-8 max-w-[720px]">
-        {/* Header */}
-        <h2 class="text-16-medium text-text-strong">General</h2>
+      <div class="sticky top-0 z-10 bg-background-base border-b border-border-weak-base">
+        <div class="flex flex-col gap-1 p-8 max-w-[720px]">
+          <h2 class="text-16-medium text-text-strong">General</h2>
+          <p class="text-14-regular text-text-weak">Appearance, notifications, and sound preferences.</p>
+        </div>
+      </div>
 
+      <div class="flex flex-col gap-8 p-8 pt-6 max-w-[720px]">
         {/* Appearance Section */}
         <div class="flex flex-col gap-1">
           <h3 class="text-14-medium text-text-strong pb-2">Appearance</h3>

+ 304 - 5
packages/app/src/components/settings-keybinds.tsx

@@ -1,11 +1,310 @@
-import { Component } from "solid-js"
+import { Component, For, Show, createMemo, createSignal, onCleanup, onMount } from "solid-js"
+import { Button } from "@opencode-ai/ui/button"
+import { showToast } from "@opencode-ai/ui/toast"
+import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
+import { useSettings } from "@/context/settings"
+
+const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
+const PALETTE_ID = "command.palette"
+const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
+
+type KeybindGroup = "General" | "Session" | "Navigation" | "Model and agent" | "Terminal" | "Prompt"
+
+type KeybindMeta = {
+  title: string
+  group: KeybindGroup
+}
+
+const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"]
+
+function groupFor(id: string): KeybindGroup {
+  if (id === PALETTE_ID) return "General"
+  if (id.startsWith("terminal.")) return "Terminal"
+  if (id.startsWith("model.") || id.startsWith("agent.") || id.startsWith("mcp.")) return "Model and agent"
+  if (id.startsWith("file.")) return "Navigation"
+  if (id.startsWith("prompt.")) return "Prompt"
+  if (
+    id.startsWith("session.") ||
+    id.startsWith("message.") ||
+    id.startsWith("permissions.") ||
+    id.startsWith("steps.") ||
+    id.startsWith("review.")
+  )
+    return "Session"
+
+  return "General"
+}
+
+function isModifier(key: string) {
+  return key === "Shift" || key === "Control" || key === "Alt" || key === "Meta"
+}
+
+function normalizeKey(key: string) {
+  if (key === ",") return "comma"
+  if (key === "+") return "plus"
+  if (key === " ") return "space"
+  return key.toLowerCase()
+}
+
+function recordKeybind(event: KeyboardEvent) {
+  if (isModifier(event.key)) return
+
+  const parts: string[] = []
+
+  const mod = IS_MAC ? event.metaKey : event.ctrlKey
+  if (mod) parts.push("mod")
+
+  if (IS_MAC && event.ctrlKey) parts.push("ctrl")
+  if (!IS_MAC && event.metaKey) parts.push("meta")
+  if (event.altKey) parts.push("alt")
+  if (event.shiftKey) parts.push("shift")
+
+  const key = normalizeKey(event.key)
+  if (!key) return
+  parts.push(key)
+
+  return parts.join("+")
+}
+
+function signatures(config: string | undefined) {
+  if (!config) return []
+  const sigs: string[] = []
+
+  for (const kb of parseKeybind(config)) {
+    const parts: string[] = []
+    if (kb.ctrl) parts.push("ctrl")
+    if (kb.alt) parts.push("alt")
+    if (kb.shift) parts.push("shift")
+    if (kb.meta) parts.push("meta")
+    if (kb.key) parts.push(kb.key)
+    if (parts.length === 0) continue
+    sigs.push(parts.join("+"))
+  }
+
+  return sigs
+}
 
 export const SettingsKeybinds: Component = () => {
+  const command = useCommand()
+  const settings = useSettings()
+
+  const [active, setActive] = createSignal<string | null>(null)
+
+  const stop = () => {
+    if (!active()) return
+    setActive(null)
+    command.keybinds(true)
+  }
+
+  const start = (id: string) => {
+    if (active() === id) {
+      stop()
+      return
+    }
+
+    if (active()) stop()
+
+    setActive(id)
+    command.keybinds(false)
+  }
+
+  const hasOverrides = createMemo(() => {
+    const keybinds = settings.current.keybinds as Record<string, string | undefined> | undefined
+    if (!keybinds) return false
+    return Object.values(keybinds).some((x) => typeof x === "string")
+  })
+
+  const resetAll = () => {
+    stop()
+    settings.keybinds.resetAll()
+    showToast({ title: "Shortcuts reset", description: "Keyboard shortcuts have been reset to defaults." })
+  }
+
+  const list = createMemo(() => {
+    const out = new Map<string, KeybindMeta>()
+    out.set(PALETTE_ID, { title: "Command palette", group: "General" })
+
+    for (const opt of command.options) {
+      if (opt.id.startsWith("suggested.")) continue
+
+      out.set(opt.id, {
+        title: opt.title,
+        group: groupFor(opt.id),
+      })
+    }
+
+    return out
+  })
+
+  const title = (id: string) => list().get(id)?.title ?? ""
+
+  const grouped = createMemo(() => {
+    const map = list()
+    const out = new Map<KeybindGroup, string[]>()
+
+    for (const group of GROUPS) out.set(group, [])
+
+    for (const [id, item] of map) {
+      const ids = out.get(item.group)
+      if (!ids) continue
+      ids.push(id)
+    }
+
+    for (const group of GROUPS) {
+      const ids = out.get(group)
+      if (!ids) continue
+
+      ids.sort((a, b) => {
+        const at = map.get(a)?.title ?? ""
+        const bt = map.get(b)?.title ?? ""
+        return at.localeCompare(bt)
+      })
+    }
+
+    return out
+  })
+
+  const used = createMemo(() => {
+    const map = new Map<string, { id: string; title: string }[]>()
+
+    const add = (key: string, value: { id: string; title: string }) => {
+      const list = map.get(key)
+      if (!list) {
+        map.set(key, [value])
+        return
+      }
+      list.push(value)
+    }
+
+    const palette = settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND
+    for (const sig of signatures(palette)) {
+      add(sig, { id: PALETTE_ID, title: "Command palette" })
+    }
+
+    for (const opt of command.options) {
+      if (opt.id.startsWith("suggested.")) continue
+      if (!opt.keybind) continue
+      for (const sig of signatures(opt.keybind)) {
+        add(sig, { id: opt.id, title: opt.title })
+      }
+    }
+
+    return map
+  })
+
+  const setKeybind = (id: string, keybind: string) => {
+    settings.keybinds.set(id, keybind)
+  }
+
+  onMount(() => {
+    const handle = (event: KeyboardEvent) => {
+      const id = active()
+      if (!id) return
+
+      event.preventDefault()
+      event.stopPropagation()
+      event.stopImmediatePropagation()
+
+      if (event.key === "Escape") {
+        stop()
+        return
+      }
+
+      const clear =
+        (event.key === "Backspace" || event.key === "Delete") &&
+        !event.ctrlKey &&
+        !event.metaKey &&
+        !event.altKey &&
+        !event.shiftKey
+      if (clear) {
+        setKeybind(id, "none")
+        stop()
+        return
+      }
+
+      const next = recordKeybind(event)
+      if (!next) return
+
+      const map = used()
+      const conflicts = new Map<string, string>()
+
+      for (const sig of signatures(next)) {
+        const list = map.get(sig) ?? []
+        for (const item of list) {
+          if (item.id === id) continue
+          conflicts.set(item.id, item.title)
+        }
+      }
+
+      if (conflicts.size > 0) {
+        showToast({
+          title: "Shortcut already in use",
+          description: `${formatKeybind(next)} is already assigned to ${[...conflicts.values()].join(", ")}.`,
+        })
+        return
+      }
+
+      setKeybind(id, next)
+      stop()
+    }
+
+    document.addEventListener("keydown", handle, true)
+    onCleanup(() => {
+      document.removeEventListener("keydown", handle, true)
+    })
+  })
+
+  onCleanup(() => {
+    if (active()) command.keybinds(true)
+  })
+
   return (
-    <div class="flex flex-col h-full overflow-y-auto">
-      <div class="flex flex-col gap-6 p-6 max-w-[600px]">
-        <h2 class="text-16-medium text-text-strong">Shortcuts</h2>
-        <p class="text-14-regular text-text-weak">Keyboard shortcuts will be configurable here.</p>
+    <div class="flex flex-col h-full overflow-y-auto no-scrollbar">
+      <div class="sticky top-0 z-10 bg-background-base border-b border-border-weak-base">
+        <div class="flex items-start justify-between gap-4 p-8 max-w-[720px]">
+          <div class="flex flex-col gap-1">
+            <h2 class="text-16-medium text-text-strong">Keyboard shortcuts</h2>
+            <p class="text-14-regular text-text-weak">Click a shortcut to edit. Press Esc to cancel.</p>
+          </div>
+          <Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
+            Reset to defaults
+          </Button>
+        </div>
+      </div>
+
+      <div class="flex flex-col gap-6 p-8 pt-6 max-w-[720px]">
+        <For each={GROUPS}>
+          {(group) => (
+            <Show when={(grouped().get(group) ?? []).length > 0}>
+              <div class="flex flex-col gap-2">
+                <h3 class="text-14-medium text-text-strong">{group}</h3>
+                <div class="border border-border-weak-base rounded-lg overflow-hidden">
+                  <For each={grouped().get(group) ?? []}>
+                    {(id) => (
+                      <div class="flex items-center justify-between gap-4 px-4 py-3 border-b border-border-weak-base last:border-none">
+                        <span class="text-14-regular text-text-strong">{title(id)}</span>
+                        <button
+                          type="button"
+                          classList={{
+                            "h-8 px-3 rounded-md text-12-regular border border-border-base": true,
+                            "bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active":
+                              active() !== id,
+                            "bg-surface-raised-stronger-non-alpha text-text-strong": active() === id,
+                          }}
+                          onClick={() => start(id)}
+                        >
+                          <Show when={active() === id} fallback={command.keybind(id) || "Unassigned"}>
+                            Press keys
+                          </Show>
+                        </button>
+                      </div>
+                    )}
+                  </For>
+                </div>
+              </div>
+            </Show>
+          )}
+        </For>
       </div>
     </div>
   )

+ 46 - 9
packages/app/src/context/command.tsx

@@ -1,9 +1,26 @@
 import { createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { useSettings } from "@/context/settings"
 
 const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
 
+const PALETTE_ID = "command.palette"
+const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
+const SUGGESTED_PREFIX = "suggested."
+
+function actionId(id: string) {
+  if (!id.startsWith(SUGGESTED_PREFIX)) return id
+  return id.slice(SUGGESTED_PREFIX.length)
+}
+
+function normalizeKey(key: string) {
+  if (key === ",") return "comma"
+  if (key === "+") return "plus"
+  if (key === " ") return "space"
+  return key.toLowerCase()
+}
+
 export type KeybindConfig = string
 
 export interface Keybind {
@@ -73,7 +90,7 @@ export function parseKeybind(config: string): Keybind[] {
 }
 
 export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean {
-  const eventKey = event.key.toLowerCase()
+  const eventKey = normalizeKey(event.key)
 
   for (const kb of keybinds) {
     const keyMatch = kb.key === eventKey
@@ -105,15 +122,18 @@ export function formatKeybind(config: string): string {
   if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta")
 
   if (kb.key) {
-    const arrows: Record<string, string> = {
+    const keys: Record<string, string> = {
       arrowup: "↑",
       arrowdown: "↓",
       arrowleft: "←",
       arrowright: "→",
+      comma: ",",
+      plus: "+",
+      space: "Space",
     }
+    const key = kb.key.toLowerCase()
     const displayKey =
-      arrows[kb.key.toLowerCase()] ??
-      (kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1))
+      keys[key] ?? (key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1))
     parts.push(displayKey)
   }
 
@@ -124,9 +144,17 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
   name: "Command",
   init: () => {
     const dialog = useDialog()
+    const settings = useSettings()
     const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
     const [suspendCount, setSuspendCount] = createSignal(0)
 
+    const bind = (id: string, def: KeybindConfig | undefined) => {
+      const custom = settings.keybinds.get(actionId(id))
+      const config = custom ?? def
+      if (!config || config === "none") return
+      return config
+    }
+
     const options = createMemo(() => {
       const seen = new Set<string>()
       const all: CommandOption[] = []
@@ -139,15 +167,20 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
         }
       }
 
-      const suggested = all.filter((x) => x.suggested && !x.disabled)
+      const resolved = all.map((opt) => ({
+        ...opt,
+        keybind: bind(opt.id, opt.keybind),
+      }))
+
+      const suggested = resolved.filter((x) => x.suggested && !x.disabled)
 
       return [
         ...suggested.map((x) => ({
           ...x,
-          id: "suggested." + x.id,
+          id: SUGGESTED_PREFIX + x.id,
           category: "Suggested",
         })),
-        ...all,
+        ...resolved,
       ]
     })
 
@@ -169,7 +202,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
     const handleKeyDown = (event: KeyboardEvent) => {
       if (suspended() || dialog.active) return
 
-      const paletteKeybinds = parseKeybind("mod+shift+p")
+      const paletteKeybinds = parseKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND)
       if (matchKeybind(paletteKeybinds, event)) {
         event.preventDefault()
         showPalette()
@@ -209,7 +242,11 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
         run(id, source)
       },
       keybind(id: string) {
-        const option = options().find((x) => x.id === id || x.id === "suggested." + id)
+        if (id === PALETTE_ID) {
+          return formatKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND)
+        }
+
+        const option = options().find((x) => x.id === id || x.id === SUGGESTED_PREFIX + id)
         if (!option?.keybind) return ""
         return formatKeybind(option.keybind)
       },

+ 4 - 1
packages/app/src/context/settings.tsx

@@ -1,4 +1,4 @@
-import { createStore } from "solid-js/store"
+import { createStore, reconcile } from "solid-js/store"
 import { createEffect, createMemo } from "solid-js"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { persisted } from "@/utils/persist"
@@ -115,6 +115,9 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
         reset(action: string) {
           setStore("keybinds", action, undefined!)
         },
+        resetAll() {
+          setStore("keybinds", reconcile({}))
+        },
       },
       permissions: {
         autoApprove: createMemo(() => store.permissions?.autoApprove ?? defaultSettings.permissions.autoApprove),