command.tsx 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. import { createMemo, createSignal, onCleanup, onMount, Show, type Accessor } from "solid-js"
  2. import { createSimpleContext } from "@opencode-ai/ui/context"
  3. import { useDialog } from "@opencode-ai/ui/context/dialog"
  4. import { Dialog } from "@opencode-ai/ui/dialog"
  5. import { List } from "@opencode-ai/ui/list"
  6. const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
  7. export type KeybindConfig = string
  8. export interface Keybind {
  9. key: string
  10. ctrl: boolean
  11. meta: boolean
  12. shift: boolean
  13. alt: boolean
  14. }
  15. export interface CommandOption {
  16. id: string
  17. title: string
  18. description?: string
  19. category?: string
  20. keybind?: KeybindConfig
  21. slash?: string
  22. suggested?: boolean
  23. disabled?: boolean
  24. onSelect?: (source?: "palette" | "keybind" | "slash") => void
  25. onHighlight?: () => (() => void) | void
  26. }
  27. export function parseKeybind(config: string): Keybind[] {
  28. if (!config || config === "none") return []
  29. return config.split(",").map((combo) => {
  30. const parts = combo.trim().toLowerCase().split("+")
  31. const keybind: Keybind = {
  32. key: "",
  33. ctrl: false,
  34. meta: false,
  35. shift: false,
  36. alt: false,
  37. }
  38. for (const part of parts) {
  39. switch (part) {
  40. case "ctrl":
  41. case "control":
  42. keybind.ctrl = true
  43. break
  44. case "meta":
  45. case "cmd":
  46. case "command":
  47. keybind.meta = true
  48. break
  49. case "mod":
  50. if (IS_MAC) keybind.meta = true
  51. else keybind.ctrl = true
  52. break
  53. case "alt":
  54. case "option":
  55. keybind.alt = true
  56. break
  57. case "shift":
  58. keybind.shift = true
  59. break
  60. default:
  61. keybind.key = part
  62. break
  63. }
  64. }
  65. return keybind
  66. })
  67. }
  68. export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean {
  69. const eventKey = event.key.toLowerCase()
  70. for (const kb of keybinds) {
  71. const keyMatch = kb.key === eventKey
  72. const ctrlMatch = kb.ctrl === (event.ctrlKey || false)
  73. const metaMatch = kb.meta === (event.metaKey || false)
  74. const shiftMatch = kb.shift === (event.shiftKey || false)
  75. const altMatch = kb.alt === (event.altKey || false)
  76. if (keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch) {
  77. return true
  78. }
  79. }
  80. return false
  81. }
  82. export function formatKeybind(config: string): string {
  83. if (!config || config === "none") return ""
  84. const keybinds = parseKeybind(config)
  85. if (keybinds.length === 0) return ""
  86. const kb = keybinds[0]
  87. const parts: string[] = []
  88. if (kb.ctrl) parts.push(IS_MAC ? "⌃" : "Ctrl")
  89. if (kb.alt) parts.push(IS_MAC ? "⌥" : "Alt")
  90. if (kb.shift) parts.push(IS_MAC ? "⇧" : "Shift")
  91. if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta")
  92. if (kb.key) {
  93. const displayKey = kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1)
  94. parts.push(displayKey)
  95. }
  96. return IS_MAC ? parts.join("") : parts.join("+")
  97. }
  98. function DialogCommand(props: { options: CommandOption[] }) {
  99. const dialog = useDialog()
  100. let cleanup: (() => void) | void
  101. let committed = false
  102. const handleMove = (option: CommandOption | undefined) => {
  103. cleanup?.()
  104. cleanup = option?.onHighlight?.()
  105. }
  106. const handleSelect = (option: CommandOption | undefined) => {
  107. if (option) {
  108. committed = true
  109. cleanup = undefined
  110. dialog.close()
  111. option.onSelect?.("palette")
  112. }
  113. }
  114. onCleanup(() => {
  115. if (!committed) {
  116. cleanup?.()
  117. }
  118. })
  119. return (
  120. <Dialog title="Commands">
  121. <List
  122. search={{ placeholder: "Search commands", autofocus: true }}
  123. emptyMessage="No commands found"
  124. items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)}
  125. key={(x) => x?.id}
  126. filterKeys={["title", "description", "category"]}
  127. groupBy={(x) => x.category ?? ""}
  128. onMove={handleMove}
  129. onSelect={handleSelect}
  130. >
  131. {(option) => (
  132. <div class="w-full flex items-center justify-between gap-4">
  133. <div class="flex items-center gap-2 min-w-0">
  134. <span class="text-14-regular text-text-strong whitespace-nowrap">{option.title}</span>
  135. <Show when={option.description}>
  136. <span class="text-14-regular text-text-weak truncate">{option.description}</span>
  137. </Show>
  138. </div>
  139. <Show when={option.keybind}>
  140. <span class="text-12-regular text-text-subtle shrink-0">{formatKeybind(option.keybind!)}</span>
  141. </Show>
  142. </div>
  143. )}
  144. </List>
  145. </Dialog>
  146. )
  147. }
  148. export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
  149. name: "Command",
  150. init: () => {
  151. const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
  152. const [suspendCount, setSuspendCount] = createSignal(0)
  153. const dialog = useDialog()
  154. const options = createMemo(() => {
  155. const seen = new Set<string>()
  156. const all: CommandOption[] = []
  157. for (const reg of registrations()) {
  158. for (const opt of reg()) {
  159. if (seen.has(opt.id)) continue
  160. seen.add(opt.id)
  161. all.push(opt)
  162. }
  163. }
  164. const suggested = all.filter((x) => x.suggested && !x.disabled)
  165. return [
  166. ...suggested.map((x) => ({
  167. ...x,
  168. id: "suggested." + x.id,
  169. category: "Suggested",
  170. })),
  171. ...all,
  172. ]
  173. })
  174. const suspended = () => suspendCount() > 0
  175. const showPalette = () => {
  176. if (!dialog.active) {
  177. dialog.show(() => <DialogCommand options={options().filter((x) => !x.disabled)} />)
  178. }
  179. }
  180. const handleKeyDown = (event: KeyboardEvent) => {
  181. if (suspended()) return
  182. const paletteKeybinds = parseKeybind("mod+shift+p")
  183. if (matchKeybind(paletteKeybinds, event)) {
  184. event.preventDefault()
  185. showPalette()
  186. return
  187. }
  188. for (const option of options()) {
  189. if (option.disabled) continue
  190. if (!option.keybind) continue
  191. const keybinds = parseKeybind(option.keybind)
  192. if (matchKeybind(keybinds, event)) {
  193. event.preventDefault()
  194. option.onSelect?.("keybind")
  195. return
  196. }
  197. }
  198. }
  199. onMount(() => {
  200. document.addEventListener("keydown", handleKeyDown)
  201. })
  202. onCleanup(() => {
  203. document.removeEventListener("keydown", handleKeyDown)
  204. })
  205. return {
  206. register(cb: () => CommandOption[]) {
  207. const results = createMemo(cb)
  208. setRegistrations((arr) => [results, ...arr])
  209. onCleanup(() => {
  210. setRegistrations((arr) => arr.filter((x) => x !== results))
  211. })
  212. },
  213. trigger(id: string, source?: "palette" | "keybind" | "slash") {
  214. for (const option of options()) {
  215. if (option.id === id || option.id === "suggested." + id) {
  216. option.onSelect?.(source)
  217. return
  218. }
  219. }
  220. },
  221. keybind(id: string) {
  222. const option = options().find((x) => x.id === id || x.id === "suggested." + id)
  223. if (!option?.keybind) return ""
  224. return formatKeybind(option.keybind)
  225. },
  226. show: showPalette,
  227. keybinds(enabled: boolean) {
  228. setSuspendCount((count) => count + (enabled ? -1 : 1))
  229. },
  230. suspended,
  231. get options() {
  232. return options()
  233. },
  234. }
  235. },
  236. })