command.tsx 13 KB

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