Skip to content

Commit df094a1

Browse files
committed
wip(app): settings
1 parent de3641e commit df094a1

4 files changed

Lines changed: 361 additions & 18 deletions

File tree

packages/app/src/components/settings-general.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,14 @@ export const SettingsGeneral: Component = () => {
3737

3838
return (
3939
<div class="flex flex-col h-full overflow-y-auto no-scrollbar">
40-
<div class="flex flex-col gap-8 p-8 max-w-[720px]">
41-
{/* Header */}
42-
<h2 class="text-16-medium text-text-strong">General</h2>
40+
<div class="sticky top-0 z-10 bg-background-base border-b border-border-weak-base">
41+
<div class="flex flex-col gap-1 p-8 max-w-[720px]">
42+
<h2 class="text-16-medium text-text-strong">General</h2>
43+
<p class="text-14-regular text-text-weak">Appearance, notifications, and sound preferences.</p>
44+
</div>
45+
</div>
4346

47+
<div class="flex flex-col gap-8 p-8 pt-6 max-w-[720px]">
4448
{/* Appearance Section */}
4549
<div class="flex flex-col gap-1">
4650
<h3 class="text-14-medium text-text-strong pb-2">Appearance</h3>

packages/app/src/components/settings-keybinds.tsx

Lines changed: 304 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,310 @@
1-
import { Component } from "solid-js"
1+
import { Component, For, Show, createMemo, createSignal, onCleanup, onMount } from "solid-js"
2+
import { Button } from "@opencode-ai/ui/button"
3+
import { showToast } from "@opencode-ai/ui/toast"
4+
import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
5+
import { useSettings } from "@/context/settings"
6+
7+
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
8+
const PALETTE_ID = "command.palette"
9+
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
10+
11+
type KeybindGroup = "General" | "Session" | "Navigation" | "Model and agent" | "Terminal" | "Prompt"
12+
13+
type KeybindMeta = {
14+
title: string
15+
group: KeybindGroup
16+
}
17+
18+
const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"]
19+
20+
function groupFor(id: string): KeybindGroup {
21+
if (id === PALETTE_ID) return "General"
22+
if (id.startsWith("terminal.")) return "Terminal"
23+
if (id.startsWith("model.") || id.startsWith("agent.") || id.startsWith("mcp.")) return "Model and agent"
24+
if (id.startsWith("file.")) return "Navigation"
25+
if (id.startsWith("prompt.")) return "Prompt"
26+
if (
27+
id.startsWith("session.") ||
28+
id.startsWith("message.") ||
29+
id.startsWith("permissions.") ||
30+
id.startsWith("steps.") ||
31+
id.startsWith("review.")
32+
)
33+
return "Session"
34+
35+
return "General"
36+
}
37+
38+
function isModifier(key: string) {
39+
return key === "Shift" || key === "Control" || key === "Alt" || key === "Meta"
40+
}
41+
42+
function normalizeKey(key: string) {
43+
if (key === ",") return "comma"
44+
if (key === "+") return "plus"
45+
if (key === " ") return "space"
46+
return key.toLowerCase()
47+
}
48+
49+
function recordKeybind(event: KeyboardEvent) {
50+
if (isModifier(event.key)) return
51+
52+
const parts: string[] = []
53+
54+
const mod = IS_MAC ? event.metaKey : event.ctrlKey
55+
if (mod) parts.push("mod")
56+
57+
if (IS_MAC && event.ctrlKey) parts.push("ctrl")
58+
if (!IS_MAC && event.metaKey) parts.push("meta")
59+
if (event.altKey) parts.push("alt")
60+
if (event.shiftKey) parts.push("shift")
61+
62+
const key = normalizeKey(event.key)
63+
if (!key) return
64+
parts.push(key)
65+
66+
return parts.join("+")
67+
}
68+
69+
function signatures(config: string | undefined) {
70+
if (!config) return []
71+
const sigs: string[] = []
72+
73+
for (const kb of parseKeybind(config)) {
74+
const parts: string[] = []
75+
if (kb.ctrl) parts.push("ctrl")
76+
if (kb.alt) parts.push("alt")
77+
if (kb.shift) parts.push("shift")
78+
if (kb.meta) parts.push("meta")
79+
if (kb.key) parts.push(kb.key)
80+
if (parts.length === 0) continue
81+
sigs.push(parts.join("+"))
82+
}
83+
84+
return sigs
85+
}
286

387
export const SettingsKeybinds: Component = () => {
88+
const command = useCommand()
89+
const settings = useSettings()
90+
91+
const [active, setActive] = createSignal<string | null>(null)
92+
93+
const stop = () => {
94+
if (!active()) return
95+
setActive(null)
96+
command.keybinds(true)
97+
}
98+
99+
const start = (id: string) => {
100+
if (active() === id) {
101+
stop()
102+
return
103+
}
104+
105+
if (active()) stop()
106+
107+
setActive(id)
108+
command.keybinds(false)
109+
}
110+
111+
const hasOverrides = createMemo(() => {
112+
const keybinds = settings.current.keybinds as Record<string, string | undefined> | undefined
113+
if (!keybinds) return false
114+
return Object.values(keybinds).some((x) => typeof x === "string")
115+
})
116+
117+
const resetAll = () => {
118+
stop()
119+
settings.keybinds.resetAll()
120+
showToast({ title: "Shortcuts reset", description: "Keyboard shortcuts have been reset to defaults." })
121+
}
122+
123+
const list = createMemo(() => {
124+
const out = new Map<string, KeybindMeta>()
125+
out.set(PALETTE_ID, { title: "Command palette", group: "General" })
126+
127+
for (const opt of command.options) {
128+
if (opt.id.startsWith("suggested.")) continue
129+
130+
out.set(opt.id, {
131+
title: opt.title,
132+
group: groupFor(opt.id),
133+
})
134+
}
135+
136+
return out
137+
})
138+
139+
const title = (id: string) => list().get(id)?.title ?? ""
140+
141+
const grouped = createMemo(() => {
142+
const map = list()
143+
const out = new Map<KeybindGroup, string[]>()
144+
145+
for (const group of GROUPS) out.set(group, [])
146+
147+
for (const [id, item] of map) {
148+
const ids = out.get(item.group)
149+
if (!ids) continue
150+
ids.push(id)
151+
}
152+
153+
for (const group of GROUPS) {
154+
const ids = out.get(group)
155+
if (!ids) continue
156+
157+
ids.sort((a, b) => {
158+
const at = map.get(a)?.title ?? ""
159+
const bt = map.get(b)?.title ?? ""
160+
return at.localeCompare(bt)
161+
})
162+
}
163+
164+
return out
165+
})
166+
167+
const used = createMemo(() => {
168+
const map = new Map<string, { id: string; title: string }[]>()
169+
170+
const add = (key: string, value: { id: string; title: string }) => {
171+
const list = map.get(key)
172+
if (!list) {
173+
map.set(key, [value])
174+
return
175+
}
176+
list.push(value)
177+
}
178+
179+
const palette = settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND
180+
for (const sig of signatures(palette)) {
181+
add(sig, { id: PALETTE_ID, title: "Command palette" })
182+
}
183+
184+
for (const opt of command.options) {
185+
if (opt.id.startsWith("suggested.")) continue
186+
if (!opt.keybind) continue
187+
for (const sig of signatures(opt.keybind)) {
188+
add(sig, { id: opt.id, title: opt.title })
189+
}
190+
}
191+
192+
return map
193+
})
194+
195+
const setKeybind = (id: string, keybind: string) => {
196+
settings.keybinds.set(id, keybind)
197+
}
198+
199+
onMount(() => {
200+
const handle = (event: KeyboardEvent) => {
201+
const id = active()
202+
if (!id) return
203+
204+
event.preventDefault()
205+
event.stopPropagation()
206+
event.stopImmediatePropagation()
207+
208+
if (event.key === "Escape") {
209+
stop()
210+
return
211+
}
212+
213+
const clear =
214+
(event.key === "Backspace" || event.key === "Delete") &&
215+
!event.ctrlKey &&
216+
!event.metaKey &&
217+
!event.altKey &&
218+
!event.shiftKey
219+
if (clear) {
220+
setKeybind(id, "none")
221+
stop()
222+
return
223+
}
224+
225+
const next = recordKeybind(event)
226+
if (!next) return
227+
228+
const map = used()
229+
const conflicts = new Map<string, string>()
230+
231+
for (const sig of signatures(next)) {
232+
const list = map.get(sig) ?? []
233+
for (const item of list) {
234+
if (item.id === id) continue
235+
conflicts.set(item.id, item.title)
236+
}
237+
}
238+
239+
if (conflicts.size > 0) {
240+
showToast({
241+
title: "Shortcut already in use",
242+
description: `${formatKeybind(next)} is already assigned to ${[...conflicts.values()].join(", ")}.`,
243+
})
244+
return
245+
}
246+
247+
setKeybind(id, next)
248+
stop()
249+
}
250+
251+
document.addEventListener("keydown", handle, true)
252+
onCleanup(() => {
253+
document.removeEventListener("keydown", handle, true)
254+
})
255+
})
256+
257+
onCleanup(() => {
258+
if (active()) command.keybinds(true)
259+
})
260+
4261
return (
5-
<div class="flex flex-col h-full overflow-y-auto">
6-
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
7-
<h2 class="text-16-medium text-text-strong">Shortcuts</h2>
8-
<p class="text-14-regular text-text-weak">Keyboard shortcuts will be configurable here.</p>
262+
<div class="flex flex-col h-full overflow-y-auto no-scrollbar">
263+
<div class="sticky top-0 z-10 bg-background-base border-b border-border-weak-base">
264+
<div class="flex items-start justify-between gap-4 p-8 max-w-[720px]">
265+
<div class="flex flex-col gap-1">
266+
<h2 class="text-16-medium text-text-strong">Keyboard shortcuts</h2>
267+
<p class="text-14-regular text-text-weak">Click a shortcut to edit. Press Esc to cancel.</p>
268+
</div>
269+
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
270+
Reset to defaults
271+
</Button>
272+
</div>
273+
</div>
274+
275+
<div class="flex flex-col gap-6 p-8 pt-6 max-w-[720px]">
276+
<For each={GROUPS}>
277+
{(group) => (
278+
<Show when={(grouped().get(group) ?? []).length > 0}>
279+
<div class="flex flex-col gap-2">
280+
<h3 class="text-14-medium text-text-strong">{group}</h3>
281+
<div class="border border-border-weak-base rounded-lg overflow-hidden">
282+
<For each={grouped().get(group) ?? []}>
283+
{(id) => (
284+
<div class="flex items-center justify-between gap-4 px-4 py-3 border-b border-border-weak-base last:border-none">
285+
<span class="text-14-regular text-text-strong">{title(id)}</span>
286+
<button
287+
type="button"
288+
classList={{
289+
"h-8 px-3 rounded-md text-12-regular border border-border-base": true,
290+
"bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active":
291+
active() !== id,
292+
"bg-surface-raised-stronger-non-alpha text-text-strong": active() === id,
293+
}}
294+
onClick={() => start(id)}
295+
>
296+
<Show when={active() === id} fallback={command.keybind(id) || "Unassigned"}>
297+
Press keys
298+
</Show>
299+
</button>
300+
</div>
301+
)}
302+
</For>
303+
</div>
304+
</div>
305+
</Show>
306+
)}
307+
</For>
9308
</div>
10309
</div>
11310
)

0 commit comments

Comments
 (0)