Sebastian 4 месяцев назад
Родитель
Сommit
9a8b54fe62

+ 67 - 0
packages/opencode/specs/v2/tui-command-shim.md

@@ -0,0 +1,67 @@
+# TUI Command Shim Removal
+
+Problem:
+
+- v1 keeps a deprecated `api.command` TUI plugin shim so older plugins do not fail during initialization
+- v2 should expose only the keymap command API
+- tests and fixtures should not encode legacy command behavior as expected behavior
+
+## Remove Public Types
+
+In `packages/plugin/src/tui.ts`, remove:
+
+- `TuiCommand`
+- `TuiCommandApi`
+- `TuiPluginApi.command`
+
+Keep `api.keymap` as the only TUI command registration and execution surface.
+
+## Remove Runtime Shim
+
+Delete `packages/opencode/src/cli/cmd/tui/plugin/command-shim.ts`.
+
+In `packages/opencode/src/cli/cmd/tui/plugin/api.tsx`, remove:
+
+- the `createCommandShim` import
+- the `command: createCommandShim(...)` field from `createTuiApi(...)`
+
+In `packages/opencode/src/cli/cmd/tui/plugin/runtime.ts`, remove:
+
+- the `createCommandShim` import
+- the `command: createCommandShim(...)` field from `pluginApi(...)`
+
+## Migration Target
+
+Plugin authors should replace old calls with keymap calls:
+
+```ts
+api.keymap.registerLayer({
+  commands: [
+    {
+      name: "plugin.command",
+      title: "Plugin Command",
+      namespace: "palette",
+      slashName: "plugin",
+      run() {
+        api.ui.dialog.clear()
+      },
+    },
+  ],
+  bindings: [{ key: "ctrl+shift+p", cmd: "plugin.command" }],
+})
+```
+
+Direct replacements:
+
+- `api.command.register(cb)` -> `api.keymap.registerLayer({ commands, bindings })`
+- `api.command.trigger(name)` -> `api.keymap.dispatchCommand(name)`
+- `api.command.show()` -> `api.keymap.dispatchCommand("command.palette.show")`
+- `onSelect(dialog)` -> use `api.ui.dialog` from the plugin API closure
+
+## Verification
+
+After removal, run from package directories:
+
+- `bun typecheck` in `packages/plugin`
+- `bun typecheck` in `packages/opencode`
+- TUI plugin loader tests in `packages/opencode` if runtime plugin API wiring changed

+ 3 - 0
packages/opencode/src/cli/cmd/tui/plugin/api.tsx

@@ -17,6 +17,7 @@ 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"
+import { createCommandShim } from "./command-shim"
 
 type RouteEntry = {
   key: symbol
@@ -200,6 +201,8 @@ export function createTuiApi(input: Input): TuiPluginApi {
   }
   return {
     app: appApi(),
+    // Keep deprecated `api.command` working for v1 plugins; remove in v2.
+    command: createCommandShim(input.keymap, input.dialog, input.tuiConfig.keybinds),
     keys: {
       formatSequence(parts) {
         return Keymap.formatKeySequence(parts, input.tuiConfig)

+ 109 - 0
packages/opencode/src/cli/cmd/tui/plugin/command-shim.ts

@@ -0,0 +1,109 @@
+// Legacy `api.command` bridge for v1 plugins; remove in v2.
+import type { TuiCommand, TuiPluginApi } from "@opencode-ai/plugin/tui"
+import { TuiKeybind } from "../config/keybind"
+import type { DialogContext } from "../ui/dialog"
+
+const COMMAND_PALETTE_SHOW = "command.palette.show"
+const warned = new Set<string>()
+
+type Warn = (api: string, replacement: string) => void
+type LegacyDialog = TuiPluginApi["ui"]["dialog"]
+type CommandShimDialog = DialogContext | LegacyDialog
+type LegacyKeybinds = TuiPluginApi["tuiConfig"]["keybinds"]
+
+function warnCommandShim(api: string, replacement: string) {
+  // Warn v1 plugins about deprecated `api.command`; remove this shim path in v2.
+  console.warn("[tui.plugin] deprecated TUI plugin API", { api, replacement })
+}
+
+function createCommandShimDialog(dialog: CommandShimDialog): LegacyDialog {
+  if (!("stack" in dialog)) return dialog
+  return {
+    replace(render, onClose) {
+      dialog.replace(render, onClose)
+    },
+    clear() {
+      dialog.clear()
+    },
+    setSize(size) {
+      dialog.setSize(size)
+    },
+    get size() {
+      return dialog.size
+    },
+    get depth() {
+      return dialog.stack.length
+    },
+    get open() {
+      return dialog.stack.length > 0
+    },
+  }
+}
+
+function warnOnce(api: string, replacement: string, warn: Warn) {
+  if (warned.has(api)) return
+  warned.add(api)
+  warn(api, replacement)
+}
+
+function toCommand(item: TuiCommand, dialog: LegacyDialog) {
+  return {
+    namespace: "palette",
+    name: item.value,
+    title: item.title,
+    desc: item.description,
+    category: item.category,
+    suggested: item.suggested,
+    hidden: item.hidden,
+    enabled: item.enabled,
+    slashName: item.slash?.name,
+    slashAliases: item.slash?.aliases,
+    run() {
+      return item.onSelect?.(dialog)
+    },
+  }
+}
+
+function toBindings(commands: TuiCommand[], keybinds: LegacyKeybinds) {
+  return commands.flatMap((item) =>
+    item.keybind
+      ? keybinds.has(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind)
+        ? keybinds
+            .get(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind)
+            .map((binding) => ({ ...binding, cmd: item.value, desc: binding.desc ?? item.title }))
+        : [
+            {
+              key: item.keybind,
+              cmd: item.value,
+              desc: item.title,
+            },
+          ]
+      : [],
+  )
+}
+
+export function createCommandShim(
+  keymap: TuiPluginApi["keymap"],
+  dialog: CommandShimDialog,
+  keybinds: LegacyKeybinds,
+): TuiPluginApi["command"] {
+  const shimDialog = createCommandShimDialog(dialog)
+  return {
+    register(cb) {
+      warnOnce("api.command.register", "api.keymap.registerLayer({ commands, bindings })", warnCommandShim)
+      const commands = cb()
+      return keymap.registerLayer({
+        commands: commands.map((item) => toCommand(item, shimDialog)),
+        bindings: toBindings(commands, keybinds),
+      })
+    },
+    trigger(value) {
+      warnOnce("api.command.trigger", "api.keymap.dispatchCommand(name)", warnCommandShim)
+      keymap.dispatchCommand(value)
+    },
+    show() {
+      warnOnce("api.command.show", `api.keymap.dispatchCommand("${COMMAND_PALETTE_SHOW}")`, warnCommandShim)
+      keymap.dispatchCommand(COMMAND_PALETTE_SHOW)
+    },
+  }
+}

+ 3 - 0
packages/opencode/src/cli/cmd/tui/plugin/runtime.ts

@@ -39,6 +39,7 @@ import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
 import { setupSlots, Slot as View } from "./slots"
 import type { HostPluginApi, HostSlots } from "./slots"
 import { ConfigPlugin } from "@/config/plugin"
+import { createCommandShim } from "./command-shim"
 
 ensureRuntimePluginSupport({ additional: keymapRuntimeModules })
 
@@ -576,6 +577,8 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
 
   return {
     app: api.app,
+    // Keep deprecated `api.command` working for v1 plugins; remove in v2.
+    command: createCommandShim(keymap, api.ui.dialog, api.tuiConfig.keybinds),
     keys: api.keys,
     keymap,
     route,

+ 43 - 0
packages/plugin/src/tui.ts

@@ -77,6 +77,42 @@ export type TuiKeys = {
 
 export type TuiKeymap = Keymap<Renderable, KeyEvent>
 
+/**
+ * Legacy `api.command` shape kept so v1 plugins can initialize. Remove in v2.
+ *
+ * @deprecated Use `api.keymap.registerLayer({ commands, bindings })` instead.
+ */
+export type TuiCommand = {
+  title: string
+  value: string
+  description?: string
+  category?: string
+  keybind?: string
+  suggested?: boolean
+  hidden?: boolean
+  enabled?: boolean
+  slash?: {
+    name: string
+    aliases?: string[]
+  }
+  onSelect?: (dialog?: TuiDialogStack) => void | Promise<void>
+}
+
+/**
+ * Legacy `api.command` API kept so v1 plugins can initialize. Remove in v2.
+ *
+ * @deprecated Use `api.keymap.registerLayer`, `api.keymap.dispatchCommand`, and
+ * `api.keymap.dispatchCommand("command.palette.show")` instead.
+ */
+export type TuiCommandApi = {
+  /** @deprecated Use `api.keymap.registerLayer({ commands, bindings })` instead. */
+  register: (cb: () => TuiCommand[]) => () => void
+  /** @deprecated Use `api.keymap.dispatchCommand(name)` instead. */
+  trigger: (value: string) => void
+  /** @deprecated Use `api.keymap.dispatchCommand("command.palette.show")` instead. */
+  show: () => void
+}
+
 export type TuiDialogProps = {
   size?: "medium" | "large" | "xlarge"
   onClose: () => void
@@ -461,6 +497,13 @@ export type TuiWorkspace = {
 
 export type TuiPluginApi = {
   app: TuiApp
+  /**
+   * Legacy `api.command` API kept so v1 plugins can initialize. Remove in v2.
+   *
+   * @deprecated Use `api.keymap.registerLayer`, `api.keymap.dispatchCommand`, and
+   * `api.keymap.dispatchCommand("command.palette.show")` instead.
+   */
+  command?: TuiCommandApi
   keys: TuiKeys
   keymap: TuiKeymap
   route: {