diff --git a/.opencode/plugins/tui-smoke.tsx b/.opencode/plugins/tui-smoke.tsx index 3e90bafb65..deb3c3e3e4 100644 --- a/.opencode/plugins/tui-smoke.tsx +++ b/.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 | 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 | null, m } } -export default { +const plugin: TuiPluginModule & { id: string } = { id: "tui-smoke", tui, } + +export default plugin diff --git a/packages/opencode/specs/tui-plugins.md b/packages/opencode/specs/tui-plugins.md index 1a7ba55a02..02b2a9741d 100644 --- a/packages/opencode/specs/tui-plugins.md +++ b/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`. - 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`. diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx index 1a1d3c174c..c0e02f74af 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx +++ b/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 diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx index c8538ae2a7..9ffe779791 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx +++ b/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 diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/files.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/files.tsx index 16bed72878..c865c5eb49 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/files.tsx +++ b/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 diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx index a6bff01a57..b468d851b0 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx +++ b/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 diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx index db9b3a7e56..cb4050fdb8 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx +++ b/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 diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/mcp.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/mcp.tsx index 178050abd5..391bf27b90 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/mcp.tsx +++ b/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 diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/todo.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/todo.tsx index c9e904debd..eed0cb703d 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/todo.tsx +++ b/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 diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx index 8293be5068..f2fd25ffb6 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx +++ b/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 }) { ( scope: - {global() ? "global" : "local"} - ({Keybind.toString(tab)} toggle) + + {global() ? "global" : "local"} + + + ({Keybind.toString(tab)} toggle) + )} onConfirm={(raw) => { @@ -256,7 +262,9 @@ const tui: TuiPlugin = async (api) => { ]) } -export default { +const plugin: TuiPluginModule & { id: string } = { id, tui, } + +export default plugin diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index 9cc5194df0..0e1674bdac 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/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, 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, }, diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx index b1b05a0f1a..cb1b8257ab 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx +++ b/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 ( @@ -47,22 +66,28 @@ export function DialogPrompt(props: DialogPromptProps) { {props.description}