diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 52672d01f4..15dc98bfbe 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -37,10 +37,14 @@ export const SettingsGeneral: Component = () => { return (
-
- {/* Header */} -

General

+
+
+

General

+

Appearance, notifications, and sound preferences.

+
+
+
{/* Appearance Section */}

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.

+
+
+
+
+

Keyboard shortcuts

+

Click a shortcut to edit. Press Esc to cancel.

+
+ +
+
+ +
+ + {(group) => ( + 0}> +
+

{group}

+
+ + {(id) => ( +
+ {title(id)} + +
+ )} +
+
+
+
+ )} +
) 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),