cloudaxe-opencode/packages/app/src/context/command.tsx

445 lines
13 KiB
TypeScript
Raw Permalink Normal View History

2025-12-15 10:09:57 +00:00
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"
2026-01-20 11:40:44 +00:00
import { useLanguage } from "@/context/language"
2026-01-07 12:54:48 +00:00
import { useSettings } from "@/context/settings"
import { dict as en } from "@/i18n/en"
2026-01-20 13:10:40 +00:00
import { Persist, persisted } from "@/utils/persist"
2025-12-15 10:09:57 +00:00
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
2026-01-07 12:54:48 +00:00
const PALETTE_ID = "command.palette"
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
const SUGGESTED_PREFIX = "suggested."
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"])
2026-01-07 12:54:48 +00:00
2026-03-13 11:48:38 +00:00
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]
}
2026-01-07 12:54:48 +00:00
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()
}
2026-01-26 03:45:44 +00:00
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))
}
2025-12-15 10:09:57 +00:00
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
2025-12-15 10:09:57 +00:00
onSelect?: (source?: "palette" | "keybind" | "slash") => void
2025-12-29 01:26:46 +00:00
onHighlight?: () => (() => void) | void
2025-12-15 10:09:57 +00:00
}
type CommandSource = "palette" | "keybind" | "slash"
2026-01-20 13:10:40 +00:00
export type CommandCatalogItem = {
title: string
description?: string
category?: string
keybind?: KeybindConfig
slash?: string
hidden?: boolean
2026-01-20 13:10:40 +00:00
}
2026-02-06 11:51:01 +00:00
export type CommandRegistration = {
key?: string
options: Accessor<CommandOption[]>
}
export function upsertCommandRegistration(registrations: CommandRegistration[], entry: CommandRegistration) {
if (entry.key === undefined) return [entry, ...registrations]
return [entry, ...registrations.filter((x) => x.key !== entry.key)]
}
2025-12-15 10:09:57 +00:00
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 {
2026-01-07 12:54:48 +00:00
const eventKey = normalizeKey(event.key)
2025-12-15 10:09:57 +00:00
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) {
2025-12-15 10:09:57 +00:00
const parts: string[] = []
2026-03-13 11:48:38 +00:00
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))
2025-12-15 10:09:57 +00:00
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",
2025-12-15 10:09:57 +00:00
}
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) : []
}
2025-12-15 10:09:57 +00:00
export function formatKeybind(config: string, t?: (key: KeyLabel) => string): string {
const parts = formatKeybindParts(config, t)
if (parts.length === 0) return ""
2025-12-15 10:09:57 +00:00
return IS_MAC ? parts.join("") : parts.join("+")
}
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
}
2025-12-15 10:09:57 +00:00
export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
name: "Command",
init: () => {
const dialog = useDialog()
2026-01-07 12:54:48 +00:00
const settings = useSettings()
2026-01-20 11:40:44 +00:00
const language = useLanguage()
2026-01-26 16:04:59 +00:00
const [store, setStore] = createStore({
2026-02-06 11:51:01 +00:00
registrations: [] as CommandRegistration[],
2026-01-26 16:04:59 +00:00
suspendCount: 0,
})
2026-02-06 11:51:01 +00:00
const warnedDuplicates = new Set<string>()
2025-12-15 10:09:57 +00:00
type CommandCatalog = Record<string, CommandCatalogItem>
2026-01-20 13:10:40 +00:00
const [catalog, setCatalog, _, catalogReady] = persisted(
Persist.global("command.catalog.v1"),
createStore<CommandCatalog>({}),
2026-01-20 13:10:40 +00:00
)
2026-01-07 12:54:48 +00:00
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
}
2026-01-20 13:10:40 +00:00
const registered = createMemo(() => {
const seen = new Set<string>()
const all: CommandOption[] = []
2026-01-26 16:04:59 +00:00
for (const reg of store.registrations) {
2026-02-06 11:51:01 +00:00
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`)
2026-02-06 11:51:01 +00:00
}
continue
}
seen.add(opt.id)
all.push(opt)
}
}
2026-01-20 13:10:40 +00:00
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),
)
2026-01-20 13:10:40 +00:00
})
const catalogOptions = createMemo(() => Object.entries(catalog).map(([id, meta]) => ({ id, ...meta })))
const options = createMemo(() => {
const resolved = registered().map((opt) => ({
2026-01-07 12:54:48 +00:00
...opt,
keybind: bind(opt.id, opt.keybind),
}))
const suggested = resolved.filter((x) => x.suggested && !x.disabled)
2025-12-15 10:09:57 +00:00
return [
...suggested.map((x) => ({
...x,
2026-01-07 12:54:48 +00:00
id: SUGGESTED_PREFIX + x.id,
2026-01-20 11:40:44 +00:00
category: language.t("command.category.suggested"),
2025-12-15 10:09:57 +00:00
})),
2026-01-07 12:54:48 +00:00
...resolved,
2025-12-15 10:09:57 +00:00
]
})
2026-01-26 16:04:59 +00:00
const suspended = () => store.suspendCount > 0
2025-12-15 10:09:57 +00:00
2026-01-26 03:45:44 +00:00
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)
if (map.has(sig)) 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)
2025-12-15 10:09:57 +00:00
}
return map
})
const run = (id: string, source?: CommandSource) => {
const option = optionMap().get(id)
option?.onSelect?.(source)
2025-12-15 10:09:57 +00:00
}
const showPalette = () => {
run("file.open", "palette")
}
2025-12-15 10:09:57 +00:00
const handleKeyDown = (event: KeyboardEvent) => {
if (suspended() || dialog.active) return
2025-12-15 10:09:57 +00:00
2026-01-26 03:45:44 +00:00
const sig = signatureFromEvent(event)
const isPalette = palette().has(sig)
const option = keymap().get(sig)
2026-02-12 20:39:02 +00:00
const modified = event.ctrlKey || event.metaKey || event.altKey
2026-02-15 13:46:56 +00:00
const isTab = event.key === "Tab"
2026-02-15 13:46:56 +00:00
if (isEditableTarget(event.target) && !isPalette && !isAllowedEditableKeybind(option?.id) && !modified && !isTab)
return
2026-01-26 03:45:44 +00:00
if (isPalette) {
2025-12-15 10:09:57 +00:00
event.preventDefault()
showPalette()
return
}
2026-01-26 03:45:44 +00:00
if (!option) return
event.preventDefault()
option.onSelect?.("keybind")
2025-12-15 10:09:57 +00:00
}
onMount(() => {
makeEventListener(document, "keydown", handleKeyDown)
2025-12-15 10:09:57 +00:00
})
2026-02-06 11:51:01 +00:00
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) => upsertCommandRegistration(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)
}
2025-12-15 10:09:57 +00:00
return {
2026-02-06 11:51:01 +00:00
register,
trigger(id: string, source?: CommandSource) {
run(id, source)
2025-12-15 10:09:57 +00:00
},
2025-12-21 10:56:20 +00:00
keybind(id: string) {
const config = keybindConfig(id)
2026-01-20 13:10:40 +00:00
if (!config) return ""
2026-03-13 11:48:38 +00:00
return formatKeybind(config, language.t)
2025-12-21 10:56:20 +00:00
},
keybindParts(id: string) {
const config = keybindConfig(id)
return config ? formatKeybindParts(config, language.t) : []
},
2025-12-15 10:09:57 +00:00
show: showPalette,
keybinds(enabled: boolean) {
setStore("suspendCount", (count) => Math.max(0, count + (enabled ? -1 : 1)))
2025-12-15 10:09:57 +00:00
},
suspended,
2026-01-20 13:10:40 +00:00
get catalog() {
return catalogOptions()
},
2025-12-15 10:09:57 +00:00
get options() {
return options()
},
}
},
})