settings-keybinds.tsx 17 KB

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