| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273 |
- import { createMemo, createSignal, onCleanup, onMount, Show, type Accessor } from "solid-js"
- import { createSimpleContext } from "@opencode-ai/ui/context"
- import { useDialog } from "@opencode-ai/ui/context/dialog"
- import { Dialog } from "@opencode-ai/ui/dialog"
- import { List } from "@opencode-ai/ui/list"
- const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
- export type KeybindConfig = string
- export interface Keybind {
- key: string
- ctrl: boolean
- meta: boolean
- shift: boolean
- alt: boolean
- }
- export interface CommandOption {
- id: string
- title: string
- description?: string
- category?: string
- keybind?: KeybindConfig
- slash?: string
- suggested?: boolean
- disabled?: boolean
- onSelect?: (source?: "palette" | "keybind" | "slash") => void
- onHighlight?: () => (() => void) | void
- }
- export function parseKeybind(config: string): Keybind[] {
- if (!config || config === "none") return []
- return config.split(",").map((combo) => {
- const parts = combo.trim().toLowerCase().split("+")
- const keybind: Keybind = {
- key: "",
- ctrl: false,
- meta: false,
- shift: false,
- alt: false,
- }
- for (const part of parts) {
- switch (part) {
- case "ctrl":
- case "control":
- keybind.ctrl = true
- break
- case "meta":
- case "cmd":
- case "command":
- keybind.meta = true
- break
- case "mod":
- if (IS_MAC) keybind.meta = true
- else keybind.ctrl = true
- break
- case "alt":
- case "option":
- keybind.alt = true
- break
- case "shift":
- keybind.shift = true
- break
- default:
- keybind.key = part
- break
- }
- }
- return keybind
- })
- }
- export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean {
- const eventKey = event.key.toLowerCase()
- for (const kb of keybinds) {
- const keyMatch = kb.key === eventKey
- const ctrlMatch = kb.ctrl === (event.ctrlKey || false)
- const metaMatch = kb.meta === (event.metaKey || false)
- const shiftMatch = kb.shift === (event.shiftKey || false)
- const altMatch = kb.alt === (event.altKey || false)
- if (keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch) {
- return true
- }
- }
- return false
- }
- export function formatKeybind(config: string): string {
- if (!config || config === "none") return ""
- const keybinds = parseKeybind(config)
- if (keybinds.length === 0) return ""
- const kb = keybinds[0]
- const parts: string[] = []
- if (kb.ctrl) parts.push(IS_MAC ? "⌃" : "Ctrl")
- if (kb.alt) parts.push(IS_MAC ? "⌥" : "Alt")
- if (kb.shift) parts.push(IS_MAC ? "⇧" : "Shift")
- if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta")
- if (kb.key) {
- const displayKey = kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1)
- parts.push(displayKey)
- }
- return IS_MAC ? parts.join("") : parts.join("+")
- }
- function DialogCommand(props: { options: CommandOption[] }) {
- const dialog = useDialog()
- let cleanup: (() => void) | void
- let committed = false
- const handleMove = (option: CommandOption | undefined) => {
- cleanup?.()
- cleanup = option?.onHighlight?.()
- }
- const handleSelect = (option: CommandOption | undefined) => {
- if (option) {
- committed = true
- cleanup = undefined
- dialog.close()
- option.onSelect?.("palette")
- }
- }
- onCleanup(() => {
- if (!committed) {
- cleanup?.()
- }
- })
- return (
- <Dialog title="Commands">
- <List
- search={{ placeholder: "Search commands", autofocus: true }}
- emptyMessage="No commands found"
- items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)}
- key={(x) => x?.id}
- filterKeys={["title", "description", "category"]}
- groupBy={(x) => x.category ?? ""}
- onMove={handleMove}
- onSelect={handleSelect}
- >
- {(option) => (
- <div class="w-full flex items-center justify-between gap-4">
- <div class="flex items-center gap-2 min-w-0">
- <span class="text-14-regular text-text-strong whitespace-nowrap">{option.title}</span>
- <Show when={option.description}>
- <span class="text-14-regular text-text-weak truncate">{option.description}</span>
- </Show>
- </div>
- <Show when={option.keybind}>
- <span class="text-12-regular text-text-subtle shrink-0">{formatKeybind(option.keybind!)}</span>
- </Show>
- </div>
- )}
- </List>
- </Dialog>
- )
- }
- export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
- name: "Command",
- init: () => {
- const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
- const [suspendCount, setSuspendCount] = createSignal(0)
- const dialog = useDialog()
- const options = createMemo(() => {
- const seen = new Set<string>()
- const all: CommandOption[] = []
- for (const reg of registrations()) {
- for (const opt of reg()) {
- if (seen.has(opt.id)) continue
- seen.add(opt.id)
- all.push(opt)
- }
- }
- const suggested = all.filter((x) => x.suggested && !x.disabled)
- return [
- ...suggested.map((x) => ({
- ...x,
- id: "suggested." + x.id,
- category: "Suggested",
- })),
- ...all,
- ]
- })
- const suspended = () => suspendCount() > 0
- const showPalette = () => {
- if (!dialog.active) {
- dialog.show(() => <DialogCommand options={options().filter((x) => !x.disabled)} />)
- }
- }
- const handleKeyDown = (event: KeyboardEvent) => {
- if (suspended()) return
- const paletteKeybinds = parseKeybind("mod+shift+p")
- if (matchKeybind(paletteKeybinds, event)) {
- event.preventDefault()
- showPalette()
- return
- }
- for (const option of options()) {
- if (option.disabled) continue
- if (!option.keybind) continue
- const keybinds = parseKeybind(option.keybind)
- if (matchKeybind(keybinds, event)) {
- event.preventDefault()
- option.onSelect?.("keybind")
- return
- }
- }
- }
- onMount(() => {
- document.addEventListener("keydown", handleKeyDown)
- })
- onCleanup(() => {
- document.removeEventListener("keydown", handleKeyDown)
- })
- return {
- register(cb: () => CommandOption[]) {
- const results = createMemo(cb)
- setRegistrations((arr) => [results, ...arr])
- onCleanup(() => {
- setRegistrations((arr) => arr.filter((x) => x !== results))
- })
- },
- trigger(id: string, source?: "palette" | "keybind" | "slash") {
- for (const option of options()) {
- if (option.id === id || option.id === "suggested." + id) {
- option.onSelect?.(source)
- return
- }
- }
- },
- keybind(id: string) {
- const option = options().find((x) => x.id === id || x.id === "suggested." + id)
- if (!option?.keybind) return ""
- return formatKeybind(option.keybind)
- },
- show: showPalette,
- keybinds(enabled: boolean) {
- setSuspendCount((count) => count + (enabled ? -1 : 1))
- },
- suspended,
- get options() {
- return options()
- },
- }
- },
- })
|