Single target plugin entrypoints (#19467)
parent
02b19bc3d7
commit
f3997d8082
|
|
@ -1,7 +1,14 @@
|
||||||
/** @jsxImportSource @opentui/solid */
|
/** @jsxImportSource @opentui/solid */
|
||||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||||
import { RGBA, VignetteEffect } from "@opentui/core"
|
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 tabs = ["overview", "counter", "help"]
|
||||||
const bind = {
|
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
|
if (options?.enabled === false) return
|
||||||
|
|
||||||
await api.theme.install("./smoke-theme.json")
|
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",
|
id: "tui-smoke",
|
||||||
tui,
|
tui,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default plugin
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ Technical reference for the current TUI plugin system.
|
||||||
- Author package entrypoint is `@opencode-ai/plugin/tui`.
|
- Author package entrypoint is `@opencode-ai/plugin/tui`.
|
||||||
- Internal plugins load inside the CLI app the same way external TUI plugins do.
|
- Internal plugins load inside the CLI app the same way external TUI plugins do.
|
||||||
- Package plugins can be installed from CLI or TUI.
|
- 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
|
## TUI config
|
||||||
|
|
||||||
|
|
@ -27,6 +29,7 @@ Example:
|
||||||
- `plugin` entries can be either a string spec or `[spec, options]`.
|
- `plugin` entries can be either a string spec or `[spec, options]`.
|
||||||
- Plugin specs can be npm specs, `file://` URLs, relative paths, or absolute paths.
|
- 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.
|
- 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 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.
|
- 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.
|
- `plugin_enabled` is keyed by plugin id, not by plugin spec.
|
||||||
|
|
@ -46,7 +49,7 @@ Minimal module shape:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
/** @jsxImportSource @opentui/solid */
|
/** @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) => {
|
const tui: TuiPlugin = async (api, options, meta) => {
|
||||||
api.command.register(() => [
|
api.command.register(() => [
|
||||||
|
|
@ -69,16 +72,20 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
const plugin: TuiPluginModule & { id: string } = {
|
||||||
id: "acme.demo",
|
id: "acme.demo",
|
||||||
tui,
|
tui,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default plugin
|
||||||
```
|
```
|
||||||
|
|
||||||
- Loader only reads the module default export object. Named exports are ignored.
|
- 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>`.
|
- `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 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`.
|
- File/path plugins must export a non-empty `id`.
|
||||||
- npm plugins may omit `id`; package `name` is used.
|
- 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.
|
- 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.
|
- 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.
|
- Tuple targets in `oc-plugin` provide default options written into config.
|
||||||
- A package can target `server`, `tui`, or both.
|
- 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.
|
- There is no uninstall, list, or update CLI command for external plugins.
|
||||||
- Local file plugins are configured directly in `tui.json`.
|
- Local file plugins are configured directly in `tui.json`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 { createMemo, Show } from "solid-js"
|
||||||
import { Tips } from "./tips-view"
|
import { Tips } from "./tips-view"
|
||||||
|
|
||||||
|
|
@ -42,7 +42,9 @@ const tui: TuiPlugin = async (api) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
const plugin: TuiPluginModule & { id: string } = {
|
||||||
id,
|
id,
|
||||||
tui,
|
tui,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default plugin
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
|
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"
|
import { createMemo } from "solid-js"
|
||||||
|
|
||||||
const id = "internal:sidebar-context"
|
const id = "internal:sidebar-context"
|
||||||
|
|
@ -55,7 +55,9 @@ const tui: TuiPlugin = async (api) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
const plugin: TuiPluginModule & { id: string } = {
|
||||||
id,
|
id,
|
||||||
tui,
|
tui,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default plugin
|
||||||
|
|
|
||||||
|
|
@ -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 { createMemo, For, Show, createSignal } from "solid-js"
|
||||||
|
|
||||||
const id = "internal:sidebar-files"
|
const id = "internal:sidebar-files"
|
||||||
|
|
@ -54,7 +54,9 @@ const tui: TuiPlugin = async (api) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
const plugin: TuiPluginModule & { id: string } = {
|
||||||
id,
|
id,
|
||||||
tui,
|
tui,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default plugin
|
||||||
|
|
|
||||||
|
|
@ -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 { createMemo, Show } from "solid-js"
|
||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
|
|
||||||
|
|
@ -85,7 +85,9 @@ const tui: TuiPlugin = async (api) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
const plugin: TuiPluginModule & { id: string } = {
|
||||||
id,
|
id,
|
||||||
tui,
|
tui,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default plugin
|
||||||
|
|
|
||||||
|
|
@ -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 { createMemo, For, Show, createSignal } from "solid-js"
|
||||||
|
|
||||||
const id = "internal:sidebar-lsp"
|
const id = "internal:sidebar-lsp"
|
||||||
|
|
@ -58,7 +58,9 @@ const tui: TuiPlugin = async (api) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
const plugin: TuiPluginModule & { id: string } = {
|
||||||
id,
|
id,
|
||||||
tui,
|
tui,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default plugin
|
||||||
|
|
|
||||||
|
|
@ -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"
|
import { createMemo, For, Match, Show, Switch, createSignal } from "solid-js"
|
||||||
|
|
||||||
const id = "internal:sidebar-mcp"
|
const id = "internal:sidebar-mcp"
|
||||||
|
|
@ -88,7 +88,9 @@ const tui: TuiPlugin = async (api) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
const plugin: TuiPluginModule & { id: string } = {
|
||||||
id,
|
id,
|
||||||
tui,
|
tui,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default plugin
|
||||||
|
|
|
||||||
|
|
@ -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 { createMemo, For, Show, createSignal } from "solid-js"
|
||||||
import { TodoItem } from "../../component/todo-item"
|
import { TodoItem } from "../../component/todo-item"
|
||||||
|
|
||||||
|
|
@ -40,7 +40,9 @@ const tui: TuiPlugin = async (api) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
const plugin: TuiPluginModule & { id: string } = {
|
||||||
id,
|
id,
|
||||||
tui,
|
tui,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default plugin
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { Keybind } from "@/util/keybind"
|
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 { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
|
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 id = "internal:plugin-manager"
|
||||||
const key = Keybind.parse("space").at(0)
|
const key = Keybind.parse("space").at(0)
|
||||||
|
|
@ -53,11 +53,17 @@ function Install(props: { api: TuiPluginApi }) {
|
||||||
<props.api.ui.DialogPrompt
|
<props.api.ui.DialogPrompt
|
||||||
title="Install plugin"
|
title="Install plugin"
|
||||||
placeholder="npm package name"
|
placeholder="npm package name"
|
||||||
|
busy={busy()}
|
||||||
|
busyText="Installing plugin..."
|
||||||
description={() => (
|
description={() => (
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg={props.api.theme.current.textMuted}>scope:</text>
|
<text fg={props.api.theme.current.textMuted}>scope:</text>
|
||||||
<text fg={props.api.theme.current.text}>{global() ? "global" : "local"}</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>
|
<text fg={props.api.theme.current.textMuted}>({Keybind.toString(tab)} toggle)</text>
|
||||||
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
onConfirm={(raw) => {
|
onConfirm={(raw) => {
|
||||||
|
|
@ -256,7 +262,9 @@ const tui: TuiPlugin = async (api) => {
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
const plugin: TuiPluginModule & { id: string } = {
|
||||||
id,
|
id,
|
||||||
tui,
|
tui,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default plugin
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,10 @@ import { isRecord } from "@/util/record"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import {
|
import {
|
||||||
checkPluginCompatibility,
|
checkPluginCompatibility,
|
||||||
getDefaultPlugin,
|
|
||||||
isDeprecatedPlugin,
|
isDeprecatedPlugin,
|
||||||
pluginSource,
|
pluginSource,
|
||||||
readPluginId,
|
readPluginId,
|
||||||
|
readV1Plugin,
|
||||||
resolvePluginEntrypoint,
|
resolvePluginEntrypoint,
|
||||||
resolvePluginId,
|
resolvePluginId,
|
||||||
resolvePluginTarget,
|
resolvePluginTarget,
|
||||||
|
|
@ -231,9 +231,7 @@ async function loadExternalPlugin(
|
||||||
|
|
||||||
const mod = await import(entry)
|
const mod = await import(entry)
|
||||||
.then((raw) => {
|
.then((raw) => {
|
||||||
const mod = getDefaultPlugin(raw) as TuiPluginModule | undefined
|
return readV1Plugin(raw as Record<string, unknown>, spec, "tui") as TuiPluginModule
|
||||||
if (!mod?.tui) throw new TypeError(`Plugin ${spec} must default export an object with tui()`)
|
|
||||||
return mod
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
fail("failed to load tui plugin", { path: spec, target: entry, retry, 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) {
|
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
|
const options = load.item ? Config.pluginOptions(load.item) : undefined
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: load.id,
|
id: load.id,
|
||||||
load,
|
load,
|
||||||
meta,
|
meta,
|
||||||
plugin,
|
plugin: load.module.tui,
|
||||||
options,
|
options,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
import { TextareaRenderable, TextAttributes } from "@opentui/core"
|
import { TextareaRenderable, TextAttributes } from "@opentui/core"
|
||||||
import { useTheme } from "../context/theme"
|
import { useTheme } from "../context/theme"
|
||||||
import { useDialog, type DialogContext } from "./dialog"
|
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 { useKeyboard } from "@opentui/solid"
|
||||||
|
import { Spinner } from "../component/spinner"
|
||||||
|
|
||||||
export type DialogPromptProps = {
|
export type DialogPromptProps = {
|
||||||
title: string
|
title: string
|
||||||
description?: () => JSX.Element
|
description?: () => JSX.Element
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
value?: string
|
value?: string
|
||||||
|
busy?: boolean
|
||||||
|
busyText?: string
|
||||||
onConfirm?: (value: string) => void
|
onConfirm?: (value: string) => void
|
||||||
onCancel?: () => void
|
onCancel?: () => void
|
||||||
}
|
}
|
||||||
|
|
@ -19,6 +22,12 @@ export function DialogPrompt(props: DialogPromptProps) {
|
||||||
let textarea: TextareaRenderable
|
let textarea: TextareaRenderable
|
||||||
|
|
||||||
useKeyboard((evt) => {
|
useKeyboard((evt) => {
|
||||||
|
if (props.busy) {
|
||||||
|
if (evt.name === "escape") return
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (evt.name === "return") {
|
if (evt.name === "return") {
|
||||||
props.onConfirm?.(textarea.plainText)
|
props.onConfirm?.(textarea.plainText)
|
||||||
}
|
}
|
||||||
|
|
@ -28,11 +37,21 @@ export function DialogPrompt(props: DialogPromptProps) {
|
||||||
dialog.setSize("medium")
|
dialog.setSize("medium")
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!textarea || textarea.isDestroyed) return
|
if (!textarea || textarea.isDestroyed) return
|
||||||
|
if (props.busy) return
|
||||||
textarea.focus()
|
textarea.focus()
|
||||||
}, 1)
|
}, 1)
|
||||||
textarea.gotoLineEnd()
|
textarea.gotoLineEnd()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!textarea || textarea.isDestroyed) return
|
||||||
|
if (props.busy) {
|
||||||
|
textarea.blur()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
textarea.focus()
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
|
|
@ -47,22 +66,28 @@ export function DialogPrompt(props: DialogPromptProps) {
|
||||||
{props.description}
|
{props.description}
|
||||||
<textarea
|
<textarea
|
||||||
onSubmit={() => {
|
onSubmit={() => {
|
||||||
|
if (props.busy) return
|
||||||
props.onConfirm?.(textarea.plainText)
|
props.onConfirm?.(textarea.plainText)
|
||||||
}}
|
}}
|
||||||
height={3}
|
height={3}
|
||||||
keyBindings={[{ name: "return", action: "submit" }]}
|
keyBindings={props.busy ? [] : [{ name: "return", action: "submit" }]}
|
||||||
ref={(val: TextareaRenderable) => (textarea = val)}
|
ref={(val: TextareaRenderable) => (textarea = val)}
|
||||||
initialValue={props.value}
|
initialValue={props.value}
|
||||||
placeholder={props.placeholder ?? "Enter text"}
|
placeholder={props.placeholder ?? "Enter text"}
|
||||||
textColor={theme.text}
|
textColor={props.busy ? theme.textMuted : theme.text}
|
||||||
focusedTextColor={theme.text}
|
focusedTextColor={props.busy ? theme.textMuted : theme.text}
|
||||||
cursorColor={theme.text}
|
cursorColor={props.busy ? theme.backgroundElement : theme.text}
|
||||||
/>
|
/>
|
||||||
|
<Show when={props.busy}>
|
||||||
|
<Spinner color={theme.textMuted}>{props.busyText ?? "Working..."}</Spinner>
|
||||||
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
<box paddingBottom={1} gap={1} flexDirection="row">
|
<box paddingBottom={1} gap={1} flexDirection="row">
|
||||||
|
<Show when={!props.busy} fallback={<text fg={theme.textMuted}>processing...</text>}>
|
||||||
<text fg={theme.text}>
|
<text fg={theme.text}>
|
||||||
enter <span style={{ fg: theme.textMuted }}>submit</span>
|
enter <span style={{ fg: theme.textMuted }}>submit</span>
|
||||||
</text>
|
</text>
|
||||||
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,15 @@ import { errorMessage } from "@/util/error"
|
||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
import {
|
import {
|
||||||
checkPluginCompatibility,
|
checkPluginCompatibility,
|
||||||
getDefaultPlugin,
|
|
||||||
isDeprecatedPlugin,
|
isDeprecatedPlugin,
|
||||||
parsePluginSpecifier,
|
parsePluginSpecifier,
|
||||||
pluginSource,
|
pluginSource,
|
||||||
|
readPluginId,
|
||||||
|
readV1Plugin,
|
||||||
resolvePluginEntrypoint,
|
resolvePluginEntrypoint,
|
||||||
|
resolvePluginId,
|
||||||
resolvePluginTarget,
|
resolvePluginTarget,
|
||||||
|
type PluginSource,
|
||||||
} from "./shared"
|
} from "./shared"
|
||||||
|
|
||||||
export namespace Plugin {
|
export namespace Plugin {
|
||||||
|
|
@ -35,6 +38,8 @@ export namespace Plugin {
|
||||||
type Loaded = {
|
type Loaded = {
|
||||||
item: Config.PluginSpec
|
item: Config.PluginSpec
|
||||||
spec: string
|
spec: string
|
||||||
|
target: string
|
||||||
|
source: PluginSource
|
||||||
mod: Record<string, unknown>
|
mod: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,7 +117,8 @@ export namespace Plugin {
|
||||||
const resolved = await resolvePlugin(spec)
|
const resolved = await resolvePlugin(spec)
|
||||||
if (!resolved) return
|
if (!resolved) return
|
||||||
|
|
||||||
if (pluginSource(spec) === "npm") {
|
const source = pluginSource(spec)
|
||||||
|
if (source === "npm") {
|
||||||
const incompatible = await checkPluginCompatibility(resolved, Installation.VERSION)
|
const incompatible = await checkPluginCompatibility(resolved, Installation.VERSION)
|
||||||
.then(() => false)
|
.then(() => false)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
|
@ -156,14 +162,17 @@ export namespace Plugin {
|
||||||
return {
|
return {
|
||||||
item,
|
item,
|
||||||
spec,
|
spec,
|
||||||
|
target,
|
||||||
|
source,
|
||||||
mod,
|
mod,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyPlugin(load: Loaded, input: PluginInput, hooks: Hooks[]) {
|
async function applyPlugin(load: Loaded, input: PluginInput, hooks: Hooks[]) {
|
||||||
const plugin = getDefaultPlugin(load.mod) as PluginModule | undefined
|
const plugin = readV1Plugin(load.mod, load.spec, "server", "detect")
|
||||||
if (plugin?.server) {
|
if (plugin) {
|
||||||
hooks.push(await plugin.server(input, Config.pluginOptions(load.item)))
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ export function parsePluginSpecifier(spec: string) {
|
||||||
|
|
||||||
export type PluginSource = "file" | "npm"
|
export type PluginSource = "file" | "npm"
|
||||||
export type PluginKind = "server" | "tui"
|
export type PluginKind = "server" | "tui"
|
||||||
|
type PluginMode = "strict" | "detect"
|
||||||
|
|
||||||
export function pluginSource(spec: string): PluginSource {
|
export function pluginSource(spec: string): PluginSource {
|
||||||
return spec.startsWith("file://") ? "file" : "npm"
|
return spec.startsWith("file://") ? "file" : "npm"
|
||||||
|
|
@ -123,6 +124,40 @@ export function readPluginId(id: unknown, spec: string) {
|
||||||
return value
|
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) {
|
export async function resolvePluginId(source: PluginSource, spec: string, target: string, id: string | undefined) {
|
||||||
if (source === "file") {
|
if (source === "file") {
|
||||||
if (id) return id
|
if (id) return id
|
||||||
|
|
@ -135,15 +170,3 @@ export async function resolvePluginId(source: PluginSource, spec: string, target
|
||||||
}
|
}
|
||||||
return pkg.json.name.trim()
|
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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -130,3 +130,60 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
|
||||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,7 @@ describe("plugin.loader.shared", () => {
|
||||||
file,
|
file,
|
||||||
[
|
[
|
||||||
"export default {",
|
"export default {",
|
||||||
|
' id: "demo.v1-default",',
|
||||||
" server: async () => {",
|
" server: async () => {",
|
||||||
` await Bun.write(${JSON.stringify(mark)}, "default")`,
|
` await Bun.write(${JSON.stringify(mark)}, "default")`,
|
||||||
" return {}",
|
" return {}",
|
||||||
|
|
@ -154,6 +155,82 @@ describe("plugin.loader.shared", () => {
|
||||||
expect(await Bun.file(tmp.extra.mark).text()).toBe("default")
|
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 () => {
|
test("resolves npm plugin specs with explicit and default versions", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,8 @@ export type Plugin = (input: PluginInput, options?: PluginOptions) => Promise<Ho
|
||||||
|
|
||||||
export type PluginModule = {
|
export type PluginModule = {
|
||||||
id?: string
|
id?: string
|
||||||
server?: Plugin
|
server: Plugin
|
||||||
|
tui?: never
|
||||||
}
|
}
|
||||||
|
|
||||||
type Rule = {
|
type Rule = {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import type {
|
||||||
} from "@opencode-ai/sdk/v2"
|
} from "@opencode-ai/sdk/v2"
|
||||||
import type { CliRenderer, ParsedKey, RGBA } from "@opentui/core"
|
import type { CliRenderer, ParsedKey, RGBA } from "@opentui/core"
|
||||||
import type { JSX, SolidPlugin } from "@opentui/solid"
|
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"
|
export type { CliRenderer, SlotMode } from "@opentui/core"
|
||||||
|
|
||||||
|
|
@ -107,6 +107,8 @@ export type TuiDialogPromptProps = {
|
||||||
description?: () => JSX.Element
|
description?: () => JSX.Element
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
value?: string
|
value?: string
|
||||||
|
busy?: boolean
|
||||||
|
busyText?: string
|
||||||
onConfirm?: (value: string) => void
|
onConfirm?: (value: string) => void
|
||||||
onCancel?: () => void
|
onCancel?: () => void
|
||||||
}
|
}
|
||||||
|
|
@ -414,6 +416,8 @@ export type TuiPluginApi = {
|
||||||
|
|
||||||
export type TuiPlugin = (api: TuiPluginApi, options: PluginOptions | undefined, meta: TuiPluginMeta) => Promise<void>
|
export type TuiPlugin = (api: TuiPluginApi, options: PluginOptions | undefined, meta: TuiPluginMeta) => Promise<void>
|
||||||
|
|
||||||
export type TuiPluginModule = PluginModule & {
|
export type TuiPluginModule = {
|
||||||
tui?: TuiPlugin
|
id?: string
|
||||||
|
tui: TuiPlugin
|
||||||
|
server?: never
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue