command.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  1. import { createSimpleContext } from "@opencode-ai/ui/context"
  2. import { useDialog } from "@opencode-ai/ui/context/dialog"
  3. import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js"
  4. import { createStore } from "solid-js/store"
  5. import { makeEventListener } from "@solid-primitives/event-listener"
  6. import { useLanguage } from "@/context/language"
  7. import { useSettings } from "@/context/settings"
  8. import { dict as en } from "@/i18n/en"
  9. import { Persist, persisted } from "@/utils/persist"
  10. const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
  11. const PALETTE_ID = "command.palette"
  12. export const DEFAULT_PALETTE_KEYBIND = "mod+k,mod+shift+p"
  13. const SUGGESTED_PREFIX = "suggested."
  14. const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"])
  15. type KeyLabel =
  16. | "common.key.ctrl"
  17. | "common.key.alt"
  18. | "common.key.shift"
  19. | "common.key.meta"
  20. | "common.key.space"
  21. | "common.key.backspace"
  22. | "common.key.enter"
  23. | "common.key.tab"
  24. | "common.key.delete"
  25. | "common.key.home"
  26. | "common.key.end"
  27. | "common.key.pageUp"
  28. | "common.key.pageDown"
  29. | "common.key.insert"
  30. | "common.key.esc"
  31. function keyText(key: KeyLabel, t?: (key: KeyLabel) => string) {
  32. return t ? t(key) : en[key]
  33. }
  34. function actionId(id: string) {
  35. if (!id.startsWith(SUGGESTED_PREFIX)) return id
  36. return id.slice(SUGGESTED_PREFIX.length)
  37. }
  38. function normalizeKey(key: string) {
  39. if (key === ",") return "comma"
  40. if (key === "+") return "plus"
  41. if (key === " ") return "space"
  42. return key.toLowerCase()
  43. }
  44. function signature(key: string, ctrl: boolean, meta: boolean, shift: boolean, alt: boolean) {
  45. const mask = (ctrl ? 1 : 0) | (meta ? 2 : 0) | (shift ? 4 : 0) | (alt ? 8 : 0)
  46. return `${key}:${mask}`
  47. }
  48. function signatureFromEvent(event: KeyboardEvent) {
  49. return signature(normalizeKey(event.key), event.ctrlKey, event.metaKey, event.shiftKey, event.altKey)
  50. }
  51. function isAllowedEditableKeybind(id: string | undefined) {
  52. if (!id) return false
  53. return EDITABLE_KEYBIND_IDS.has(actionId(id))
  54. }
  55. export type KeybindConfig = string
  56. export interface Keybind {
  57. key: string
  58. ctrl: boolean
  59. meta: boolean
  60. shift: boolean
  61. alt: boolean
  62. }
  63. export interface CommandOption {
  64. id: string
  65. title: string
  66. description?: string
  67. category?: string
  68. keybind?: KeybindConfig
  69. slash?: string
  70. suggested?: boolean
  71. disabled?: boolean
  72. hidden?: boolean
  73. when?: (event: KeyboardEvent) => boolean
  74. onSelect?: (source?: "palette" | "keybind" | "slash") => void
  75. onHighlight?: () => (() => void) | void
  76. }
  77. export function commandPaletteOptions(options: CommandOption[]) {
  78. return options.filter(
  79. (option) =>
  80. !option.disabled &&
  81. !option.hidden &&
  82. !option.id.startsWith(SUGGESTED_PREFIX) &&
  83. option.id !== "file.open",
  84. )
  85. }
  86. export function resolveKeybindOption(candidates: CommandOption[] | undefined, event: KeyboardEvent) {
  87. return candidates?.find((option) => option.when?.(event)) ?? candidates?.find((option) => !option.when)
  88. }
  89. type CommandSource = "palette" | "keybind" | "slash"
  90. export type CommandCatalogItem = {
  91. title: string
  92. description?: string
  93. category?: string
  94. keybind?: KeybindConfig
  95. slash?: string
  96. hidden?: boolean
  97. }
  98. export type CommandRegistration = {
  99. key?: string
  100. options: Accessor<CommandOption[]>
  101. }
  102. export function upsertCommandRegistration(registrations: CommandRegistration[], entry: CommandRegistration) {
  103. if (entry.key === undefined) return [entry, ...registrations]
  104. return [entry, ...registrations.filter((x) => x.key !== entry.key)]
  105. }
  106. export function parseKeybind(config: string): Keybind[] {
  107. if (!config || config === "none") return []
  108. return config.split(",").map((combo) => {
  109. const parts = combo.trim().toLowerCase().split("+")
  110. const keybind: Keybind = {
  111. key: "",
  112. ctrl: false,
  113. meta: false,
  114. shift: false,
  115. alt: false,
  116. }
  117. for (const part of parts) {
  118. switch (part) {
  119. case "ctrl":
  120. case "control":
  121. keybind.ctrl = true
  122. break
  123. case "meta":
  124. case "cmd":
  125. case "command":
  126. keybind.meta = true
  127. break
  128. case "mod":
  129. if (IS_MAC) keybind.meta = true
  130. else keybind.ctrl = true
  131. break
  132. case "alt":
  133. case "option":
  134. keybind.alt = true
  135. break
  136. case "shift":
  137. keybind.shift = true
  138. break
  139. default:
  140. keybind.key = part
  141. break
  142. }
  143. }
  144. return keybind
  145. })
  146. }
  147. export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean {
  148. const eventKey = normalizeKey(event.key)
  149. for (const kb of keybinds) {
  150. const keyMatch = kb.key === eventKey
  151. const ctrlMatch = kb.ctrl === (event.ctrlKey || false)
  152. const metaMatch = kb.meta === (event.metaKey || false)
  153. const shiftMatch = kb.shift === (event.shiftKey || false)
  154. const altMatch = kb.alt === (event.altKey || false)
  155. if (keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch) {
  156. return true
  157. }
  158. }
  159. return false
  160. }
  161. function displayKeybindParts(kb: Keybind, t?: (key: KeyLabel) => string) {
  162. const parts: string[] = []
  163. if (kb.ctrl) parts.push(IS_MAC ? "⌃" : keyText("common.key.ctrl", t))
  164. if (kb.alt) parts.push(IS_MAC ? "⌥" : keyText("common.key.alt", t))
  165. if (kb.shift) parts.push(IS_MAC ? "⇧" : keyText("common.key.shift", t))
  166. if (kb.meta) parts.push(IS_MAC ? "⌘" : keyText("common.key.meta", t))
  167. if (!kb.key) return parts
  168. const keys: Record<string, string> = {
  169. arrowup: "↑",
  170. arrowdown: "↓",
  171. arrowleft: "←",
  172. arrowright: "→",
  173. comma: ",",
  174. plus: "+",
  175. }
  176. const named: Record<string, KeyLabel> = {
  177. backspace: "common.key.backspace",
  178. delete: "common.key.delete",
  179. end: "common.key.end",
  180. enter: "common.key.enter",
  181. esc: "common.key.esc",
  182. escape: "common.key.esc",
  183. home: "common.key.home",
  184. insert: "common.key.insert",
  185. pagedown: "common.key.pageDown",
  186. pageup: "common.key.pageUp",
  187. space: "common.key.space",
  188. tab: "common.key.tab",
  189. }
  190. const key = kb.key.toLowerCase()
  191. const displayKey =
  192. keys[key] ??
  193. (named[key]
  194. ? keyText(named[key], t)
  195. : key.length === 1
  196. ? key.toUpperCase()
  197. : key.charAt(0).toUpperCase() + key.slice(1))
  198. parts.push(displayKey)
  199. return parts
  200. }
  201. export function formatKeybindParts(config: string, t?: (key: KeyLabel) => string): string[] {
  202. if (!config || config === "none") return []
  203. const keybind = parseKeybind(config)[0]
  204. return keybind ? displayKeybindParts(keybind, t) : []
  205. }
  206. export function formatKeybind(config: string, t?: (key: KeyLabel) => string): string {
  207. const parts = formatKeybindParts(config, t)
  208. if (parts.length === 0) return ""
  209. return IS_MAC ? parts.join("") : parts.join("+")
  210. }
  211. // KeybindV2 takes an array instead of a string
  212. export function formatKeybindKeys(config: string, t?: (key: KeyLabel) => string): string[] {
  213. return formatKeybindParts(config, t)
  214. }
  215. function isEditableTarget(target: EventTarget | null) {
  216. if (!(target instanceof HTMLElement)) return false
  217. if (target.isContentEditable) return true
  218. if (target.closest("[contenteditable='true']")) return true
  219. if (target.closest("input, textarea, select")) return true
  220. return false
  221. }
  222. export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
  223. name: "Command",
  224. init: () => {
  225. const dialog = useDialog()
  226. const settings = useSettings()
  227. const language = useLanguage()
  228. const [store, setStore] = createStore({
  229. registrations: [] as CommandRegistration[],
  230. suspendCount: 0,
  231. })
  232. const warnedDuplicates = new Set<string>()
  233. type CommandCatalog = Record<string, CommandCatalogItem>
  234. const [catalog, setCatalog, _, catalogReady] = persisted(
  235. Persist.global("command.catalog.v1"),
  236. createStore<CommandCatalog>({}),
  237. )
  238. const bind = (id: string, def: KeybindConfig | undefined) => {
  239. const custom = settings.keybinds.get(actionId(id))
  240. const config = custom ?? def
  241. if (!config || config === "none") return
  242. return config
  243. }
  244. const registered = createMemo(() => {
  245. const seen = new Set<string>()
  246. const all: CommandOption[] = []
  247. for (const reg of store.registrations) {
  248. for (const opt of reg.options()) {
  249. if (seen.has(opt.id)) {
  250. if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) {
  251. warnedDuplicates.add(opt.id)
  252. console.warn(`[command] duplicate command id "${opt.id}" registered; keeping first entry`)
  253. }
  254. continue
  255. }
  256. seen.add(opt.id)
  257. all.push(opt)
  258. }
  259. }
  260. return all
  261. })
  262. createEffect(() => {
  263. if (!catalogReady()) return
  264. setCatalog(
  265. registered().reduce((acc, opt) => {
  266. const id = actionId(opt.id)
  267. if (opt.title)
  268. acc[id] = {
  269. title: opt.title,
  270. description: opt.description,
  271. category: opt.category,
  272. keybind: opt.keybind,
  273. slash: opt.slash,
  274. }
  275. return acc
  276. }, {} as CommandCatalog),
  277. )
  278. })
  279. const catalogOptions = createMemo(() => Object.entries(catalog).map(([id, meta]) => ({ id, ...meta })))
  280. const options = createMemo(() => {
  281. const resolved = registered().map((opt) => ({
  282. ...opt,
  283. keybind: bind(opt.id, opt.keybind),
  284. }))
  285. const suggested = resolved.filter((x) => x.suggested && !x.disabled)
  286. return [
  287. ...suggested.map((x) => ({
  288. ...x,
  289. id: SUGGESTED_PREFIX + x.id,
  290. category: language.t("command.category.suggested"),
  291. })),
  292. ...resolved,
  293. ]
  294. })
  295. const suspended = () => store.suspendCount > 0
  296. const palette = createMemo(() => {
  297. const config = settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND
  298. const keybinds = parseKeybind(config)
  299. return new Set(keybinds.map((kb) => signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt)))
  300. })
  301. const keymap = createMemo(() => {
  302. const map = new Map<string, CommandOption[]>()
  303. for (const option of options()) {
  304. if (option.id.startsWith(SUGGESTED_PREFIX)) continue
  305. if (option.disabled) continue
  306. if (!option.keybind) continue
  307. const keybinds = parseKeybind(option.keybind)
  308. for (const kb of keybinds) {
  309. if (!kb.key) continue
  310. const sig = signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt)
  311. const existing = map.get(sig)
  312. if (existing) {
  313. existing.push(option)
  314. continue
  315. }
  316. map.set(sig, [option])
  317. }
  318. }
  319. return map
  320. })
  321. const optionMap = createMemo(() => {
  322. const map = new Map<string, CommandOption>()
  323. for (const option of options()) {
  324. map.set(option.id, option)
  325. map.set(actionId(option.id), option)
  326. }
  327. return map
  328. })
  329. const run = (id: string, source?: CommandSource) => {
  330. const option = optionMap().get(id)
  331. option?.onSelect?.(source)
  332. }
  333. const showPalette = () => {
  334. run(PALETTE_ID, "palette")
  335. }
  336. const handleKeyDown = (event: KeyboardEvent) => {
  337. if (suspended() || dialog.active) return
  338. const sig = signatureFromEvent(event)
  339. const isPalette = palette().has(sig)
  340. const option = resolveKeybindOption(keymap().get(sig), event)
  341. const modified = event.ctrlKey || event.metaKey || event.altKey
  342. const isTab = event.key === "Tab"
  343. if (isEditableTarget(event.target) && !isPalette && !isAllowedEditableKeybind(option?.id) && !modified && !isTab)
  344. return
  345. if (isPalette) {
  346. event.preventDefault()
  347. event.stopPropagation()
  348. showPalette()
  349. return
  350. }
  351. if (!option) return
  352. event.preventDefault()
  353. event.stopPropagation()
  354. option.onSelect?.("keybind")
  355. }
  356. onMount(() => {
  357. makeEventListener(document, "keydown", handleKeyDown, { capture: true })
  358. })
  359. function register(cb: () => CommandOption[]): void
  360. function register(key: string, cb: () => CommandOption[]): void
  361. function register(key: string | (() => CommandOption[]), cb?: () => CommandOption[]) {
  362. const id = typeof key === "string" ? key : undefined
  363. const next = typeof key === "function" ? key : cb
  364. if (!next) return
  365. const options = createMemo(next)
  366. const entry: CommandRegistration = {
  367. key: id,
  368. options,
  369. }
  370. setStore("registrations", (arr) => upsertCommandRegistration(arr, entry))
  371. onCleanup(() => {
  372. setStore("registrations", (arr) => arr.filter((x) => x !== entry))
  373. })
  374. }
  375. const keybindConfig = (id: string) => {
  376. if (id === PALETTE_ID) return settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND
  377. const base = actionId(id)
  378. return options().find((x) => actionId(x.id) === base)?.keybind ?? bind(base, catalog[base]?.keybind)
  379. }
  380. return {
  381. register,
  382. trigger(id: string, source?: CommandSource) {
  383. run(id, source)
  384. },
  385. keybind(id: string) {
  386. const config = keybindConfig(id)
  387. if (!config) return ""
  388. return formatKeybind(config, language.t)
  389. },
  390. keybindParts(id: string) {
  391. const config = keybindConfig(id)
  392. return config ? formatKeybindParts(config, language.t) : []
  393. },
  394. show: showPalette,
  395. keybinds(enabled: boolean) {
  396. setStore("suspendCount", (count) => Math.max(0, count + (enabled ? -1 : 1)))
  397. },
  398. suspended,
  399. get catalog() {
  400. return catalogOptions()
  401. },
  402. get options() {
  403. return options()
  404. },
  405. }
  406. },
  407. })