From e1e0d65db22b77ce4bcc6027041e3ccccee9b785 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 14 Sep 2026 21:20:33 -0700 Subject: [PATCH 1/2] fix(tui): deduplicate plugin targets --- packages/tui/src/plugin/context.tsx | 39 ++++++++++++---------- packages/tui/src/plugin/discovery.ts | 17 +++++++++- packages/tui/test/plugin-discovery.test.ts | 18 ++++++++++ 3 files changed, 55 insertions(+), 19 deletions(-) create mode 100644 packages/tui/test/plugin-discovery.test.ts diff --git a/packages/tui/src/plugin/context.tsx b/packages/tui/src/plugin/context.tsx index 1c756f805574..614070ac9c7c 100644 --- a/packages/tui/src/plugin/context.tsx +++ b/packages/tui/src/plugin/context.tsx @@ -29,7 +29,7 @@ import { useData } from "../context/data" import { errorMessage } from "../util/error" import { createPluginContext, usePluginHost, type Dispose, type RegisteredSlot, type SlotRender } from "./api" import { createSourceWatcher } from "./watch" -import { discoverPluginTargets, localSource } from "./discovery" +import { deduplicatePluginTargets, discoverPluginTargets, localSource } from "./discovery" import { createPluginSources } from "./source" import { isMissingPath } from "../util/config-directories" import { createMarkdownRenderer } from "./markdown" @@ -285,23 +285,26 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir await trace("watch", { reconciliation: id, directories: props.directories }, () => Promise.all(props.directories.map(watcher.wait)).then(() => undefined), ) - const entries = [ - ...( - await trace("discover", { reconciliation: id, directories: props.directories }, () => - discoverPluginTargets(props.directories), - ) - ).map((entry) => ({ - entry, - install: true, - optional: true, - })), - ...serverTuiPlugins().map((plugin) => ({ - entry: plugin.source.type === "package" ? plugin.source.target : path.dirname(plugin.source.path), - install: false, - optional: true, - })), - ...(config.data.plugins ?? []).map((entry) => ({ entry, install: true, optional: false })), - ] + const entries = deduplicatePluginTargets( + [ + ...( + await trace("discover", { reconciliation: id, directories: props.directories }, () => + discoverPluginTargets(props.directories), + ) + ).map((entry) => ({ + entry, + install: true, + optional: true, + })), + ...serverTuiPlugins().map((plugin) => ({ + entry: plugin.source.type === "package" ? plugin.source.target : path.dirname(plugin.source.path), + install: false, + optional: true, + })), + ...(config.data.plugins ?? []).map((entry) => ({ entry, install: true, optional: false })), + ], + directory, + ) // Resolve: fold entries into one desired generation. A source that fails // to import keeps its running previous version and only reports failure. diff --git a/packages/tui/src/plugin/discovery.ts b/packages/tui/src/plugin/discovery.ts index 6fd42bc208c3..33fbea657006 100644 --- a/packages/tui/src/plugin/discovery.ts +++ b/packages/tui/src/plugin/discovery.ts @@ -1,8 +1,10 @@ import { readdir, stat } from "node:fs/promises" import path from "node:path" -export { localSource } from "@opencode/plugin/source" +import { localSource } from "@opencode/plugin/source" import { isMissingPath, localProjectDirectory, projectConfigDirectories } from "../util/config-directories" +export { localSource } + export async function localPluginDirectories(cwd: string, configDirectory: string) { const projectDirectory = await localProjectDirectory(cwd) const projectConfig = path.join(projectDirectory, ".opencode") @@ -49,3 +51,16 @@ export async function discoverPluginTargets(directories: string[]) { ) ).flat() } + +export function deduplicatePluginTargets< + const Target extends { readonly entry: string | { readonly package: string } }, +>(targets: readonly Target[], directory: string) { + return [ + ...targets + .reduce((result, target) => { + const value = typeof target.entry === "string" ? target.entry : target.entry.package + return result.set(localSource(value, directory)?.href ?? value, target) + }, new Map()) + .values(), + ] +} diff --git a/packages/tui/test/plugin-discovery.test.ts b/packages/tui/test/plugin-discovery.test.ts new file mode 100644 index 000000000000..9b6ce73f167b --- /dev/null +++ b/packages/tui/test/plugin-discovery.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from "bun:test" +import { deduplicatePluginTargets } from "../src/plugin/discovery" + +test("deduplicates equivalent local plugin targets while retaining the final source", () => { + const directory = "/project" + const discovered = { entry: "/project/.opencode/plugins/example", install: true, optional: true } + const server = { entry: "./.opencode/plugins/example", install: false, optional: true } + + expect(deduplicatePluginTargets([discovered, server], directory)).toEqual([server]) +}) + +test("preserves the first target position when a later source overrides it", () => { + const first = { entry: "example", install: true, optional: true } + const other = { entry: "other", install: true, optional: true } + const configured = { entry: { package: "example", options: { enabled: true } }, install: true, optional: false } + + expect(deduplicatePluginTargets([first, other, configured], "/project")).toEqual([configured, other]) +}) From 7c4eb7702152eb3cec4ae7b63718b56df654af45 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 15 Sep 2026 08:37:41 -0700 Subject: [PATCH 2/2] refactor(tui): clarify plugin target merge --- packages/tui/src/plugin/context.tsx | 6 ++++-- packages/tui/src/plugin/discovery.ts | 7 ++++--- packages/tui/test/plugin-discovery.test.ts | 6 +++--- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/tui/src/plugin/context.tsx b/packages/tui/src/plugin/context.tsx index 614070ac9c7c..31c4f4cea0b0 100644 --- a/packages/tui/src/plugin/context.tsx +++ b/packages/tui/src/plugin/context.tsx @@ -29,7 +29,7 @@ import { useData } from "../context/data" import { errorMessage } from "../util/error" import { createPluginContext, usePluginHost, type Dispose, type RegisteredSlot, type SlotRender } from "./api" import { createSourceWatcher } from "./watch" -import { deduplicatePluginTargets, discoverPluginTargets, localSource } from "./discovery" +import { discoverPluginTargets, localSource, mergePluginTargets } from "./discovery" import { createPluginSources } from "./source" import { isMissingPath } from "../util/config-directories" import { createMarkdownRenderer } from "./markdown" @@ -285,7 +285,9 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir await trace("watch", { reconciliation: id, directories: props.directories }, () => Promise.all(props.directories.map(watcher.wait)).then(() => undefined), ) - const entries = deduplicatePluginTargets( + // Discovery admits TUI-only plugins while server inventory carries combined plugins. + // Their overlap is intentional; explicit configuration remains the final authority. + const entries = mergePluginTargets( [ ...( await trace("discover", { reconciliation: id, directories: props.directories }, () => diff --git a/packages/tui/src/plugin/discovery.ts b/packages/tui/src/plugin/discovery.ts index 33fbea657006..9116a3e556a1 100644 --- a/packages/tui/src/plugin/discovery.ts +++ b/packages/tui/src/plugin/discovery.ts @@ -52,9 +52,10 @@ export async function discoverPluginTargets(directories: string[]) { ).flat() } -export function deduplicatePluginTargets< - const Target extends { readonly entry: string | { readonly package: string } }, ->(targets: readonly Target[], directory: string) { +export function mergePluginTargets( + targets: readonly Target[], + directory: string, +) { return [ ...targets .reduce((result, target) => { diff --git a/packages/tui/test/plugin-discovery.test.ts b/packages/tui/test/plugin-discovery.test.ts index 9b6ce73f167b..709fe1d3106b 100644 --- a/packages/tui/test/plugin-discovery.test.ts +++ b/packages/tui/test/plugin-discovery.test.ts @@ -1,12 +1,12 @@ import { expect, test } from "bun:test" -import { deduplicatePluginTargets } from "../src/plugin/discovery" +import { mergePluginTargets } from "../src/plugin/discovery" test("deduplicates equivalent local plugin targets while retaining the final source", () => { const directory = "/project" const discovered = { entry: "/project/.opencode/plugins/example", install: true, optional: true } const server = { entry: "./.opencode/plugins/example", install: false, optional: true } - expect(deduplicatePluginTargets([discovered, server], directory)).toEqual([server]) + expect(mergePluginTargets([discovered, server], directory)).toEqual([server]) }) test("preserves the first target position when a later source overrides it", () => { @@ -14,5 +14,5 @@ test("preserves the first target position when a later source overrides it", () const other = { entry: "other", install: true, optional: true } const configured = { entry: { package: "example", options: { enabled: true } }, install: true, optional: false } - expect(deduplicatePluginTargets([first, other, configured], "/project")).toEqual([configured, other]) + expect(mergePluginTargets([first, other, configured], "/project")).toEqual([configured, other]) })