From 068c32df39b9301ee525831d0cac4ff49d71722a Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 28 Jul 2026 20:20:24 -0400 Subject: [PATCH] feat(tui): discover project plugins --- .opencode/plugins/tui/discovery-smoke.ts | 16 +++++++++++++ packages/tui/src/plugin/context.tsx | 3 ++- packages/tui/src/plugin/discovery.ts | 16 +++++++++++++ packages/tui/test/plugin-discovery.test.ts | 27 ++++++++++++++++++++++ 4 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 .opencode/plugins/tui/discovery-smoke.ts create mode 100644 packages/tui/src/plugin/discovery.ts create mode 100644 packages/tui/test/plugin-discovery.test.ts diff --git a/.opencode/plugins/tui/discovery-smoke.ts b/.opencode/plugins/tui/discovery-smoke.ts new file mode 100644 index 00000000000..f0144918f12 --- /dev/null +++ b/.opencode/plugins/tui/discovery-smoke.ts @@ -0,0 +1,16 @@ +import type { Context } from "../../../packages/plugin/src/tui/context" + +export default { + id: "test.tui-discovery-smoke", + setup(context: Context) { + const timer = setTimeout(() => { + context.ui.toast.show({ + title: "TUI plugin discovery works", + message: "Loaded .opencode/plugins/tui/discovery-smoke.ts", + variant: "success", + duration: 30_000, + }) + }, 1_000) + return () => clearTimeout(timer) + }, +} diff --git a/packages/tui/src/plugin/context.tsx b/packages/tui/src/plugin/context.tsx index ad6c394ecff..7fd02f98646 100644 --- a/packages/tui/src/plugin/context.tsx +++ b/packages/tui/src/plugin/context.tsx @@ -33,6 +33,7 @@ import { useToast } from "../ui/toast" import { useAttention } from "../context/attention" import { abbreviateHome } from "../util/path-format" import { builtins } from "./builtins" +import { discoverTuiPlugins } from "./discovery" export interface PackageResolver { readonly resolve: (spec: string) => Promise @@ -353,7 +354,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }> .filter(([, registration]) => registration.active) .map(([id]) => deactivate(id)), ) - const entries = config.data.plugins ?? [] + const entries = [...(await discoverTuiPlugins(paths.cwd)), ...(config.data.plugins ?? [])] batch(() => { setStore("registrations", reconcileStore({})) setStore("states", []) diff --git a/packages/tui/src/plugin/discovery.ts b/packages/tui/src/plugin/discovery.ts new file mode 100644 index 00000000000..fb81da1244d --- /dev/null +++ b/packages/tui/src/plugin/discovery.ts @@ -0,0 +1,16 @@ +import { readdir } from "node:fs/promises" +import path from "node:path" + +const extensions = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"]) + +export async function discoverTuiPlugins(cwd: string) { + const directory = path.join(cwd, ".opencode", "plugins", "tui") + const entries = await readdir(directory, { withFileTypes: true }).catch((error: unknown) => { + if (error && typeof error === "object" && Reflect.get(error, "code") === "ENOENT") return [] + return Promise.reject(error) + }) + return entries + .filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && extensions.has(path.extname(entry.name))) + .map((entry) => path.join(directory, entry.name)) + .sort() +} diff --git a/packages/tui/test/plugin-discovery.test.ts b/packages/tui/test/plugin-discovery.test.ts new file mode 100644 index 00000000000..3c3c85fcf99 --- /dev/null +++ b/packages/tui/test/plugin-discovery.test.ts @@ -0,0 +1,27 @@ +import { mkdir, writeFile } from "node:fs/promises" +import path from "node:path" +import { expect, test } from "bun:test" +import { discoverTuiPlugins } from "../src/plugin/discovery" +import { tmpdir } from "./fixture/fixture" + +test("discovers project TUI plugin files in stable order", async () => { + await using tmp = await tmpdir() + const directory = path.join(tmp.path, ".opencode", "plugins", "tui") + await mkdir(path.join(directory, "nested"), { recursive: true }) + await Promise.all([ + writeFile(path.join(directory, "second.tsx"), "export default {}"), + writeFile(path.join(directory, "first.js"), "export default {}"), + writeFile(path.join(directory, "ignored.json"), "{}"), + writeFile(path.join(directory, "nested", "ignored.ts"), "export default {}"), + ]) + + expect(await discoverTuiPlugins(tmp.path)).toEqual([ + path.join(directory, "first.js"), + path.join(directory, "second.tsx"), + ]) +}) + +test("returns no project TUI plugins when the directory is absent", async () => { + await using tmp = await tmpdir() + expect(await discoverTuiPlugins(tmp.path)).toEqual([]) +})