command.tsx 12 KB

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