introduce opentui keymap as sole key/cmd engine (#26053)
This commit is contained in:
163
packages/opencode/src/cli/cmd/tui/context/command-palette.tsx
Normal file
163
packages/opencode/src/cli/cmd/tui/context/command-palette.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { createContext, createMemo, createSignal, useContext, type Accessor, type ParentProps } from "solid-js"
|
||||
import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
|
||||
import { useDialog, type DialogContext } from "@tui/ui/dialog"
|
||||
import {
|
||||
formatKeyBindings,
|
||||
reactiveMatcherFromSignal,
|
||||
type OpenTuiKeymap,
|
||||
useKeymapSelector,
|
||||
useOpencodeKeymap,
|
||||
} from "../keymap"
|
||||
import { useTuiConfig } from "./tui-config"
|
||||
|
||||
type SlashEntry = {
|
||||
display: string
|
||||
description?: string
|
||||
aliases?: string[]
|
||||
onSelect: () => void
|
||||
}
|
||||
|
||||
type CommandPaletteContext = {
|
||||
run(command: string): void
|
||||
show(): void
|
||||
slashes: Accessor<readonly SlashEntry[]>
|
||||
suspend(enabled: boolean): void
|
||||
readonly suspended: boolean
|
||||
matcher: ReturnType<typeof reactiveMatcherFromSignal>
|
||||
}
|
||||
|
||||
const COMMAND_PALETTE_DIALOG = "command.palette.show"
|
||||
const ctx = createContext<CommandPaletteContext>()
|
||||
type PaletteCommandEntry = ReturnType<OpenTuiKeymap["getCommandEntries"]>[number]
|
||||
|
||||
function isVisiblePaletteCommand(entry: PaletteCommandEntry) {
|
||||
return entry.command.hidden !== true && entry.command.name !== COMMAND_PALETTE_DIALOG
|
||||
}
|
||||
|
||||
function isSuggestedPaletteCommand(entry: PaletteCommandEntry) {
|
||||
const suggested = entry.command.suggested
|
||||
if (typeof suggested === "boolean") return suggested
|
||||
if (typeof suggested === "function") return suggested() === true
|
||||
return false
|
||||
}
|
||||
|
||||
export function CommandPaletteProvider(props: ParentProps) {
|
||||
const dialog = useDialog()
|
||||
const keymap = useOpencodeKeymap()
|
||||
const [suspendCount, setSuspendCount] = createSignal(0)
|
||||
const entries = useKeymapSelector((keymap: OpenTuiKeymap) =>
|
||||
keymap
|
||||
.getCommandEntries({
|
||||
visibility: "reachable",
|
||||
namespace: "palette",
|
||||
})
|
||||
.filter(isVisiblePaletteCommand),
|
||||
)
|
||||
|
||||
const run = (command: string) => {
|
||||
keymap.dispatchCommand(command)
|
||||
}
|
||||
|
||||
const slashes = createMemo<SlashEntry[]>(() =>
|
||||
entries().flatMap((entry) => {
|
||||
const slashName = entry.command.slashName
|
||||
if (typeof slashName !== "string" || !slashName) return []
|
||||
const slashAliases = entry.command.slashAliases
|
||||
return {
|
||||
display: `/${slashName}`,
|
||||
description:
|
||||
typeof entry.command.desc === "string"
|
||||
? entry.command.desc
|
||||
: typeof entry.command.title === "string"
|
||||
? entry.command.title
|
||||
: undefined,
|
||||
aliases: Array.isArray(slashAliases)
|
||||
? slashAliases.filter((alias): alias is string => typeof alias === "string").map((alias) => `/${alias}`)
|
||||
: undefined,
|
||||
onSelect: () => run(entry.command.name),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const value: CommandPaletteContext = {
|
||||
run,
|
||||
show() {
|
||||
dialog.replace(() => <CommandPaletteDialog run={run} />)
|
||||
},
|
||||
slashes,
|
||||
suspend(enabled: boolean) {
|
||||
setSuspendCount((count) => Math.max(0, count + (enabled ? 1 : -1)))
|
||||
},
|
||||
get suspended() {
|
||||
return suspendCount() > 0 || dialog.stack.length > 0
|
||||
},
|
||||
matcher: reactiveMatcherFromSignal(() => suspendCount() === 0 && dialog.stack.length === 0),
|
||||
}
|
||||
|
||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
|
||||
}
|
||||
|
||||
export function useCommandPalette() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) throw new Error("CommandPalette context must be used within a CommandPaletteProvider")
|
||||
return value
|
||||
}
|
||||
|
||||
function CommandPaletteDialog(props: { run(command: string): void }) {
|
||||
const config = useTuiConfig()
|
||||
const entries = useKeymapSelector((keymap: OpenTuiKeymap) => {
|
||||
const query = {
|
||||
namespace: "palette",
|
||||
}
|
||||
const reachable = keymap
|
||||
.getCommandEntries({
|
||||
...query,
|
||||
visibility: "reachable",
|
||||
})
|
||||
.filter(isVisiblePaletteCommand)
|
||||
const registeredBindings = keymap.getCommandBindings({
|
||||
visibility: "registered",
|
||||
commands: reachable.map((entry) => entry.command.name),
|
||||
})
|
||||
|
||||
return reachable.map((entry) => ({
|
||||
...entry,
|
||||
bindings: registeredBindings.get(entry.command.name) ?? entry.bindings,
|
||||
}))
|
||||
})
|
||||
const options = createMemo(() =>
|
||||
entries().map((entry) => ({
|
||||
title: typeof entry.command.title === "string" ? entry.command.title : entry.command.name,
|
||||
description: typeof entry.command.desc === "string" ? entry.command.desc : undefined,
|
||||
category: typeof entry.command.category === "string" ? entry.command.category : undefined,
|
||||
footer: formatKeyBindings(entry.bindings, config),
|
||||
value: entry.command.name,
|
||||
suggested: isSuggestedPaletteCommand(entry),
|
||||
onSelect: (dialog: DialogContext) => {
|
||||
dialog.clear()
|
||||
props.run(entry.command.name)
|
||||
},
|
||||
})),
|
||||
)
|
||||
|
||||
let ref: DialogSelectRef<string>
|
||||
const list = () => {
|
||||
if (ref?.filter) return options()
|
||||
return [
|
||||
...options()
|
||||
.filter((option) => option.suggested)
|
||||
.map((option) => ({
|
||||
...option,
|
||||
value: `suggested:${option.value}`,
|
||||
category: "Suggested",
|
||||
})),
|
||||
...options(),
|
||||
]
|
||||
}
|
||||
|
||||
return <DialogSelect ref={(value) => (ref = value)} title="Commands" options={list()} />
|
||||
}
|
||||
|
||||
export function useCommandSlashes(): Accessor<readonly SlashEntry[]> {
|
||||
return useCommandPalette().slashes
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { Keybind } from "@/util/keybind"
|
||||
import { pipe, mapValues } from "remeda"
|
||||
import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||
import type { ParsedKey, Renderable } from "@opentui/core"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useKeyboard, useRenderer } from "@opentui/solid"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useTuiConfig } from "./tui-config"
|
||||
|
||||
export type KeybindKey = keyof NonNullable<TuiConfig.Info["keybinds"]> & string
|
||||
|
||||
export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({
|
||||
name: "Keybind",
|
||||
init: () => {
|
||||
const config = useTuiConfig()
|
||||
const keybinds = createMemo<Record<string, Keybind.Info[]>>(() => {
|
||||
return pipe(
|
||||
(config.keybinds ?? {}) as Record<string, string>,
|
||||
mapValues((value) => Keybind.parse(value)),
|
||||
)
|
||||
})
|
||||
const [store, setStore] = createStore({
|
||||
leader: false,
|
||||
})
|
||||
const renderer = useRenderer()
|
||||
|
||||
let focus: Renderable | null
|
||||
let timeout: NodeJS.Timeout
|
||||
function leader(active: boolean) {
|
||||
if (active) {
|
||||
setStore("leader", true)
|
||||
focus = renderer.currentFocusedRenderable
|
||||
focus?.blur()
|
||||
if (timeout) clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
if (!store.leader) return
|
||||
leader(false)
|
||||
if (!focus || focus.isDestroyed) return
|
||||
focus.focus()
|
||||
}, 2000)
|
||||
return
|
||||
}
|
||||
|
||||
if (!active) {
|
||||
if (focus && !renderer.currentFocusedRenderable) {
|
||||
focus.focus()
|
||||
}
|
||||
setStore("leader", false)
|
||||
}
|
||||
}
|
||||
|
||||
useKeyboard(async (evt) => {
|
||||
if (!store.leader && result.match("leader", evt)) {
|
||||
leader(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (store.leader && evt.name) {
|
||||
setImmediate(() => {
|
||||
if (focus && renderer.currentFocusedRenderable === focus) {
|
||||
focus.focus()
|
||||
}
|
||||
leader(false)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const result = {
|
||||
get all() {
|
||||
return keybinds()
|
||||
},
|
||||
get leader() {
|
||||
return store.leader
|
||||
},
|
||||
parse(evt: ParsedKey): Keybind.Info {
|
||||
// Handle special case for Ctrl+Underscore (represented as \x1F)
|
||||
if (evt.name === "\x1F") {
|
||||
return Keybind.fromParsedKey({ ...evt, name: "_", ctrl: true }, store.leader)
|
||||
}
|
||||
return Keybind.fromParsedKey(evt, store.leader)
|
||||
},
|
||||
match(key: string, evt: ParsedKey) {
|
||||
const list = keybinds()[key] ?? Keybind.parse(key)
|
||||
if (!list.length) return false
|
||||
const parsed: Keybind.Info = result.parse(evt)
|
||||
for (const item of list) {
|
||||
if (Keybind.match(item, parsed)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
print(key: string) {
|
||||
const first = keybinds()[key]?.at(0) ?? Keybind.parse(key).at(0)
|
||||
if (!first) return ""
|
||||
const text = Keybind.toString(first)
|
||||
const lead = keybinds().leader?.[0]
|
||||
if (!lead) return text
|
||||
return text.replace("<leader>", Keybind.toString(lead))
|
||||
},
|
||||
}
|
||||
return result
|
||||
},
|
||||
})
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { ParsedKey } from "@opentui/core"
|
||||
|
||||
export type PluginKeybindMap = Record<string, string>
|
||||
|
||||
type Base = {
|
||||
match: (key: string, evt: ParsedKey) => boolean
|
||||
print: (key: string) => string
|
||||
}
|
||||
|
||||
export type PluginKeybind = {
|
||||
readonly all: PluginKeybindMap
|
||||
get: (name: string) => string
|
||||
match: (name: string, evt: ParsedKey) => boolean
|
||||
print: (name: string) => string
|
||||
}
|
||||
|
||||
const txt = (value: unknown) => {
|
||||
if (typeof value !== "string") return
|
||||
if (!value.trim()) return
|
||||
return value
|
||||
}
|
||||
|
||||
export function createPluginKeybind(
|
||||
base: Base,
|
||||
defaults: PluginKeybindMap,
|
||||
overrides?: Record<string, unknown>,
|
||||
): PluginKeybind {
|
||||
const all = Object.freeze(
|
||||
Object.fromEntries(Object.entries(defaults).map(([name, value]) => [name, txt(overrides?.[name]) ?? value])),
|
||||
)
|
||||
const get = (name: string) => all[name] ?? name
|
||||
|
||||
return {
|
||||
get all() {
|
||||
return all
|
||||
},
|
||||
get,
|
||||
match: (name, evt) => base.match(get(name), evt),
|
||||
print: (name) => base.print(get(name)),
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { createSimpleContext } from "./helper"
|
||||
|
||||
export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({
|
||||
name: "TuiConfig",
|
||||
init: (props: { config: TuiConfig.Info }) => {
|
||||
init: (props: { config: TuiConfig.Resolved }) => {
|
||||
return props.config
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user