settings-keybinds.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  1. import { Component, For, Show, createMemo, onCleanup, onMount } from "solid-js"
  2. import { createStore } from "solid-js/store"
  3. import { Button } from "@opencode-ai/ui/button"
  4. import { Icon } from "@opencode-ai/ui/icon"
  5. import { IconButton } from "@opencode-ai/ui/icon-button"
  6. import { TextField } from "@opencode-ai/ui/text-field"
  7. import { showToast } from "@opencode-ai/ui/toast"
  8. import fuzzysort from "fuzzysort"
  9. import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
  10. import { useLanguage } from "@/context/language"
  11. import { useSettings } from "@/context/settings"
  12. const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
  13. const PALETTE_ID = "command.palette"
  14. const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
  15. type KeybindGroup = "General" | "Session" | "Navigation" | "Model and agent" | "Terminal" | "Prompt"
  16. type KeybindMeta = {
  17. title: string
  18. group: KeybindGroup
  19. }
  20. const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"]
  21. type GroupKey =
  22. | "settings.shortcuts.group.general"
  23. | "settings.shortcuts.group.session"
  24. | "settings.shortcuts.group.navigation"
  25. | "settings.shortcuts.group.modelAndAgent"
  26. | "settings.shortcuts.group.terminal"
  27. | "settings.shortcuts.group.prompt"
  28. const groupKey: Record<KeybindGroup, GroupKey> = {
  29. General: "settings.shortcuts.group.general",
  30. Session: "settings.shortcuts.group.session",
  31. Navigation: "settings.shortcuts.group.navigation",
  32. "Model and agent": "settings.shortcuts.group.modelAndAgent",
  33. Terminal: "settings.shortcuts.group.terminal",
  34. Prompt: "settings.shortcuts.group.prompt",
  35. }
  36. function groupFor(id: string): KeybindGroup {
  37. if (id === PALETTE_ID) return "General"
  38. if (id.startsWith("terminal.")) return "Terminal"
  39. if (id.startsWith("model.") || id.startsWith("agent.") || id.startsWith("mcp.")) return "Model and agent"
  40. if (id.startsWith("file.")) return "Navigation"
  41. if (id.startsWith("prompt.")) return "Prompt"
  42. if (
  43. id.startsWith("session.") ||
  44. id.startsWith("message.") ||
  45. id.startsWith("permissions.") ||
  46. id.startsWith("steps.") ||
  47. id.startsWith("review.")
  48. )
  49. return "Session"
  50. return "General"
  51. }
  52. function isModifier(key: string) {
  53. return key === "Shift" || key === "Control" || key === "Alt" || key === "Meta"
  54. }
  55. function normalizeKey(key: string) {
  56. if (key === ",") return "comma"
  57. if (key === "+") return "plus"
  58. if (key === " ") return "space"
  59. return key.toLowerCase()
  60. }
  61. function recordKeybind(event: KeyboardEvent) {
  62. if (isModifier(event.key)) return
  63. const parts: string[] = []
  64. const mod = IS_MAC ? event.metaKey : event.ctrlKey
  65. if (mod) parts.push("mod")
  66. if (IS_MAC && event.ctrlKey) parts.push("ctrl")
  67. if (!IS_MAC && event.metaKey) parts.push("meta")
  68. if (event.altKey) parts.push("alt")
  69. if (event.shiftKey) parts.push("shift")
  70. const key = normalizeKey(event.key)
  71. if (!key) return
  72. parts.push(key)
  73. return parts.join("+")
  74. }
  75. function signatures(config: string | undefined) {
  76. if (!config) return []
  77. const sigs: string[] = []
  78. for (const kb of parseKeybind(config)) {
  79. const parts: string[] = []
  80. if (kb.ctrl) parts.push("ctrl")
  81. if (kb.alt) parts.push("alt")
  82. if (kb.shift) parts.push("shift")
  83. if (kb.meta) parts.push("meta")
  84. if (kb.key) parts.push(kb.key)
  85. if (parts.length === 0) continue
  86. sigs.push(parts.join("+"))
  87. }
  88. return sigs
  89. }
  90. export const SettingsKeybinds: Component = () => {
  91. const command = useCommand()
  92. const language = useLanguage()
  93. const settings = useSettings()
  94. const [store, setStore] = createStore({
  95. active: null as string | null,
  96. filter: "",
  97. })
  98. const stop = () => {
  99. if (!store.active) return
  100. setStore("active", null)
  101. command.keybinds(true)
  102. }
  103. const start = (id: string) => {
  104. if (store.active === id) {
  105. stop()
  106. return
  107. }
  108. if (store.active) stop()
  109. setStore("active", id)
  110. command.keybinds(false)
  111. }
  112. const hasOverrides = createMemo(() => {
  113. const keybinds = settings.current.keybinds as Record<string, string | undefined> | undefined
  114. if (!keybinds) return false
  115. return Object.values(keybinds).some((x) => typeof x === "string")
  116. })
  117. const resetAll = () => {
  118. stop()
  119. settings.keybinds.resetAll()
  120. showToast({
  121. title: language.t("settings.shortcuts.reset.toast.title"),
  122. description: language.t("settings.shortcuts.reset.toast.description"),
  123. })
  124. }
  125. const list = createMemo(() => {
  126. language.locale()
  127. const out = new Map<string, KeybindMeta>()
  128. out.set(PALETTE_ID, { title: language.t("command.palette"), group: "General" })
  129. for (const opt of command.catalog) {
  130. if (opt.id.startsWith("suggested.")) continue
  131. out.set(opt.id, { title: opt.title, group: groupFor(opt.id) })
  132. }
  133. for (const opt of command.options) {
  134. if (opt.id.startsWith("suggested.")) continue
  135. out.set(opt.id, { title: opt.title, group: groupFor(opt.id) })
  136. }
  137. const keybinds = settings.current.keybinds as Record<string, string | undefined> | undefined
  138. if (keybinds) {
  139. for (const [id, value] of Object.entries(keybinds)) {
  140. if (typeof value !== "string") continue
  141. if (out.has(id)) continue
  142. out.set(id, { title: id, group: groupFor(id) })
  143. }
  144. }
  145. return out
  146. })
  147. const title = (id: string) => list().get(id)?.title ?? ""
  148. const grouped = createMemo(() => {
  149. const map = list()
  150. const out = new Map<KeybindGroup, string[]>()
  151. for (const group of GROUPS) out.set(group, [])
  152. for (const [id, item] of map) {
  153. const ids = out.get(item.group)
  154. if (!ids) continue
  155. ids.push(id)
  156. }
  157. for (const group of GROUPS) {
  158. const ids = out.get(group)
  159. if (!ids) continue
  160. ids.sort((a, b) => {
  161. const at = map.get(a)?.title ?? ""
  162. const bt = map.get(b)?.title ?? ""
  163. return at.localeCompare(bt)
  164. })
  165. }
  166. return out
  167. })
  168. const filtered = createMemo(() => {
  169. const query = store.filter.toLowerCase().trim()
  170. if (!query) return grouped()
  171. const map = list()
  172. const out = new Map<KeybindGroup, string[]>()
  173. for (const group of GROUPS) out.set(group, [])
  174. const items = Array.from(map.entries()).map(([id, meta]) => ({
  175. id,
  176. title: meta.title,
  177. group: meta.group,
  178. keybind: command.keybind(id) || "",
  179. }))
  180. const results = fuzzysort.go(query, items, {
  181. keys: ["title", "keybind"],
  182. threshold: -10000,
  183. })
  184. for (const result of results) {
  185. const item = result.obj
  186. const ids = out.get(item.group)
  187. if (!ids) continue
  188. ids.push(item.id)
  189. }
  190. return out
  191. })
  192. const hasResults = createMemo(() => {
  193. for (const group of GROUPS) {
  194. const ids = filtered().get(group) ?? []
  195. if (ids.length > 0) return true
  196. }
  197. return false
  198. })
  199. const used = createMemo(() => {
  200. const map = new Map<string, { id: string; title: string }[]>()
  201. const add = (key: string, value: { id: string; title: string }) => {
  202. const list = map.get(key)
  203. if (!list) {
  204. map.set(key, [value])
  205. return
  206. }
  207. list.push(value)
  208. }
  209. const palette = settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND
  210. for (const sig of signatures(palette)) {
  211. add(sig, { id: PALETTE_ID, title: title(PALETTE_ID) })
  212. }
  213. const valueFor = (id: string) => {
  214. const custom = settings.keybinds.get(id)
  215. if (typeof custom === "string") return custom
  216. const live = command.options.find((x) => x.id === id)
  217. if (live?.keybind) return live.keybind
  218. const meta = command.catalog.find((x) => x.id === id)
  219. return meta?.keybind
  220. }
  221. for (const id of list().keys()) {
  222. if (id === PALETTE_ID) continue
  223. for (const sig of signatures(valueFor(id))) {
  224. add(sig, { id, title: title(id) })
  225. }
  226. }
  227. return map
  228. })
  229. const setKeybind = (id: string, keybind: string) => {
  230. settings.keybinds.set(id, keybind)
  231. }
  232. onMount(() => {
  233. const handle = (event: KeyboardEvent) => {
  234. const id = store.active
  235. if (!id) return
  236. event.preventDefault()
  237. event.stopPropagation()
  238. event.stopImmediatePropagation()
  239. if (event.key === "Escape") {
  240. stop()
  241. return
  242. }
  243. const clear =
  244. (event.key === "Backspace" || event.key === "Delete") &&
  245. !event.ctrlKey &&
  246. !event.metaKey &&
  247. !event.altKey &&
  248. !event.shiftKey
  249. if (clear) {
  250. setKeybind(id, "none")
  251. stop()
  252. return
  253. }
  254. const next = recordKeybind(event)
  255. if (!next) return
  256. const map = used()
  257. const conflicts = new Map<string, string>()
  258. for (const sig of signatures(next)) {
  259. const list = map.get(sig) ?? []
  260. for (const item of list) {
  261. if (item.id === id) continue
  262. conflicts.set(item.id, item.title)
  263. }
  264. }
  265. if (conflicts.size > 0) {
  266. showToast({
  267. title: language.t("settings.shortcuts.conflict.title"),
  268. description: language.t("settings.shortcuts.conflict.description", {
  269. keybind: formatKeybind(next),
  270. titles: [...conflicts.values()].join(", "),
  271. }),
  272. })
  273. return
  274. }
  275. setKeybind(id, next)
  276. stop()
  277. }
  278. document.addEventListener("keydown", handle, true)
  279. onCleanup(() => {
  280. document.removeEventListener("keydown", handle, true)
  281. })
  282. })
  283. onCleanup(() => {
  284. if (store.active) command.keybinds(true)
  285. })
  286. return (
  287. <div class="flex flex-col h-full overflow-y-auto no-scrollbar" style={{ padding: "0 40px 40px 40px" }}>
  288. <div
  289. class="sticky top-0 z-10"
  290. style={{
  291. background:
  292. "linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha) calc(100% - 24px), transparent)",
  293. }}
  294. >
  295. <div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
  296. <div class="flex items-center justify-between gap-4">
  297. <h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
  298. <Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
  299. {language.t("settings.shortcuts.reset.button")}
  300. </Button>
  301. </div>
  302. <div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
  303. <Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
  304. <TextField
  305. variant="ghost"
  306. type="text"
  307. value={store.filter}
  308. onChange={(v) => setStore("filter", v)}
  309. placeholder={language.t("settings.shortcuts.search.placeholder")}
  310. spellcheck={false}
  311. autocorrect="off"
  312. autocomplete="off"
  313. autocapitalize="off"
  314. class="flex-1"
  315. />
  316. <Show when={store.filter}>
  317. <IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
  318. </Show>
  319. </div>
  320. </div>
  321. </div>
  322. <div class="flex flex-col gap-8 max-w-[720px]">
  323. <For each={GROUPS}>
  324. {(group) => (
  325. <Show when={(filtered().get(group) ?? []).length > 0}>
  326. <div class="flex flex-col gap-1">
  327. <h3 class="text-14-medium text-text-strong pb-2">{language.t(groupKey[group])}</h3>
  328. <div class="bg-surface-raised-base px-4 rounded-lg">
  329. <For each={filtered().get(group) ?? []}>
  330. {(id) => (
  331. <div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
  332. <span class="text-14-regular text-text-strong">{title(id)}</span>
  333. <button
  334. type="button"
  335. classList={{
  336. "h-8 px-3 rounded-md text-12-regular": true,
  337. "bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active":
  338. store.active !== id,
  339. "border border-border-weak-base bg-surface-inset-base text-text-weak": store.active === id,
  340. }}
  341. onClick={() => start(id)}
  342. >
  343. <Show
  344. when={store.active === id}
  345. fallback={command.keybind(id) || language.t("settings.shortcuts.unassigned")}
  346. >
  347. {language.t("settings.shortcuts.pressKeys")}
  348. </Show>
  349. </button>
  350. </div>
  351. )}
  352. </For>
  353. </div>
  354. </div>
  355. </Show>
  356. )}
  357. </For>
  358. <Show when={store.filter && !hasResults()}>
  359. <div class="flex flex-col items-center justify-center py-12 text-center">
  360. <span class="text-14-regular text-text-weak">{language.t("settings.shortcuts.search.empty")}</span>
  361. <Show when={store.filter}>
  362. <span class="text-14-regular text-text-strong mt-1">"{store.filter}"</span>
  363. </Show>
  364. </div>
  365. </Show>
  366. </div>
  367. </div>
  368. )
  369. }