From 103105d52a2d4300dcc1b42abfe09fa4e47a95d2 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 18 Aug 2026 13:24:27 -0400 Subject: [PATCH] feat(plugin): expose server plugin status --- .../project-settings-extensions.tsx | 5 +- .../src/components/settings-v2/extensions.tsx | 5 +- .../src/components/status-popover-body.tsx | 3 +- packages/app/src/utils/plugin.ts | 8 + .../client/src/promise/generated/types.ts | 10 +- packages/core/src/plugin.ts | 60 +++-- packages/core/src/plugin/sdk.ts | 2 +- packages/core/src/plugin/supervisor.ts | 58 +++-- packages/core/test/config/plugin.test.ts | 25 +- packages/core/test/plugin.test.ts | 61 +++-- .../plugin/fixtures/config-promise-plugin.ts | 1 + packages/plugin/src/effect/plugin.ts | 1 + packages/plugin/src/promise/adapter.ts | 1 + packages/plugin/src/promise/plugin.ts | 1 + packages/protocol/src/groups/plugin.ts | 2 +- packages/schema/src/plugin.ts | 29 ++- .../src/component/dialog-error-details.tsx | 25 +- .../src/feature-plugins/system/plugins.tsx | 218 ++++++++++++------ 18 files changed, 376 insertions(+), 139 deletions(-) create mode 100644 packages/app/src/utils/plugin.ts diff --git a/packages/app/src/components/project-settings-extensions.tsx b/packages/app/src/components/project-settings-extensions.tsx index 376cd6d0b2c..35014b3c925 100644 --- a/packages/app/src/components/project-settings-extensions.tsx +++ b/packages/app/src/components/project-settings-extensions.tsx @@ -7,6 +7,7 @@ import { useMcpToggle } from "@/context/mcp" import { useWorkspaceLocation } from "@/context/location" import { useServerSDK } from "@/context/server-sdk" import { useData } from "@/context/server" +import { pluginLabel } from "@/utils/plugin" import { ExternalLink } from "./external-link" type SkillItem = { @@ -101,10 +102,10 @@ export const ProjectSettingsExtensions: Component = () => { () => (serverSDK.connection.status() === "connected" ? directorySDK().directory : undefined), (directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data), ) - const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map((item) => item.id)) + const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map(pluginLabel)) const projectPlugins = createMemo(() => { const shared = new Set(globalPlugins()) - return (projectPluginList.latest ?? []).map((item) => item.id).filter((name) => !shared.has(name)) + return (projectPluginList.latest ?? []).map(pluginLabel).filter((name) => !shared.has(name)) }) const serverSkills = createMemo(() => data.location.skill.list() ?? []) diff --git a/packages/app/src/components/settings-v2/extensions.tsx b/packages/app/src/components/settings-v2/extensions.tsx index 49aab487daa..a672f95e9d2 100644 --- a/packages/app/src/components/settings-v2/extensions.tsx +++ b/packages/app/src/components/settings-v2/extensions.tsx @@ -6,6 +6,7 @@ import { useLanguage } from "@/context/language" import { useData } from "@/context/server" import { useServerSDK } from "@/context/server-sdk" import { useMcpToggle } from "@/context/mcp" +import { pluginLabel } from "@/utils/plugin" import { ExternalLink } from "../external-link" import { InlineServerSelect } from "./parts/server-select" import "./settings-v2.css" @@ -44,7 +45,9 @@ export const SettingsExtensionsV2: Component = () => { () => serverSdk.connection.status() === "connected", () => serverSdk.api.plugin.list().then((result) => result.data), ) - const plugins = createMemo(() => (pluginList.latest ?? []).map((item) => ({ name: item.id }))) + const plugins = createMemo(() => + (pluginList.latest ?? []).map((item) => ({ name: pluginLabel(item) })), + ) createEffect(() => { if (serverSdk.connection.status() !== "connected") return diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index 03cbfa169c7..d17acb0ab05 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -6,6 +6,7 @@ import { useMcpToggle } from "@/context/mcp" import { useWorkspaceLocation } from "@/context/location" import { useData } from "@/context/server" import { useServerSDK } from "@/context/server-sdk" +import { pluginLabel } from "@/utils/plugin" const pluginEmptyMessage = (value: string, file: string): JSXElement => { const parts = value.split(file) @@ -38,7 +39,7 @@ export function StatusPopoverBody(props: { shown: boolean }) { () => (props.shown ? sdk().directory : undefined), (directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data), ) - const plugins = createMemo(() => (pluginList.latest ?? []).map((item) => item.id)) + const plugins = createMemo(() => (pluginList.latest ?? []).map(pluginLabel)) const pluginCount = createMemo(() => plugins().length) const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json")) diff --git a/packages/app/src/utils/plugin.ts b/packages/app/src/utils/plugin.ts new file mode 100644 index 00000000000..2c4145656f7 --- /dev/null +++ b/packages/app/src/utils/plugin.ts @@ -0,0 +1,8 @@ +import type { PluginInfo } from "@opencode-ai/client" + +export function pluginLabel(plugin: PluginInfo) { + if (plugin.id) return plugin.id + if (plugin.source.type === "package") return plugin.source.package + if (plugin.source.type === "local") return plugin.source.path + return plugin.source.type +} diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 84374153c2c..2ae36dc7a38 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -10,7 +10,11 @@ export type AgentColor = string export type PermissionEffect = "allow" | "deny" | "ask" -export type PluginInfo = { id: string } +export type PluginSource = + | { type: "builtin" } + | { type: "package"; package: string } + | { type: "local"; path: string } + | { type: "sdk" } export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string } @@ -196,6 +200,10 @@ export type ProviderRequest = { export type PermissionRule = { action: string; resource: string; effect: PermissionEffect } +export type PluginInfo = + | { id: string; source: PluginSource; status: "active"; tui: boolean } + | { id?: string; source: PluginSource; status: "failed"; error: string; tui: boolean } + export type TokenUsageInfo = { input: number output: number diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index eaf250008a3..af4e2c705ff 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -1,10 +1,10 @@ export * as Plugin from "./plugin.js" -export { Event, ID, Info } from "@opencode-ai/schema/plugin" +export { Event, ID, Info, Source } from "@opencode-ai/schema/plugin" import { Plugin } from "@opencode-ai/schema/plugin" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { App } from "./app.js" -import { Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect" +import { Cause, Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect" import { Agent } from "./agent.js" import { AISDK } from "./aisdk.js" import { Catalog } from "./catalog.js" @@ -23,11 +23,17 @@ import { Tool } from "./tool.js" import { PluginHooks } from "./plugin/hooks.js" export interface Interface { - readonly activate: (plugins: readonly Versioned[]) => Effect.Effect + readonly activate: ( + plugins: readonly Versioned[], + failures?: readonly Extract[], + ) => Effect.Effect readonly list: () => Effect.Effect } -export type Versioned = import("@opencode-ai/plugin/effect/plugin").Plugin & { readonly version: string } +export type Versioned = import("@opencode-ai/plugin/effect/plugin").Plugin & { + readonly version: string + readonly source?: Plugin.Source +} export class Service extends Context.Service()("@opencode/Plugin") {} @@ -38,6 +44,7 @@ const layer = Layer.effect( const scope = yield* Scope.make() const active = new Map() const lock = Semaphore.makeUnsafe(1) + let inventory: Plugin.Info[] = [] let host: Parameters[0] const load = Effect.fnUntraced(function* (plugin: Versioned) { @@ -56,15 +63,18 @@ const layer = Layer.effect( Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)), Effect.exit, ) - if (Exit.isSuccess(loaded)) return child + if (Exit.isSuccess(loaded)) return { scope: child } as const yield* Effect.logWarning("failed to load plugin", { "plugin.id": plugin.id, cause: loaded.cause, }) - return undefined + return { error: Cause.pretty(loaded.cause) } as const }) - const activate = Effect.fn("Plugin.activate")(function* (plugins: readonly Versioned[]) { + const activate = Effect.fn("Plugin.activate")(function* ( + plugins: readonly Versioned[], + failures: readonly Extract[] = [], + ) { const definitions = plugins.map((plugin) => ({ ...plugin, id: Plugin.ID.make(plugin.id) })) const ids = new Set() for (const definition of definitions) { @@ -85,26 +95,40 @@ const layer = Layer.effect( const candidate = next[index] return definition.id === candidate?.id && definition.version === candidate.version }) - ) + ) { + const nextInventory = [...Array.from(active.values(), (entry) => activeInfo(entry.plugin)), ...failures] + if (JSON.stringify(inventory) === JSON.stringify(nextInventory)) return + inventory = nextInventory + yield* bus.publish(Plugin.Event.Updated, {}) return + } yield* State.batch( Effect.gen(function* () { + const nextInventory: Plugin.Info[] = [] for (const definition of definitions) { const previous = active.get(definition.id) active.delete(definition.id) if (previous) yield* Scope.close(previous.scope, Exit.void).pipe(Effect.ignore) const loaded = yield* load(definition) - if (loaded) { - active.set(definition.id, { plugin: definition, scope: loaded }) + if (loaded.scope !== undefined) { + active.set(definition.id, { plugin: definition, scope: loaded.scope }) + nextInventory.push(activeInfo(definition)) continue } + nextInventory.push({ + id: definition.id, + source: definition.source ?? { type: "builtin" }, + status: "failed", + error: loaded.error, + tui: definition.tui ?? false, + }) if (!previous) continue const restored = yield* load(previous.plugin) - if (restored) { - active.set(definition.id, { plugin: previous.plugin, scope: restored }) + if (restored.scope !== undefined) { + active.set(definition.id, { plugin: previous.plugin, scope: restored.scope }) continue } yield* Effect.logError("failed to restore plugin; deactivating", { @@ -119,6 +143,7 @@ const layer = Layer.effect( yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void).pipe(Effect.ignore), { discard: true, }) + inventory = [...nextInventory, ...failures] }), ) yield* bus.publish(Plugin.Event.Updated, {}) @@ -136,7 +161,7 @@ const layer = Layer.effect( const service = Service.of({ activate, list: Effect.fn("Plugin.list")(function* () { - return Array.from(active.keys()).map((id) => ({ id })) + return inventory }), }) host = yield* PluginHost.make(service) @@ -144,6 +169,15 @@ const layer = Layer.effect( }), ) +function activeInfo(plugin: Versioned): Plugin.Info { + return { + id: Plugin.ID.make(plugin.id), + source: plugin.source ?? { type: "builtin" }, + status: "active", + tui: plugin.tui ?? false, + } +} + export const node = makeLocationNode({ service: Service, layer, diff --git a/packages/core/src/plugin/sdk.ts b/packages/core/src/plugin/sdk.ts index c4c153a70fe..47d8b203748 100644 --- a/packages/core/src/plugin/sdk.ts +++ b/packages/core/src/plugin/sdk.ts @@ -35,7 +35,7 @@ export const layer = Layer.effect( return Service.of({ register: (plugin) => Effect.sync(() => { - plugins.set(plugin.id, { ...plugin, version: String(++revision) }) + plugins.set(plugin.id, { ...plugin, version: String(++revision), source: { type: "sdk" } }) }).pipe(Effect.andThen(bus.publish(Updated, {})), Effect.asVoid), all: () => [...plugins.values()], }) diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index b45df0f9681..d8e5c4c1929 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/packages/core/src/plugin/supervisor.ts @@ -2,7 +2,7 @@ export * as PluginSupervisor from "./supervisor.js" import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin" import { Event } from "@opencode-ai/schema/config" -import { Context, Deferred, Effect, Layer, Schema, Stream } from "effect" +import { Cause, Context, Deferred, Effect, Layer, Schema, Stream } from "effect" import path from "path" import { pathToFileURL } from "url" import { ConfigPluginSource } from "../config/plugin/source.js" @@ -19,12 +19,14 @@ const PluginModule = Schema.Struct({ default: Schema.Union([ Schema.Struct({ id: Schema.String, + tui: Schema.optional(Schema.Boolean), effect: Schema.declare( (input): input is PluginDefinition["effect"] => typeof input === "function", ), }), Schema.Struct({ id: Schema.String, + tui: Schema.optional(Schema.Boolean), setup: Schema.declare[0]["setup"]>( (input): input is Parameters[0]["setup"] => typeof input === "function", ), @@ -42,10 +44,12 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* ( const definitions = [...pre, ...post] const enabled = new Set(definitions.map((plugin) => plugin.id)) const packages = new Map() + const failures = new Map>() const plugins = () => [...definitions, ...packages.values()] for (const operation of operations) { if (operation.type === "remove") { + if (operation.target === "*") failures.clear() plugins() .filter((plugin) => matches(operation.target, plugin.id)) .forEach((plugin) => enabled.delete(plugin.id)) @@ -65,21 +69,35 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* ( const plugin = yield* load(operation).pipe( Effect.catchCause((cause) => - Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(Effect.as(undefined)), + Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe( + Effect.as({ error: Cause.pretty(cause) }), + ), ), ) - if (!plugin) continue + if ("error" in plugin) { + failures.set(operation.target, { + source: pluginSource(operation.target), + status: "failed", + error: plugin.error, + tui: false, + }) + continue + } + failures.delete(operation.target) const previous = packages.get(operation.target) if (previous) enabled.delete(previous.id) packages.set(operation.target, plugin) enabled.add(plugin.id) } - return [ - ...pre.filter((plugin) => enabled.has(plugin.id)), - ...Array.from(packages.values()).filter((plugin) => enabled.has(plugin.id)), - ...post.filter((plugin) => enabled.has(plugin.id)), - ] + return { + plugins: [ + ...pre.filter((plugin) => enabled.has(plugin.id)), + ...Array.from(packages.values()).filter((plugin) => enabled.has(plugin.id)), + ...post.filter((plugin) => enabled.has(plugin.id)), + ], + failures: [...failures.values()], + } }) const load = Effect.fn("PluginSupervisor.load")(function* ( @@ -89,7 +107,7 @@ const load = Effect.fn("PluginSupervisor.load")(function* ( const entrypoint = path.isAbsolute(operation.target) ? pathToFileURL(operation.target).href : (yield* npm.add(operation.target, { subpaths: ["server", ""] })).entrypoint - if (!entrypoint) return + if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`)) // Bun currently ignores query parameters when caching file:// imports. const source = operation.mtime === undefined @@ -103,7 +121,9 @@ const load = Effect.fn("PluginSupervisor.load")(function* ( const plugin = "effect" in value ? value : PluginPromise.fromPromise(value) return { id: plugin.id, + tui: plugin.tui, version: JSON.stringify(operation), + source: pluginSource(operation.target), effect: (host) => plugin.effect({ ...host, options: operation.options }), } satisfies Plugin.Versioned }) @@ -129,13 +149,20 @@ export const layer = Layer.effect( // Resolve OpenCode's internal plugins with their privileged Location services. const internal = yield* PluginInternal.list() // Combine internal plugins with host-contributed SDK plugins in boot order. - const pre = [...internal.pre.map((plugin) => ({ ...plugin, version: "internal" })), ...sdk.all()] - const post = internal.post.map((plugin) => ({ ...plugin, version: "internal" })) + const pre = [ + ...internal.pre.map((plugin) => ({ ...plugin, version: "internal", source: { type: "builtin" as const } })), + ...sdk.all(), + ] + const post = internal.post.map((plugin) => ({ + ...plugin, + version: "internal", + source: { type: "builtin" as const }, + })) const operations = yield* sources.operations() // Apply config operations and load enabled package plugins into one ordered generation. - const plugins = yield* resolve(pre, post, operations) + const resolved = yield* resolve(pre, post, operations) // Replace the active generation in one scoped, batched activation. - yield* registry.activate(plugins) + yield* registry.activate(resolved.plugins, resolved.failures) }) const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe( // Make accepted work visible to flush before coalescing the burst. @@ -172,4 +199,9 @@ const nodeDeps = [ PluginInternal.requirements, ] as const +function pluginSource(target: string): Plugin.Source { + if (path.isAbsolute(target)) return { type: "local", path: target } + return { type: "package", package: target } +} + export const node = makeLocationNode({ service: Service, layer, deps: nodeDeps }) diff --git a/packages/core/test/config/plugin.test.ts b/packages/core/test/config/plugin.test.ts index 68f39d6487c..34184bccd79 100644 --- a/packages/core/test/config/plugin.test.ts +++ b/packages/core/test/config/plugin.test.ts @@ -44,7 +44,9 @@ describe("PluginSupervisor config", () => { const plugins = yield* Plugin.Service yield* ready() expect( - (yield* plugins.list()).map((plugin) => plugin.id).filter((id) => id.startsWith("opencode.provider.")), + (yield* plugins.list()) + .flatMap((plugin) => (plugin.id ? [plugin.id] : [])) + .filter((id) => id.startsWith("opencode.provider.")), ).toEqual([Plugin.ID.make("opencode.provider.openai")]) }), ), @@ -64,10 +66,20 @@ describe("PluginSupervisor config", () => { Effect.gen(function* () { yield* ready() const agents = yield* Agent.Service + const plugins = yield* Plugin.Service expect(yield* agents.get(Agent.ID.make("configured"))).toMatchObject({ description: "Loaded from config", mode: "subagent", }) + expect((yield* plugins.list()).find((plugin) => plugin.id === "config-promise-plugin")).toEqual({ + id: Plugin.ID.make("config-promise-plugin"), + source: { + type: "local", + path: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"), + }, + status: "active", + tui: true, + }) }), ), ) @@ -143,6 +155,7 @@ describe("PluginSupervisor config", () => { Effect.gen(function* () { yield* ready() const agents = yield* Agent.Service + const plugins = yield* Plugin.Service expect(yield* agents.get(Agent.ID.make("configured"))).toMatchObject({ description: "Loaded after invalid plugins", }) @@ -150,6 +163,12 @@ describe("PluginSupervisor config", () => { path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"), path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts"), ]) + expect( + (yield* plugins.list()).filter((plugin) => plugin.status === "failed").map((plugin) => plugin.source), + ).toEqual([ + { type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts") }, + { type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts") }, + ]) }), ).pipe(Effect.provide(Logger.layer([logger]))) }) @@ -246,10 +265,12 @@ describe("PluginSupervisor config", () => { Effect.gen(function* () { yield* ready() const plugins = yield* Plugin.Service - const ids = (yield* plugins.list()).map((plugin) => String(plugin.id)) + const inventory = yield* plugins.list() + const ids = inventory.map((plugin) => String(plugin.id)) expect(ids).toContain("opencode.agent") expect(ids).toContain("static-sdk") expect(ids).not.toContain("config-promise-plugin") + expect(inventory.find((plugin) => plugin.id === "static-sdk")?.source).toEqual({ type: "sdk" }) const agents = yield* Agent.Service expect(yield* agents.get(Agent.ID.make("directory"))).toBeUndefined() diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index 9277d44b98b..f325e766b62 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -89,13 +89,7 @@ describe("Plugin", () => { yield* host.mcp.connect({ location, server: "routed" }).pipe(Effect.orDie) yield* host.mcp.disconnect({ location, server: "routed" }).pipe(Effect.orDie) expect((yield* host.mcp.list({ location }).pipe(Effect.orDie)).location.directory).toBe(target) - expect(routed).toEqual([ - "add:/target", - "remove:/target", - "connect:/target", - "disconnect:/target", - "list:/target", - ]) + expect(routed).toEqual(["add:/target", "remove:/target", "connect:/target", "disconnect:/target", "list:/target"]) }), ) @@ -138,9 +132,22 @@ describe("Plugin", () => { expect(updates).toBe(2) expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("second") + yield* plugins.activate( + [versioned(managed(), "2")], + [ + { + source: { type: "package", package: "broken" }, + status: "failed", + error: "failed to resolve", + tui: false, + }, + ], + ) + expect(updates).toBe(3) + yield* plugins.activate([]) expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined() - expect(updates).toBe(3) + expect(updates).toBe(4) yield* unsubscribe }), ) @@ -160,7 +167,7 @@ describe("Plugin", () => { .pipe(Effect.exit) expect(Exit.isFailure(result)).toBe(true) - expect(yield* plugins.list()).toEqual([{ id: active }]) + expect(yield* plugins.list()).toEqual([{ id: active, source: { type: "builtin" }, status: "active", tui: false }]) }), ) @@ -189,12 +196,24 @@ describe("Plugin", () => { }) yield* plugins.activate([versioned(good), versioned(bad)]) - expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }]) + expect(yield* plugins.list()).toEqual([ + { id: Plugin.ID.make("good"), source: { type: "builtin" }, status: "active", tui: false }, + { + id: Plugin.ID.make("bad"), + source: { type: "builtin" }, + status: "failed", + error: expect.stringContaining("materialization failed"), + tui: false, + }, + ]) expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("loaded") fail = false yield* plugins.activate([versioned(good), versioned(bad, "2")]) - expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }, { id: Plugin.ID.make("bad") }]) + expect(yield* plugins.list()).toEqual([ + { id: Plugin.ID.make("good"), source: { type: "builtin" }, status: "active", tui: false }, + { id: Plugin.ID.make("bad"), source: { type: "builtin" }, status: "active", tui: false }, + ]) }), ) @@ -229,7 +248,15 @@ describe("Plugin", () => { yield* plugins.activate([versioned(previous)]) yield* plugins.activate([versioned(replacement, "2")]) - expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("managed") }]) + expect(yield* plugins.list()).toEqual([ + { + id: Plugin.ID.make("managed"), + source: { type: "builtin" }, + status: "failed", + error: expect.stringContaining("replacement failed"), + tui: false, + }, + ]) expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("previous") }), ) @@ -261,7 +288,15 @@ describe("Plugin", () => { yield* plugins.activate([versioned(previous)]) yield* plugins.activate([versioned(replacement, "2")]) - expect(yield* plugins.list()).toEqual([]) + expect(yield* plugins.list()).toEqual([ + { + id: Plugin.ID.make("managed"), + source: { type: "builtin" }, + status: "failed", + error: expect.stringContaining("replacement failed"), + tui: false, + }, + ]) expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined() }), ) diff --git a/packages/core/test/plugin/fixtures/config-promise-plugin.ts b/packages/core/test/plugin/fixtures/config-promise-plugin.ts index 91f4a1b176e..1e8a0a9fdd6 100644 --- a/packages/core/test/plugin/fixtures/config-promise-plugin.ts +++ b/packages/core/test/plugin/fixtures/config-promise-plugin.ts @@ -2,6 +2,7 @@ import { Plugin } from "@opencode-ai/plugin" export default Plugin.define({ id: "config-promise-plugin", + tui: true, setup: async (ctx) => { await ctx.agent.transform((agents) => { agents.update("configured", (agent) => { diff --git a/packages/plugin/src/effect/plugin.ts b/packages/plugin/src/effect/plugin.ts index 78c630083d3..bd427034655 100644 --- a/packages/plugin/src/effect/plugin.ts +++ b/packages/plugin/src/effect/plugin.ts @@ -37,6 +37,7 @@ export interface Context { export interface Plugin { readonly id: string + readonly tui?: boolean readonly effect: (context: Context) => Effect.Effect } diff --git a/packages/plugin/src/promise/adapter.ts b/packages/plugin/src/promise/adapter.ts index 32333056838..13164d42ac9 100644 --- a/packages/plugin/src/promise/adapter.ts +++ b/packages/plugin/src/promise/adapter.ts @@ -67,6 +67,7 @@ function compileEndpoint(endpoint: HttpApiEndpoint.Top) { export function fromPromise(plugin: Plugin) { return define({ id: plugin.id, + tui: plugin.tui, effect: (host) => Effect.gen(function* () { const [{ ClientApi }, { OpenCodeEvent }] = yield* Effect.promise(() => diff --git a/packages/plugin/src/promise/plugin.ts b/packages/plugin/src/promise/plugin.ts index 9a05f1c4600..3448ac3184f 100644 --- a/packages/plugin/src/promise/plugin.ts +++ b/packages/plugin/src/promise/plugin.ts @@ -38,6 +38,7 @@ export type Cleanup = () => Promise | void export interface Plugin { readonly id: string + readonly tui?: boolean readonly setup: (context: Context) => Promise | Cleanup | void } diff --git a/packages/protocol/src/groups/plugin.ts b/packages/protocol/src/groups/plugin.ts index daf17c1778b..4483443054e 100644 --- a/packages/protocol/src/groups/plugin.ts +++ b/packages/protocol/src/groups/plugin.ts @@ -15,7 +15,7 @@ export const PluginGroup = HttpApiGroup.make("server.plugin") OpenApi.annotations({ identifier: "v2.plugin.list", summary: "List plugins", - description: "Retrieve currently loaded plugins.", + description: "Retrieve enabled server plugins and their current status.", }), ), ) diff --git a/packages/schema/src/plugin.ts b/packages/schema/src/plugin.ts index 4a003f1199e..9d3c9e80320 100644 --- a/packages/schema/src/plugin.ts +++ b/packages/schema/src/plugin.ts @@ -2,14 +2,35 @@ export * as Plugin from "./plugin.js" import { Schema } from "effect" import { ephemeral, inventory } from "./event.js" +import { optional } from "./schema.js" export const ID = Schema.String.pipe(Schema.brand("Plugin.ID")) export type ID = typeof ID.Type -export interface Info extends Schema.Schema.Type {} -export const Info = Schema.Struct({ - id: ID, -}).annotate({ identifier: "Plugin.Info" }) +export const Source = Schema.Union([ + Schema.Struct({ type: Schema.Literal("builtin") }), + Schema.Struct({ type: Schema.Literal("package"), package: Schema.String }), + Schema.Struct({ type: Schema.Literal("local"), path: Schema.String }), + Schema.Struct({ type: Schema.Literal("sdk") }), +]).annotate({ identifier: "Plugin.Source" }) +export type Source = typeof Source.Type + +export const Info = Schema.Union([ + Schema.Struct({ + id: ID, + source: Source, + status: Schema.Literal("active"), + tui: Schema.Boolean, + }), + Schema.Struct({ + id: ID.pipe(optional), + source: Source, + status: Schema.Literal("failed"), + error: Schema.String, + tui: Schema.Boolean, + }), +]).annotate({ identifier: "Plugin.Info" }) +export type Info = typeof Info.Type const Added = ephemeral({ type: "plugin.added", diff --git a/packages/tui/src/component/dialog-error-details.tsx b/packages/tui/src/component/dialog-error-details.tsx index b0507bcdf6a..75e7b0055d3 100644 --- a/packages/tui/src/component/dialog-error-details.tsx +++ b/packages/tui/src/component/dialog-error-details.tsx @@ -1,16 +1,14 @@ import { CliRenderEvents, TextAttributes, type ScrollBoxRenderable } from "@opentui/core" import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" -import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" +import { createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { useConfig } from "../config" import { useClipboard } from "../context/clipboard" import { Keymap } from "../context/keymap" import { getScrollAcceleration } from "../util/scroll" -import { useDialog } from "../ui/dialog" import { useTheme } from "../context/theme" import { useToast } from "../ui/toast" export function DialogErrorDetails(props: { title: string; error: string; onBack: () => void }) { - const dialog = useDialog() const clipboard = useClipboard() const toast = useToast() const theme = useTheme("elevated") @@ -20,19 +18,21 @@ export function DialogErrorDetails(props: { title: string; error: string; onBack const config = useConfig().data const [copied, setCopied] = createSignal(false) const [scrollable, setScrollable] = createSignal(false) - const height = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5)) + const [height, setHeight] = createSignal(1) + const maxHeight = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5)) let scroll: ScrollBoxRenderable | undefined let measure: (() => void) | undefined - onMount(() => dialog.setSize("large")) - createEffect(() => { dimensions() props.error if (measure) renderer.off(CliRenderEvents.FRAME, measure) measure = () => { measure = undefined - setScrollable(Boolean(scroll && scroll.scrollHeight > scroll.viewport.height)) + if (!scroll) return + const next = Math.max(1, Math.min(maxHeight(), scroll.scrollHeight)) + setHeight(next) + setScrollable(scroll.scrollHeight > next) } renderer.once(CliRenderEvents.FRAME, measure) renderer.requestRender() @@ -61,15 +61,15 @@ export function DialogErrorDetails(props: { title: string; error: string; onBack if (!scrollable()) return if (event.name === "up") return scroll?.scrollBy(-1) if (event.name === "down") return scroll?.scrollBy(1) - if (event.name === "pageup") return scroll?.scrollBy(-height()) - if (event.name === "pagedown") return scroll?.scrollBy(height()) + if (event.name === "pageup") return scroll?.scrollBy(-maxHeight()) + if (event.name === "pagedown") return scroll?.scrollBy(maxHeight()) if (event.name === "home") return scroll?.scrollTo(0) if (event.name === "end" && scroll) return scroll.scrollTo(scroll.scrollHeight) }) return ( - - + + {props.title} @@ -77,7 +77,6 @@ export function DialogErrorDetails(props: { title: string; error: string; onBack esc - ✗ Failed - + {scrollable() ? "↑/↓" : ""} diff --git a/packages/tui/src/feature-plugins/system/plugins.tsx b/packages/tui/src/feature-plugins/system/plugins.tsx index bc42becc43e..501455496f0 100644 --- a/packages/tui/src/feature-plugins/system/plugins.tsx +++ b/packages/tui/src/feature-plugins/system/plugins.tsx @@ -1,68 +1,114 @@ +import type { PluginInfo } from "@opencode-ai/client" import { Plugin } from "@opencode-ai/plugin/tui" -import { createEffect, createMemo, createSignal, Show } from "solid-js" +import { createEffect, createMemo, createResource, createSignal, onMount, Show } from "solid-js" +import { DialogErrorDetails } from "../../component/dialog-error-details" import { usePlugin } from "../../plugin/context" import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select" import { useDialog } from "../../ui/dialog" -import { DialogErrorDetails } from "../../component/dialog-error-details" const id = "opencode.plugins" -function View(props: { context: Plugin.Context; plugins: ReturnType }) { +type Entry = + | { readonly key: string; readonly runtime: "server"; readonly plugin: PluginInfo } + | { + readonly key: string + readonly runtime: "tui" + readonly id?: string + readonly target: string + readonly status: "active" | "inactive" | "failed" + readonly error?: string + } + +export function PluginsDialog(props: { + context: Plugin.Context + plugins: ReturnType + server?: () => readonly PluginInfo[] +}) { + const dialog = useDialog() const [locked, setLocked] = createSignal(false) const [focused, setFocused] = createSignal() - const [detail, setDetail] = createSignal<{ title: string; error: string }>() - const dialog = useDialog() - const options = createMemo(() => { - const builtins = props.plugins + const [detail, setDetail] = createSignal() + const [initial, setInitial] = createSignal() + const [server] = createResource( + () => (props.server ? undefined : (props.context.location ?? props.context.data.location.default())), + (location) => props.context.client.plugin.list({ location }).then((result) => result.data), + ) + onMount(() => dialog.setSize("medium")) + const entries = createMemo(() => { + const builtins: Entry[] = props.plugins .registered() .filter((plugin) => plugin.id !== id && plugin.source === "builtin") - .map( - (plugin): DialogSelectOption => ({ - title: plugin.id, - value: plugin.id, - category: "Built-in", - footer: plugin.active ? "active" : "inactive", - footerColor: plugin.active - ? props.context.theme.text.feedback.success.default - : props.context.theme.text.subdued, - }), - ) - const external = props.plugins + .map((plugin) => ({ + key: `tui:${plugin.id}`, + runtime: "tui" as const, + id: plugin.id, + target: plugin.id, + status: plugin.active ? ("active" as const) : ("inactive" as const), + })) + const external: Entry[] = props.plugins .list() .filter((plugin) => plugin.status !== "unsupported") - .map( - (plugin): DialogSelectOption => ({ - title: plugin.id ?? plugin.target, - value: plugin.id ?? plugin.target, - category: "External", - searchText: plugin.target, - footer: plugin.status, - footerColor: - plugin.status === "active" - ? props.context.theme.text.feedback.success.default - : plugin.status === "failed" - ? props.context.theme.text.feedback.error.default - : props.context.theme.text.subdued, - }), - ) - return [...builtins, ...external].sort((a, b) => a.title.localeCompare(b.title)) + .map((plugin) => ({ + key: `tui:${plugin.id ?? plugin.target}`, + runtime: "tui" as const, + id: plugin.id, + target: plugin.target, + status: plugin.status, + error: plugin.status === "failed" ? plugin.error : undefined, + })) + const serverEntries: Entry[] = (props.server?.() ?? server() ?? []).map((plugin) => ({ + key: `server:${plugin.id ?? source(plugin, props.context)}`, + runtime: "server" as const, + plugin, + })) + return [ + ...[...builtins, ...external].sort((a, b) => label(a, props.context).localeCompare(label(b, props.context))), + ...serverEntries.sort((a, b) => label(a, props.context).localeCompare(label(b, props.context))), + ] }) - - const failure = (value: string | undefined) => - props.plugins.list().find((plugin) => { - if (plugin.status !== "failed") return false - return (plugin.id ?? plugin.target) === value - }) - createEffect(() => { - if (focused()) return - const first = options()[0] - if (first) setFocused(first.value) + if (initial()) return + const first = entries().find((entry) => entry.runtime === "tui") + if (!first) return + setInitial(first.key) + setFocused(first.key) }) - const toggle = (plugin: DialogSelectOption) => { - if (locked()) return - const current = props.plugins.registered().find((item) => item.id === plugin.value) + const options = createMemo(() => + entries().map( + (entry): DialogSelectOption => ({ + title: label(entry, props.context), + value: entry.key, + category: entry.runtime === "tui" ? "TUI" : "Server", + searchText: entry.runtime === "tui" ? entry.target : source(entry.plugin, props.context), + footer: status(entry) === "active" ? undefined : status(entry), + footerColor: + status(entry) === "failed" + ? props.context.theme.text.feedback.error.default + : props.context.theme.text.subdued, + gutter: + status(entry) === "active" + ? () => + : status(entry) === "failed" + ? () => + : undefined, + }), + ), + ) + const focusedEntry = createMemo(() => entries().find((entry) => entry.key === focused())) + const focusedTui = createMemo(() => { + const entry = focusedEntry() + if (entry?.runtime !== "tui" || !entry.id) return + return entry + }) + const toggleTitle = createMemo(() => { + const entry = focusedTui() + if (!entry) return "toggle" + return props.plugins.registered().find((plugin) => plugin.id === entry.id)?.active ? "disable" : "enable" + }) + const toggle = (entry: Entry | undefined) => { + if (locked() || entry?.runtime !== "tui" || !entry.id) return + const current = props.plugins.registered().find((plugin) => plugin.id === entry.id) if (!current) return setLocked(true) void (current.active ? props.plugins.deactivate(current.id) : props.plugins.activate(current.id)) @@ -70,21 +116,15 @@ function View(props: { context: Plugin.Context; plugins: ReturnType { + .catch((cause) => { props.context.ui.toast.show({ variant: "error", - message: error instanceof Error ? error.message : String(error), + message: cause instanceof Error ? cause.message : String(cause), }) }) .finally(() => setLocked(false)) } - const select = (plugin: DialogSelectOption) => { - const failed = failure(plugin.value) - if (!failed || failed.status !== "failed") return toggle(plugin) - setDetail({ title: failed.target, error: failed.error }) - } - return ( setFocused(option.value)} - actions={[ - { - title: "toggle", - command: "plugins.toggle", - disabled: (option) => { - const failed = failure(option?.value) - return Boolean(failed && !("id" in failed && failed.id)) - }, - onTrigger: toggle, - }, - ]} - onSelect={select} + onSelect={(option) => { + const entry = entries().find((entry) => entry.key === option.value) + if (pluginError(entry)) setDetail(entry) + }} + actions={ + focusedTui() + ? [ + { + title: toggleTitle(), + command: "plugins.toggle", + onTrigger: (option) => toggle(entries().find((entry) => entry.key === option.value)), + }, + ] + : [] + } footer={ - - enter to view error + + + + enter + + view error + } /> } > - {(item) => ( + {(entry) => ( { setDetail() dialog.setSize("medium") @@ -131,6 +180,27 @@ function View(props: { context: Plugin.Context; plugins: ReturnType ({ @@ -143,7 +213,7 @@ function Commands(props: { context: Plugin.Context }) { slash: { name: "plugins" }, palette: true, run() { - props.context.ui.dialog.show(() => ) + props.context.ui.dialog.show(() => ) }, }, ],