| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476 |
- import { createSimpleContext } from "@opencode-ai/ui/context"
- import { useDialog } from "@opencode-ai/ui/context/dialog"
- import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js"
- import { createStore } from "solid-js/store"
- import { makeEventListener } from "@solid-primitives/event-listener"
- import { useLanguage } from "@/context/language"
- import { useSettings } from "@/context/settings"
- import { dict as en } from "@/i18n/en"
- import { Persist, persisted } from "@/utils/persist"
- const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
- const PALETTE_ID = "command.palette"
- export const DEFAULT_PALETTE_KEYBIND = "mod+k,mod+shift+p"
- const SUGGESTED_PREFIX = "suggested."
- const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"])
- type KeyLabel =
- | "common.key.ctrl"
- | "common.key.alt"
- | "common.key.shift"
- | "common.key.meta"
- | "common.key.space"
- | "common.key.backspace"
- | "common.key.enter"
- | "common.key.tab"
- | "common.key.delete"
- | "common.key.home"
- | "common.key.end"
- | "common.key.pageUp"
- | "common.key.pageDown"
- | "common.key.insert"
- | "common.key.esc"
- function keyText(key: KeyLabel, t?: (key: KeyLabel) => string) {
- return t ? t(key) : en[key]
- }
- function actionId(id: string) {
- if (!id.startsWith(SUGGESTED_PREFIX)) return id
- return id.slice(SUGGESTED_PREFIX.length)
- }
- function normalizeKey(key: string) {
- if (key === ",") return "comma"
- if (key === "+") return "plus"
- if (key === " ") return "space"
- return key.toLowerCase()
- }
- function signature(key: string, ctrl: boolean, meta: boolean, shift: boolean, alt: boolean) {
- const mask = (ctrl ? 1 : 0) | (meta ? 2 : 0) | (shift ? 4 : 0) | (alt ? 8 : 0)
- return `${key}:${mask}`
- }
- function signatureFromEvent(event: KeyboardEvent) {
- return signature(normalizeKey(event.key), event.ctrlKey, event.metaKey, event.shiftKey, event.altKey)
- }
- function isAllowedEditableKeybind(id: string | undefined) {
- if (!id) return false
- return EDITABLE_KEYBIND_IDS.has(actionId(id))
- }
- 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
- hidden?: boolean
- when?: (event: KeyboardEvent) => boolean
- onSelect?: (source?: "palette" | "keybind" | "slash") => void
- onHighlight?: () => (() => void) | void
- }
- export function commandPaletteOptions(options: CommandOption[]) {
- return options.filter(
- (option) =>
- !option.disabled && !option.hidden && !option.id.startsWith(SUGGESTED_PREFIX) && option.id !== "file.open",
- )
- }
- export function resolveKeybindOption(candidates: CommandOption[] | undefined, event: KeyboardEvent) {
- return candidates?.find((option) => option.when?.(event)) ?? candidates?.find((option) => !option.when)
- }
- type CommandSource = "palette" | "keybind" | "slash"
- export type CommandCatalogItem = {
- title: string
- description?: string
- category?: string
- keybind?: KeybindConfig
- slash?: string
- hidden?: boolean
- }
- export type CommandRegistration = {
- key?: string
- options: Accessor<CommandOption[]>
- }
- export function addCommandRegistration(registrations: CommandRegistration[], entry: CommandRegistration) {
- return [entry, ...registrations]
- }
- export function activeCommandRegistrations(registrations: CommandRegistration[]) {
- const keys = new Set<string>()
- return registrations.filter((entry) => {
- if (entry.key === undefined) return true
- if (keys.has(entry.key)) return false
- keys.add(entry.key)
- return true
- })
- }
- 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 = normalizeKey(event.key)
- 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
- }
- function displayKeybindParts(kb: Keybind, t?: (key: KeyLabel) => string) {
- const parts: string[] = []
- if (kb.ctrl) parts.push(IS_MAC ? "⌃" : keyText("common.key.ctrl", t))
- if (kb.alt) parts.push(IS_MAC ? "⌥" : keyText("common.key.alt", t))
- if (kb.shift) parts.push(IS_MAC ? "⇧" : keyText("common.key.shift", t))
- if (kb.meta) parts.push(IS_MAC ? "⌘" : keyText("common.key.meta", t))
- if (!kb.key) return parts
- const keys: Record<string, string> = {
- arrowup: "↑",
- arrowdown: "↓",
- arrowleft: "←",
- arrowright: "→",
- comma: ",",
- plus: "+",
- }
- const named: Record<string, KeyLabel> = {
- backspace: "common.key.backspace",
- delete: "common.key.delete",
- end: "common.key.end",
- enter: "common.key.enter",
- esc: "common.key.esc",
- escape: "common.key.esc",
- home: "common.key.home",
- insert: "common.key.insert",
- pagedown: "common.key.pageDown",
- pageup: "common.key.pageUp",
- space: "common.key.space",
- tab: "common.key.tab",
- }
- const key = kb.key.toLowerCase()
- const displayKey =
- keys[key] ??
- (named[key]
- ? keyText(named[key], t)
- : key.length === 1
- ? key.toUpperCase()
- : key.charAt(0).toUpperCase() + key.slice(1))
- parts.push(displayKey)
- return parts
- }
- export function formatKeybindParts(config: string, t?: (key: KeyLabel) => string): string[] {
- if (!config || config === "none") return []
- const keybind = parseKeybind(config)[0]
- return keybind ? displayKeybindParts(keybind, t) : []
- }
- export function formatKeybind(config: string, t?: (key: KeyLabel) => string): string {
- const parts = formatKeybindParts(config, t)
- if (parts.length === 0) return ""
- return IS_MAC ? parts.join("") : parts.join("+")
- }
- // KeybindV2 takes an array instead of a string
- export function formatKeybindKeys(config: string, t?: (key: KeyLabel) => string): string[] {
- return formatKeybindParts(config, t)
- }
- function isEditableTarget(target: EventTarget | null) {
- if (!(target instanceof HTMLElement)) return false
- if (target.isContentEditable) return true
- if (target.closest("[contenteditable='true']")) return true
- if (target.closest("input, textarea, select")) return true
- return false
- }
- export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
- name: "Command",
- init: () => {
- const dialog = useDialog()
- const settings = useSettings()
- const language = useLanguage()
- const [store, setStore] = createStore({
- registrations: [] as CommandRegistration[],
- suspendCount: 0,
- })
- const warnedDuplicates = new Set<string>()
- type CommandCatalog = Record<string, CommandCatalogItem>
- const [catalog, setCatalog, _, catalogReady] = persisted(
- Persist.global("command.catalog.v1"),
- createStore<CommandCatalog>({}),
- )
- const bind = (id: string, def: KeybindConfig | undefined) => {
- const custom = settings.keybinds.get(actionId(id))
- const config = custom ?? def
- if (!config || config === "none") return
- return config
- }
- const registered = createMemo(() => {
- const seen = new Set<string>()
- const all: CommandOption[] = []
- for (const reg of activeCommandRegistrations(store.registrations)) {
- for (const opt of reg.options()) {
- if (seen.has(opt.id)) {
- if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) {
- warnedDuplicates.add(opt.id)
- console.warn(`[command] duplicate command id "${opt.id}" registered; keeping first entry`)
- }
- continue
- }
- seen.add(opt.id)
- all.push(opt)
- }
- }
- return all
- })
- createEffect(() => {
- if (!catalogReady()) return
- setCatalog(
- registered().reduce((acc, opt) => {
- const id = actionId(opt.id)
- if (opt.title)
- acc[id] = {
- title: opt.title,
- description: opt.description,
- category: opt.category,
- keybind: opt.keybind,
- slash: opt.slash,
- }
- return acc
- }, {} as CommandCatalog),
- )
- })
- const catalogOptions = createMemo(() => Object.entries(catalog).map(([id, meta]) => ({ id, ...meta })))
- const options = createMemo(() => {
- const resolved = registered().map((opt) => ({
- ...opt,
- keybind: bind(opt.id, opt.keybind),
- }))
- const suggested = resolved.filter((x) => x.suggested && !x.disabled)
- return [
- ...suggested.map((x) => ({
- ...x,
- id: SUGGESTED_PREFIX + x.id,
- category: language.t("command.category.suggested"),
- })),
- ...resolved,
- ]
- })
- const suspended = () => store.suspendCount > 0
- const palette = createMemo(() => {
- const config = settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND
- const keybinds = parseKeybind(config)
- return new Set(keybinds.map((kb) => signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt)))
- })
- const keymap = createMemo(() => {
- const map = new Map<string, CommandOption[]>()
- for (const option of options()) {
- if (option.id.startsWith(SUGGESTED_PREFIX)) continue
- if (option.disabled) continue
- if (!option.keybind) continue
- const keybinds = parseKeybind(option.keybind)
- for (const kb of keybinds) {
- if (!kb.key) continue
- const sig = signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt)
- const existing = map.get(sig)
- if (existing) {
- existing.push(option)
- continue
- }
- map.set(sig, [option])
- }
- }
- return map
- })
- const optionMap = createMemo(() => {
- const map = new Map<string, CommandOption>()
- for (const option of options()) {
- map.set(option.id, option)
- map.set(actionId(option.id), option)
- }
- return map
- })
- const run = (id: string, source?: CommandSource) => {
- const option = optionMap().get(id)
- option?.onSelect?.(source)
- }
- const showPalette = () => {
- run(PALETTE_ID, "palette")
- }
- const handleKeyDown = (event: KeyboardEvent) => {
- if (suspended() || dialog.active) return
- const sig = signatureFromEvent(event)
- const isPalette = palette().has(sig)
- const option = resolveKeybindOption(keymap().get(sig), event)
- const modified = event.ctrlKey || event.metaKey || event.altKey
- const isTab = event.key === "Tab"
- if (isEditableTarget(event.target) && !isPalette && !isAllowedEditableKeybind(option?.id) && !modified && !isTab)
- return
- if (isPalette) {
- event.preventDefault()
- event.stopPropagation()
- showPalette()
- return
- }
- if (!option) return
- event.preventDefault()
- event.stopPropagation()
- option.onSelect?.("keybind")
- }
- onMount(() => {
- makeEventListener(document, "keydown", handleKeyDown, { capture: true })
- })
- function register(cb: () => CommandOption[]): void
- function register(key: string, cb: () => CommandOption[]): void
- function register(key: string | (() => CommandOption[]), cb?: () => CommandOption[]) {
- const id = typeof key === "string" ? key : undefined
- const next = typeof key === "function" ? key : cb
- if (!next) return
- const options = createMemo(next)
- const entry: CommandRegistration = {
- key: id,
- options,
- }
- setStore("registrations", (arr) => addCommandRegistration(arr, entry))
- onCleanup(() => {
- setStore("registrations", (arr) => arr.filter((x) => x !== entry))
- })
- }
- const keybindConfig = (id: string) => {
- if (id === PALETTE_ID) return settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND
- const base = actionId(id)
- return options().find((x) => actionId(x.id) === base)?.keybind ?? bind(base, catalog[base]?.keybind)
- }
- return {
- register,
- trigger(id: string, source?: CommandSource) {
- run(id, source)
- },
- keybind(id: string) {
- const config = keybindConfig(id)
- if (!config) return ""
- return formatKeybind(config, language.t)
- },
- keybindParts(id: string) {
- const config = keybindConfig(id)
- return config ? formatKeybindParts(config, language.t) : []
- },
- show: showPalette,
- keybinds(enabled: boolean) {
- setStore("suspendCount", (count) => Math.max(0, count + (enabled ? -1 : 1)))
- },
- suspended,
- get catalog() {
- return catalogOptions()
- },
- get options() {
- return options()
- },
- }
- },
- })
|