Przeglądaj źródła

Single target plugin entrypoints (#19467)

Sebastian 5 miesięcy temu
rodzic
commit
f3997d8082

+ 12 - 3
.opencode/plugins/tui-smoke.tsx

@@ -1,7 +1,14 @@
 /** @jsxImportSource @opentui/solid */
 import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
 import { RGBA, VignetteEffect } from "@opentui/core"
-import type { TuiKeybindSet, TuiPluginApi, TuiPluginMeta, TuiSlotPlugin } from "@opencode-ai/plugin/tui"
+import type {
+  TuiKeybindSet,
+  TuiPlugin,
+  TuiPluginApi,
+  TuiPluginMeta,
+  TuiPluginModule,
+  TuiSlotPlugin,
+} from "@opencode-ai/plugin/tui"
 
 const tabs = ["overview", "counter", "help"]
 const bind = {
@@ -813,7 +820,7 @@ const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => {
   ])
 }
 
-const tui = async (api: TuiPluginApi, options: Record<string, unknown> | null, meta: TuiPluginMeta) => {
+const tui: TuiPlugin = async (api, options, meta) => {
   if (options?.enabled === false) return
 
   await api.theme.install("./smoke-theme.json")
@@ -846,7 +853,9 @@ const tui = async (api: TuiPluginApi, options: Record<string, unknown> | null, m
   }
 }
 
-export default {
+const plugin: TuiPluginModule & { id: string } = {
   id: "tui-smoke",
   tui,
 }
+
+export default plugin

+ 11 - 3
packages/opencode/specs/tui-plugins.md

@@ -8,6 +8,8 @@ Technical reference for the current TUI plugin system.
 - Author package entrypoint is `@opencode-ai/plugin/tui`.
 - Internal plugins load inside the CLI app the same way external TUI plugins do.
 - Package plugins can be installed from CLI or TUI.
+- v1 plugin modules are target-exclusive: a module can export `server` or `tui`, never both.
+- Server runtime keeps v0 legacy fallback (function exports / enumerated exports) after v1 parsing.
 
 ## TUI config
 
@@ -27,6 +29,7 @@ Example:
 - `plugin` entries can be either a string spec or `[spec, options]`.
 - Plugin specs can be npm specs, `file://` URLs, relative paths, or absolute paths.
 - Relative path specs are resolved relative to the config file that declared them.
+- A file module listed in `tui.json` must be a TUI module (`default export { id?, tui }`) and must not export `server`.
 - Duplicate npm plugins are deduped by package name; higher-precedence config wins.
 - Duplicate file plugins are deduped by exact resolved file spec. This happens while merging config, before plugin modules are loaded.
 - `plugin_enabled` is keyed by plugin id, not by plugin spec.
@@ -46,7 +49,7 @@ Minimal module shape:
 
 ```tsx
 /** @jsxImportSource @opentui/solid */
-import type { TuiPlugin } from "@opencode-ai/plugin/tui"
+import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
 
 const tui: TuiPlugin = async (api, options, meta) => {
   api.command.register(() => [
@@ -69,16 +72,20 @@ const tui: TuiPlugin = async (api, options, meta) => {
   ])
 }
 
-export default {
+const plugin: TuiPluginModule & { id: string } = {
   id: "acme.demo",
   tui,
 }
+
+export default plugin
 ```
 
 - Loader only reads the module default export object. Named exports are ignored.
-- TUI shape is `default export { id?, tui }`.
+- TUI shape is `default export { id?, tui }`; including `server` is rejected.
+- A single module cannot export both `server` and `tui`.
 - `tui` signature is `(api, options, meta) => Promise<void>`.
 - If package `exports` contains `./tui`, the loader resolves that entrypoint. Otherwise it uses the resolved package target.
+- If a package supports both server and TUI, use separate files and package `exports` (`./server` and `./tui`) so each target resolves to a target-only module.
 - File/path plugins must export a non-empty `id`.
 - npm plugins may omit `id`; package `name` is used.
 - Runtime identity is the resolved plugin id. Later plugins with the same id are rejected, including collisions with internal plugin ids.
@@ -137,6 +144,7 @@ npm plugins can declare a version compatibility range in `package.json` using th
 - With `--force`, replacement matches by package name. If the existing row is `[spec, options]`, those tuple options are kept.
 - Tuple targets in `oc-plugin` provide default options written into config.
 - A package can target `server`, `tui`, or both.
+- If a package targets both, each target must still resolve to a separate target-only module. Do not export `{ server, tui }` from one module.
 - There is no uninstall, list, or update CLI command for external plugins.
 - Local file plugins are configured directly in `tui.json`.
 

+ 4 - 2
packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx

@@ -1,4 +1,4 @@
-import type { TuiPlugin } from "@opencode-ai/plugin/tui"
+import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
 import { createMemo, Show } from "solid-js"
 import { Tips } from "./tips-view"
 
@@ -42,7 +42,9 @@ const tui: TuiPlugin = async (api) => {
   })
 }
 
-export default {
+const plugin: TuiPluginModule & { id: string } = {
   id,
   tui,
 }
+
+export default plugin

+ 4 - 2
packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx

@@ -1,5 +1,5 @@
 import type { AssistantMessage } from "@opencode-ai/sdk/v2"
-import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
+import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
 import { createMemo } from "solid-js"
 
 const id = "internal:sidebar-context"
@@ -55,7 +55,9 @@ const tui: TuiPlugin = async (api) => {
   })
 }
 
-export default {
+const plugin: TuiPluginModule & { id: string } = {
   id,
   tui,
 }
+
+export default plugin

+ 4 - 2
packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/files.tsx

@@ -1,4 +1,4 @@
-import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
+import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
 import { createMemo, For, Show, createSignal } from "solid-js"
 
 const id = "internal:sidebar-files"
@@ -54,7 +54,9 @@ const tui: TuiPlugin = async (api) => {
   })
 }
 
-export default {
+const plugin: TuiPluginModule & { id: string } = {
   id,
   tui,
 }
+
+export default plugin

+ 4 - 2
packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx

@@ -1,4 +1,4 @@
-import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
+import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
 import { createMemo, Show } from "solid-js"
 import { Global } from "@/global"
 
@@ -85,7 +85,9 @@ const tui: TuiPlugin = async (api) => {
   })
 }
 
-export default {
+const plugin: TuiPluginModule & { id: string } = {
   id,
   tui,
 }
+
+export default plugin

+ 4 - 2
packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx

@@ -1,4 +1,4 @@
-import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
+import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
 import { createMemo, For, Show, createSignal } from "solid-js"
 
 const id = "internal:sidebar-lsp"
@@ -58,7 +58,9 @@ const tui: TuiPlugin = async (api) => {
   })
 }
 
-export default {
+const plugin: TuiPluginModule & { id: string } = {
   id,
   tui,
 }
+
+export default plugin

+ 4 - 2
packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/mcp.tsx

@@ -1,4 +1,4 @@
-import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
+import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
 import { createMemo, For, Match, Show, Switch, createSignal } from "solid-js"
 
 const id = "internal:sidebar-mcp"
@@ -88,7 +88,9 @@ const tui: TuiPlugin = async (api) => {
   })
 }
 
-export default {
+const plugin: TuiPluginModule & { id: string } = {
   id,
   tui,
 }
+
+export default plugin

+ 4 - 2
packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/todo.tsx

@@ -1,4 +1,4 @@
-import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
+import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
 import { createMemo, For, Show, createSignal } from "solid-js"
 import { TodoItem } from "../../component/todo-item"
 
@@ -40,7 +40,9 @@ const tui: TuiPlugin = async (api) => {
   })
 }
 
-export default {
+const plugin: TuiPluginModule & { id: string } = {
   id,
   tui,
 }
+
+export default plugin

+ 13 - 5
packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx

@@ -1,9 +1,9 @@
 import { Keybind } from "@/util/keybind"
-import type { TuiPlugin, TuiPluginApi, TuiPluginStatus } from "@opencode-ai/plugin/tui"
+import type { TuiPlugin, TuiPluginApi, TuiPluginModule, TuiPluginStatus } from "@opencode-ai/plugin/tui"
 import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
 import { fileURLToPath } from "url"
 import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
-import { createEffect, createMemo, createSignal } from "solid-js"
+import { Show, createEffect, createMemo, createSignal } from "solid-js"
 
 const id = "internal:plugin-manager"
 const key = Keybind.parse("space").at(0)
@@ -53,11 +53,17 @@ function Install(props: { api: TuiPluginApi }) {
     <props.api.ui.DialogPrompt
       title="Install plugin"
       placeholder="npm package name"
+      busy={busy()}
+      busyText="Installing plugin..."
       description={() => (
         <box flexDirection="row" gap={1}>
           <text fg={props.api.theme.current.textMuted}>scope:</text>
-          <text fg={props.api.theme.current.text}>{global() ? "global" : "local"}</text>
-          <text fg={props.api.theme.current.textMuted}>({Keybind.toString(tab)} toggle)</text>
+          <text fg={busy() ? props.api.theme.current.textMuted : props.api.theme.current.text}>
+            {global() ? "global" : "local"}
+          </text>
+          <Show when={!busy()}>
+            <text fg={props.api.theme.current.textMuted}>({Keybind.toString(tab)} toggle)</text>
+          </Show>
         </box>
       )}
       onConfirm={(raw) => {
@@ -256,7 +262,9 @@ const tui: TuiPlugin = async (api) => {
   ])
 }
 
-export default {
+const plugin: TuiPluginModule & { id: string } = {
   id,
   tui,
 }
+
+export default plugin

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

@@ -20,10 +20,10 @@ import { isRecord } from "@/util/record"
 import { Instance } from "@/project/instance"
 import {
   checkPluginCompatibility,
-  getDefaultPlugin,
   isDeprecatedPlugin,
   pluginSource,
   readPluginId,
+  readV1Plugin,
   resolvePluginEntrypoint,
   resolvePluginId,
   resolvePluginTarget,
@@ -231,9 +231,7 @@ async function loadExternalPlugin(
 
   const mod = await import(entry)
     .then((raw) => {
-      const mod = getDefaultPlugin(raw) as TuiPluginModule | undefined
-      if (!mod?.tui) throw new TypeError(`Plugin ${spec} must default export an object with tui()`)
-      return mod
+      return readV1Plugin(raw as Record<string, unknown>, spec, "tui") as TuiPluginModule
     })
     .catch((error) => {
       fail("failed to load tui plugin", { path: spec, target: entry, retry, error })
@@ -566,16 +564,13 @@ function pluginApi(runtime: RuntimeState, load: PluginLoad, scope: PluginScope,
 }
 
 function collectPluginEntries(load: PluginLoad, meta: TuiPluginMeta) {
-  // TUI stays default-only so plugin ids, lifecycle, and errors remain stable.
-  const plugin = load.module.tui
-  if (!plugin) return []
   const options = load.item ? Config.pluginOptions(load.item) : undefined
   return [
     {
       id: load.id,
       load,
       meta,
-      plugin,
+      plugin: load.module.tui,
       options,
       enabled: true,
     },

+ 33 - 8
packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx

@@ -1,14 +1,17 @@
 import { TextareaRenderable, TextAttributes } from "@opentui/core"
 import { useTheme } from "../context/theme"
 import { useDialog, type DialogContext } from "./dialog"
-import { onMount, type JSX } from "solid-js"
+import { Show, createEffect, onMount, type JSX } from "solid-js"
 import { useKeyboard } from "@opentui/solid"
+import { Spinner } from "../component/spinner"
 
 export type DialogPromptProps = {
   title: string
   description?: () => JSX.Element
   placeholder?: string
   value?: string
+  busy?: boolean
+  busyText?: string
   onConfirm?: (value: string) => void
   onCancel?: () => void
 }
@@ -19,6 +22,12 @@ export function DialogPrompt(props: DialogPromptProps) {
   let textarea: TextareaRenderable
 
   useKeyboard((evt) => {
+    if (props.busy) {
+      if (evt.name === "escape") return
+      evt.preventDefault()
+      evt.stopPropagation()
+      return
+    }
     if (evt.name === "return") {
       props.onConfirm?.(textarea.plainText)
     }
@@ -28,11 +37,21 @@ export function DialogPrompt(props: DialogPromptProps) {
     dialog.setSize("medium")
     setTimeout(() => {
       if (!textarea || textarea.isDestroyed) return
+      if (props.busy) return
       textarea.focus()
     }, 1)
     textarea.gotoLineEnd()
   })
 
+  createEffect(() => {
+    if (!textarea || textarea.isDestroyed) return
+    if (props.busy) {
+      textarea.blur()
+      return
+    }
+    textarea.focus()
+  })
+
   return (
     <box paddingLeft={2} paddingRight={2} gap={1}>
       <box flexDirection="row" justifyContent="space-between">
@@ -47,22 +66,28 @@ export function DialogPrompt(props: DialogPromptProps) {
         {props.description}
         <textarea
           onSubmit={() => {
+            if (props.busy) return
             props.onConfirm?.(textarea.plainText)
           }}
           height={3}
-          keyBindings={[{ name: "return", action: "submit" }]}
+          keyBindings={props.busy ? [] : [{ name: "return", action: "submit" }]}
           ref={(val: TextareaRenderable) => (textarea = val)}
           initialValue={props.value}
           placeholder={props.placeholder ?? "Enter text"}
-          textColor={theme.text}
-          focusedTextColor={theme.text}
-          cursorColor={theme.text}
+          textColor={props.busy ? theme.textMuted : theme.text}
+          focusedTextColor={props.busy ? theme.textMuted : theme.text}
+          cursorColor={props.busy ? theme.backgroundElement : theme.text}
         />
+        <Show when={props.busy}>
+          <Spinner color={theme.textMuted}>{props.busyText ?? "Working..."}</Spinner>
+        </Show>
       </box>
       <box paddingBottom={1} gap={1} flexDirection="row">
-        <text fg={theme.text}>
-          enter <span style={{ fg: theme.textMuted }}>submit</span>
-        </text>
+        <Show when={!props.busy} fallback={<text fg={theme.textMuted}>processing...</text>}>
+          <text fg={theme.text}>
+            enter <span style={{ fg: theme.textMuted }}>submit</span>
+          </text>
+        </Show>
       </box>
     </box>
   )

+ 14 - 5
packages/opencode/src/plugin/index.ts

@@ -17,12 +17,15 @@ import { errorMessage } from "@/util/error"
 import { Installation } from "@/installation"
 import {
   checkPluginCompatibility,
-  getDefaultPlugin,
   isDeprecatedPlugin,
   parsePluginSpecifier,
   pluginSource,
+  readPluginId,
+  readV1Plugin,
   resolvePluginEntrypoint,
+  resolvePluginId,
   resolvePluginTarget,
+  type PluginSource,
 } from "./shared"
 
 export namespace Plugin {
@@ -35,6 +38,8 @@ export namespace Plugin {
   type Loaded = {
     item: Config.PluginSpec
     spec: string
+    target: string
+    source: PluginSource
     mod: Record<string, unknown>
   }
 
@@ -112,7 +117,8 @@ export namespace Plugin {
     const resolved = await resolvePlugin(spec)
     if (!resolved) return
 
-    if (pluginSource(spec) === "npm") {
+    const source = pluginSource(spec)
+    if (source === "npm") {
       const incompatible = await checkPluginCompatibility(resolved, Installation.VERSION)
         .then(() => false)
         .catch((err) => {
@@ -156,14 +162,17 @@ export namespace Plugin {
     return {
       item,
       spec,
+      target,
+      source,
       mod,
     }
   }
 
   async function applyPlugin(load: Loaded, input: PluginInput, hooks: Hooks[]) {
-    const plugin = getDefaultPlugin(load.mod) as PluginModule | undefined
-    if (plugin?.server) {
-      hooks.push(await plugin.server(input, Config.pluginOptions(load.item)))
+    const plugin = readV1Plugin(load.mod, load.spec, "server", "detect")
+    if (plugin) {
+      await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec))
+      hooks.push(await (plugin as PluginModule).server(input, Config.pluginOptions(load.item)))
       return
     }
 

+ 35 - 12
packages/opencode/src/plugin/shared.ts

@@ -21,6 +21,7 @@ export function parsePluginSpecifier(spec: string) {
 
 export type PluginSource = "file" | "npm"
 export type PluginKind = "server" | "tui"
+type PluginMode = "strict" | "detect"
 
 export function pluginSource(spec: string): PluginSource {
   return spec.startsWith("file://") ? "file" : "npm"
@@ -123,6 +124,40 @@ export function readPluginId(id: unknown, spec: string) {
   return value
 }
 
+export function readV1Plugin(
+  mod: Record<string, unknown>,
+  spec: string,
+  kind: PluginKind,
+  mode: PluginMode = "strict",
+) {
+  const value = mod.default
+  if (!isRecord(value)) {
+    if (mode === "detect") return
+    throw new TypeError(`Plugin ${spec} must default export an object with ${kind}()`)
+  }
+  if (mode === "detect" && !("id" in value) && !("server" in value) && !("tui" in value)) return
+
+  const server = "server" in value ? value.server : undefined
+  const tui = "tui" in value ? value.tui : undefined
+  if (server !== undefined && typeof server !== "function") {
+    throw new TypeError(`Plugin ${spec} has invalid server export`)
+  }
+  if (tui !== undefined && typeof tui !== "function") {
+    throw new TypeError(`Plugin ${spec} has invalid tui export`)
+  }
+  if (server !== undefined && tui !== undefined) {
+    throw new TypeError(`Plugin ${spec} must default export either server() or tui(), not both`)
+  }
+  if (kind === "server" && server === undefined) {
+    throw new TypeError(`Plugin ${spec} must default export an object with server()`)
+  }
+  if (kind === "tui" && tui === undefined) {
+    throw new TypeError(`Plugin ${spec} must default export an object with tui()`)
+  }
+
+  return value
+}
+
 export async function resolvePluginId(source: PluginSource, spec: string, target: string, id: string | undefined) {
   if (source === "file") {
     if (id) return id
@@ -135,15 +170,3 @@ export async function resolvePluginId(source: PluginSource, spec: string, target
   }
   return pkg.json.name.trim()
 }
-
-export function getDefaultPlugin(mod: Record<string, unknown>) {
-  // A single default object keeps v1 detection explicit and avoids scanning exports.
-  const value = mod.default
-  if (!isRecord(value)) return
-  const server = "server" in value ? value.server : undefined
-  const tui = "tui" in value ? value.tui : undefined
-  if (server !== undefined && typeof server !== "function") return
-  if (tui !== undefined && typeof tui !== "function") return
-  if (server === undefined && tui === undefined) return
-  return value
-}

+ 57 - 0
packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts

@@ -130,3 +130,60 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
     delete process.env.OPENCODE_PLUGIN_META_FILE
   }
 })
+
+test("rejects npm tui plugin that exports server and tui together", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      const mod = path.join(dir, "mods", "acme-plugin")
+      const marker = path.join(dir, "mixed-called.txt")
+      await fs.mkdir(mod, { recursive: true })
+
+      await Bun.write(
+        path.join(mod, "package.json"),
+        JSON.stringify({
+          name: "acme-plugin",
+          type: "module",
+          exports: { ".": "./index.js", "./tui": "./tui.js" },
+        }),
+      )
+      await Bun.write(path.join(mod, "index.js"), "export default {}\n")
+      await Bun.write(
+        path.join(mod, "tui.js"),
+        `export default {
+  id: "demo.mixed",
+  server: async () => ({}),
+  tui: async () => {
+    await Bun.write(${JSON.stringify(marker)}, "called")
+  },
+}
+`,
+      )
+
+      return { mod, marker, spec: "acme-plugin@1.0.0" }
+    },
+  })
+
+  process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
+  const get = spyOn(TuiConfig, "get").mockResolvedValue({
+    plugin: [tmp.extra.spec],
+    plugin_meta: {
+      [tmp.extra.spec]: { scope: "local", source: path.join(tmp.path, "tui.json") },
+    },
+  })
+  const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
+  const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
+  const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
+
+  try {
+    await TuiPluginRuntime.init(createTuiPluginApi())
+    await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
+    expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
+  } finally {
+    await TuiPluginRuntime.dispose()
+    install.mockRestore()
+    cwd.mockRestore()
+    get.mockRestore()
+    wait.mockRestore()
+    delete process.env.OPENCODE_PLUGIN_META_FILE
+  }
+})

+ 77 - 0
packages/opencode/test/plugin/loader-shared.test.ts

@@ -128,6 +128,7 @@ describe("plugin.loader.shared", () => {
           file,
           [
             "export default {",
+            '  id: "demo.v1-default",',
             "  server: async () => {",
             `    await Bun.write(${JSON.stringify(mark)}, "default")`,
             "    return {}",
@@ -154,6 +155,82 @@ describe("plugin.loader.shared", () => {
     expect(await Bun.file(tmp.extra.mark).text()).toBe("default")
   })
 
+  test("rejects v1 file server plugin without id", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        const file = path.join(dir, "plugin.ts")
+        const mark = path.join(dir, "called.txt")
+        await Bun.write(
+          file,
+          [
+            "export default {",
+            "  server: async () => {",
+            `    await Bun.write(${JSON.stringify(mark)}, "called")`,
+            "    return {}",
+            "  },",
+            "}",
+            "",
+          ].join("\n"),
+        )
+
+        await Bun.write(
+          path.join(dir, "opencode.json"),
+          JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
+        )
+
+        return { mark }
+      },
+    })
+
+    const errors = await errs(tmp.path)
+    const called = await Bun.file(tmp.extra.mark)
+      .text()
+      .then(() => true)
+      .catch(() => false)
+
+    expect(called).toBe(false)
+    expect(errors.some((x) => x.includes("must export id"))).toBe(true)
+  })
+
+  test("rejects v1 plugin that exports server and tui together", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        const file = path.join(dir, "plugin.ts")
+        const mark = path.join(dir, "called.txt")
+        await Bun.write(
+          file,
+          [
+            "export default {",
+            '  id: "demo.mixed",',
+            "  server: async () => {",
+            `    await Bun.write(${JSON.stringify(mark)}, "server")`,
+            "    return {}",
+            "  },",
+            "  tui: async () => {},",
+            "}",
+            "",
+          ].join("\n"),
+        )
+
+        await Bun.write(
+          path.join(dir, "opencode.json"),
+          JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
+        )
+
+        return { mark }
+      },
+    })
+
+    const errors = await errs(tmp.path)
+    const called = await Bun.file(tmp.extra.mark)
+      .text()
+      .then(() => true)
+      .catch(() => false)
+
+    expect(called).toBe(false)
+    expect(errors.some((x) => x.includes("either server() or tui(), not both"))).toBe(true)
+  })
+
   test("resolves npm plugin specs with explicit and default versions", async () => {
     await using tmp = await tmpdir({
       init: async (dir) => {

+ 2 - 1
packages/plugin/src/index.ts

@@ -42,7 +42,8 @@ export type Plugin = (input: PluginInput, options?: PluginOptions) => Promise<Ho
 
 export type PluginModule = {
   id?: string
-  server?: Plugin
+  server: Plugin
+  tui?: never
 }
 
 type Rule = {

+ 7 - 3
packages/plugin/src/tui.ts

@@ -15,7 +15,7 @@ import type {
 } from "@opencode-ai/sdk/v2"
 import type { CliRenderer, ParsedKey, RGBA } from "@opentui/core"
 import type { JSX, SolidPlugin } from "@opentui/solid"
-import type { Config as PluginConfig, Plugin, PluginModule, PluginOptions } from "./index.js"
+import type { Config as PluginConfig, PluginOptions } from "./index.js"
 
 export type { CliRenderer, SlotMode } from "@opentui/core"
 
@@ -107,6 +107,8 @@ export type TuiDialogPromptProps = {
   description?: () => JSX.Element
   placeholder?: string
   value?: string
+  busy?: boolean
+  busyText?: string
   onConfirm?: (value: string) => void
   onCancel?: () => void
 }
@@ -414,6 +416,8 @@ export type TuiPluginApi = {
 
 export type TuiPlugin = (api: TuiPluginApi, options: PluginOptions | undefined, meta: TuiPluginMeta) => Promise<void>
 
-export type TuiPluginModule = PluginModule & {
-  tui?: TuiPlugin
+export type TuiPluginModule = {
+  id?: string
+  tui: TuiPlugin
+  server?: never
 }