settings-keybinds.tsx 14 KB

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