introduce opentui keymap as sole key/cmd engine (#26053)

This commit is contained in:
Sebastian
2026-05-07 20:35:31 +02:00
committed by GitHub
parent c4f7b8f32a
commit 4785e66cc6
67 changed files with 3858 additions and 2977 deletions
@@ -1,15 +1,12 @@
import type { ParsedKey } from "@opentui/core"
import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui"
import type { useCommandDialog } from "@tui/component/dialog-command"
import type { useEvent } from "@tui/context/event"
import type { useKeybind } from "@tui/context/keybind"
import type { useRoute } from "@tui/context/route"
import type { useSDK } from "@tui/context/sdk"
import type { useSync } from "@tui/context/sync"
import type { useTheme } from "@tui/context/theme"
import { Dialog as DialogUI, type useDialog } from "@tui/ui/dialog"
import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { createPluginKeybind } from "../context/plugin-keybinds"
import type { useOpencodeKeymap } from "../keymap"
import type { useKV } from "../context/kv"
import { DialogAlert } from "../ui/dialog-alert"
import { DialogConfirm } from "../ui/dialog-confirm"
@@ -19,6 +16,7 @@ import { Prompt } from "../component/prompt"
import { Slot as HostSlot } from "./slots"
import type { useToast } from "../ui/toast"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import * as Keymap from "../keymap"
type RouteEntry = {
key: symbol
@@ -28,10 +26,9 @@ type RouteEntry = {
export type RouteMap = Map<string, RouteEntry[]>
type Input = {
command: ReturnType<typeof useCommandDialog>
tuiConfig: TuiConfig.Info
tuiConfig: TuiConfig.Resolved
dialog: ReturnType<typeof useDialog>
keybind: ReturnType<typeof useKeybind>
keymap: ReturnType<typeof useOpencodeKeymap>
kv: ReturnType<typeof useKV>
route: ReturnType<typeof useRoute>
routes: RouteMap
@@ -201,20 +198,17 @@ export function createTuiApi(input: Input): TuiPluginApi {
return () => {}
},
}
return {
app: appApi(),
command: {
register(cb) {
return input.command.register(() => cb())
keys: {
formatSequence(parts) {
return Keymap.formatKeySequence(parts, input.tuiConfig)
},
trigger(value) {
input.command.trigger(value)
},
show() {
input.command.show()
formatBindings(bindings) {
return Keymap.formatKeyBindings(bindings, input.tuiConfig)
},
},
keymap: input.keymap,
route: {
register(list) {
return routeRegister(input.routes, list, input.bump)
@@ -306,17 +300,6 @@ export function createTuiApi(input: Input): TuiPluginApi {
},
},
},
keybind: {
match(key, evt: ParsedKey) {
return input.keybind.match(key, evt)
},
print(key) {
return input.keybind.print(key)
},
create(defaults, overrides) {
return createPluginKeybind(input.keybind, defaults, overrides)
},
},
get tuiConfig() {
return input.tuiConfig
},
@@ -1,4 +1,5 @@
import "@opentui/solid/runtime-plugin-support"
import { runtimeModules as keymapRuntimeModules } from "@opentui/keymap/runtime-modules"
import { ensureRuntimePluginSupport } from "@opentui/solid/runtime-plugin-support/configure"
import {
type TuiDispose,
type TuiPlugin,
@@ -39,6 +40,8 @@ import { setupSlots, Slot as View } from "./slots"
import type { HostPluginApi, HostSlots } from "./slots"
import { ConfigPlugin } from "@/config/plugin"
ensureRuntimePluginSupport({ additional: keymapRuntimeModules })
type PluginLoad = {
options: ConfigPlugin.Options | undefined
spec: string
@@ -70,6 +73,36 @@ type PluginEntry = {
scope?: PluginScope
}
const ScopedKeymapMethods = new Set<PropertyKey>([
"acquireResource",
"registerLayer",
"registerLayerFields",
"prependLayerBindingsTransformer",
"appendLayerBindingsTransformer",
"prependBindingTransformer",
"appendBindingTransformer",
"prependBindingParser",
"appendBindingParser",
"registerToken",
"registerSequencePattern",
"prependBindingExpander",
"appendBindingExpander",
"registerBindingFields",
"registerCommandFields",
"prependCommandTransformer",
"appendCommandTransformer",
"prependCommandResolver",
"appendCommandResolver",
"prependLayerAnalyzer",
"appendLayerAnalyzer",
"intercept",
"on",
"prependEventMatchResolver",
"appendEventMatchResolver",
"prependDisambiguationResolver",
"appendDisambiguationResolver",
])
type RuntimeState = {
directory: string
api: Api
@@ -104,6 +137,25 @@ function warn(message: string, data: Record<string, unknown>) {
console.warn(`[tui.plugin] ${message}`, data)
}
function createScopedKeymap(keymap: TuiPluginApi["keymap"], scope: PluginScope): TuiPluginApi["keymap"] {
const cache = new Map<PropertyKey, unknown>()
return new Proxy(keymap, {
get(target, prop) {
const value = Reflect.get(target, prop, target)
if (typeof value !== "function") return value
if (cache.has(prop)) return cache.get(prop)
const fn = ScopedKeymapMethods.has(prop)
? (...args: unknown[]) => {
const dispose = (value as (...args: unknown[]) => unknown).apply(target, args)
return scope.track(typeof dispose === "function" ? (dispose as () => void) : undefined)
}
: (...args: unknown[]) => (value as (...args: unknown[]) => unknown).apply(target, args)
cache.set(prop, fn)
return fn
},
})
}
type CleanupResult = { type: "ok" } | { type: "error"; error: unknown } | { type: "timeout" }
function runCleanup(fn: () => unknown, ms: number): Promise<CleanupResult> {
@@ -327,14 +379,16 @@ function createPluginScope(load: PluginLoad, id: string) {
const track = (fn: (() => void) | undefined) => {
if (!fn) return () => {}
const off = onDispose(fn)
let drop = false
return () => {
let off = () => {}
const wrapped = () => {
if (drop) return
drop = true
off()
fn()
}
off = onDispose(wrapped)
return wrapped
}
const lifecycle: TuiPluginApi["lifecycle"] = {
@@ -395,7 +449,7 @@ function readPluginEnabledMap(value: unknown) {
)
}
function pluginEnabledState(state: RuntimeState, config: TuiConfig.Info) {
function pluginEnabledState(state: RuntimeState, config: TuiConfig.Resolved) {
return {
...readPluginEnabledMap(config.plugin_enabled),
...readPluginEnabledMap(state.api.kv.get(KV_KEY, {})),
@@ -484,17 +538,6 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
const api = runtime.api
const host = runtime.slots
const load = plugin.load
const command: TuiPluginApi["command"] = {
register(cb) {
return scope.track(api.command.register(cb))
},
trigger(value) {
api.command.trigger(value)
},
show() {
api.command.show()
},
}
const route: TuiPluginApi["route"] = {
register(list) {
@@ -518,6 +561,8 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
},
}
const keymap = createScopedKeymap(api.keymap, scope)
let count = 0
const slots: TuiPluginApi["slots"] = {
@@ -531,10 +576,10 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
return {
app: api.app,
command,
keys: api.keys,
keymap,
route,
ui: api.ui,
keybind: api.keybind,
tuiConfig: api.tuiConfig,
kv: api.kv,
state: api.state,
@@ -580,7 +625,7 @@ function addPluginEntry(state: RuntimeState, plugin: PluginEntry) {
return true
}
function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.Info) {
function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.Resolved) {
const map = pluginEnabledState(state, config)
for (const plugin of state.plugins) {
const enabled = map[plugin.id]
@@ -923,7 +968,7 @@ let loaded: Promise<void> | undefined
let runtime: RuntimeState | undefined
export const Slot = View
export async function init(input: { api: HostPluginApi; config: TuiConfig.Info }) {
export async function init(input: { api: HostPluginApi; config: TuiConfig.Resolved }) {
const cwd = process.cwd()
if (loaded) {
if (dir !== cwd) {
@@ -972,7 +1017,7 @@ export async function dispose() {
}
}
async function load(input: { api: Api; config: TuiConfig.Info }) {
async function load(input: { api: Api; config: TuiConfig.Resolved }) {
const { api, config } = input
const cwd = process.cwd()
const slots = setupSlots(api)