From df094a10ff1f1a95f66abc6bdccfa69080480afa Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 7 Jan 2026 06:54:48 -0600 Subject: wip(app): settings --- packages/app/src/components/settings-general.tsx | 10 +- packages/app/src/components/settings-keybinds.tsx | 309 +++++++++++++++++++++- 2 files changed, 311 insertions(+), 8 deletions(-) (limited to 'packages/app/src/components') diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 52672d01f..15dc98bfb 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 3688559bc..811b34f9b 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)} + +
+ )} +
+
+
+
+ )} +
) -- cgit v1.2.3