Appearance
diff --git a/packages/app/src/components/settings-keybinds.tsx b/packages/app/src/components/settings-keybinds.tsx
index 3688559bcb..811b34f9b2 100644
--- a/packages/app/src/components/settings-keybinds.tsx
+++ b/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
(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 | 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()
+ 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()
+
+ 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()
+
+ 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()
+
+ 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 (
-
-
-
Shortcuts
-
Keyboard shortcuts will be configurable here.
+
)
diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx
index d8dc13e234..7986e7509a 100644
--- a/packages/app/src/context/command.tsx
+++ b/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
= {
+ const keys: Record = {
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[]>([])
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()
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)
},
diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx
index 4160d1b70a..b44b4e1437 100644
--- a/packages/app/src/context/settings.tsx
+++ b/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),