command.tsx 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  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. }
  26. export function parseKeybind(config: string): Keybind[] {
  27. if (!config || config === "none") return []
  28. return config.split(",").map((combo) => {
  29. const parts = combo.trim().toLowerCase().split("+")
  30. const keybind: Keybind = {
  31. key: "",
  32. ctrl: false,
  33. meta: false,
  34. shift: false,
  35. alt: false,
  36. }
  37. for (const part of parts) {
  38. switch (part) {
  39. case "ctrl":
  40. case "control":
  41. keybind.ctrl = true
  42. break
  43. case "meta":
  44. case "cmd":
  45. case "command":
  46. keybind.meta = true
  47. break
  48. case "mod":
  49. if (IS_MAC) keybind.meta = true
  50. else keybind.ctrl = true
  51. break
  52. case "alt":
  53. case "option":
  54. keybind.alt = true
  55. break
  56. case "shift":
  57. keybind.shift = true
  58. break
  59. default:
  60. keybind.key = part
  61. break
  62. }
  63. }
  64. return keybind
  65. })
  66. }
  67. export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean {
  68. const eventKey = event.key.toLowerCase()
  69. for (const kb of keybinds) {
  70. const keyMatch = kb.key === eventKey
  71. const ctrlMatch = kb.ctrl === (event.ctrlKey || false)
  72. const metaMatch = kb.meta === (event.metaKey || false)
  73. const shiftMatch = kb.shift === (event.shiftKey || false)
  74. const altMatch = kb.alt === (event.altKey || false)
  75. if (keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch) {
  76. return true
  77. }
  78. }
  79. return false
  80. }
  81. export function formatKeybind(config: string): string {
  82. if (!config || config === "none") return ""
  83. const keybinds = parseKeybind(config)
  84. if (keybinds.length === 0) return ""
  85. const kb = keybinds[0]
  86. const parts: string[] = []
  87. if (kb.ctrl) parts.push(IS_MAC ? "⌃" : "Ctrl")
  88. if (kb.alt) parts.push(IS_MAC ? "⌥" : "Alt")
  89. if (kb.shift) parts.push(IS_MAC ? "⇧" : "Shift")
  90. if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta")
  91. if (kb.key) {
  92. const displayKey = kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1)
  93. parts.push(displayKey)
  94. }
  95. return IS_MAC ? parts.join("") : parts.join("+")
  96. }
  97. function DialogCommand(props: { options: CommandOption[] }) {
  98. const dialog = useDialog()
  99. return (
  100. <Dialog title="Commands">
  101. <List
  102. class="px-2.5"
  103. search={{ placeholder: "Search commands", autofocus: true }}
  104. emptyMessage="No commands found"
  105. items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)}
  106. key={(x) => x?.id}
  107. filterKeys={["title", "description", "category"]}
  108. groupBy={(x) => x.category ?? ""}
  109. onSelect={(option) => {
  110. if (option) {
  111. dialog.clear()
  112. option.onSelect?.("palette")
  113. }
  114. }}
  115. >
  116. {(option) => (
  117. <div class="w-full flex items-center justify-between gap-4">
  118. <div class="flex items-center gap-2 min-w-0">
  119. <span class="text-14-regular text-text-strong whitespace-nowrap">{option.title}</span>
  120. <Show when={option.description}>
  121. <span class="text-14-regular text-text-weak truncate">{option.description}</span>
  122. </Show>
  123. </div>
  124. <Show when={option.keybind}>
  125. <span class="text-12-regular text-text-subtle shrink-0">{formatKeybind(option.keybind!)}</span>
  126. </Show>
  127. </div>
  128. )}
  129. </List>
  130. </Dialog>
  131. )
  132. }
  133. export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
  134. name: "Command",
  135. init: () => {
  136. const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
  137. const [suspendCount, setSuspendCount] = createSignal(0)
  138. const dialog = useDialog()
  139. const options = createMemo(() => {
  140. const all = registrations().flatMap((x) => x())
  141. const suggested = all.filter((x) => x.suggested && !x.disabled)
  142. return [
  143. ...suggested.map((x) => ({
  144. ...x,
  145. id: "suggested." + x.id,
  146. category: "Suggested",
  147. })),
  148. ...all,
  149. ]
  150. })
  151. const suspended = () => suspendCount() > 0
  152. const showPalette = () => {
  153. if (dialog.stack.length === 0) {
  154. dialog.replace(() => <DialogCommand options={options().filter((x) => !x.disabled)} />)
  155. }
  156. }
  157. const handleKeyDown = (event: KeyboardEvent) => {
  158. if (suspended()) return
  159. const paletteKeybinds = parseKeybind("mod+shift+p")
  160. if (matchKeybind(paletteKeybinds, event)) {
  161. event.preventDefault()
  162. showPalette()
  163. return
  164. }
  165. for (const option of options()) {
  166. if (option.disabled) continue
  167. if (!option.keybind) continue
  168. const keybinds = parseKeybind(option.keybind)
  169. if (matchKeybind(keybinds, event)) {
  170. event.preventDefault()
  171. option.onSelect?.("keybind")
  172. return
  173. }
  174. }
  175. }
  176. onMount(() => {
  177. document.addEventListener("keydown", handleKeyDown)
  178. })
  179. onCleanup(() => {
  180. document.removeEventListener("keydown", handleKeyDown)
  181. })
  182. return {
  183. register(cb: () => CommandOption[]) {
  184. const results = createMemo(cb)
  185. setRegistrations((arr) => [results, ...arr])
  186. onCleanup(() => {
  187. setRegistrations((arr) => arr.filter((x) => x !== results))
  188. })
  189. },
  190. trigger(id: string, source?: "palette" | "keybind" | "slash") {
  191. for (const option of options()) {
  192. if (option.id === id || option.id === "suggested." + id) {
  193. option.onSelect?.(source)
  194. return
  195. }
  196. }
  197. },
  198. show: showPalette,
  199. keybinds(enabled: boolean) {
  200. setSuspendCount((count) => count + (enabled ? -1 : 1))
  201. },
  202. suspended,
  203. get options() {
  204. return options()
  205. },
  206. }
  207. },
  208. })