From bd406b96197bc51dc5ea934c9eb0b7b23680f800 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 31 Jul 2026 21:40:04 -0400 Subject: [PATCH] fix(tui): share runtime with external TSX plugins --- packages/tui/package.json | 5 ++ packages/tui/src/plugin/context.tsx | 6 +- packages/tui/src/plugin/loader.bun.ts | 44 ++++++++++++ packages/tui/src/plugin/loader.node.ts | 3 + packages/tui/test/plugin-reactivity.test.tsx | 72 ++++++++++++++++++++ 5 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 packages/tui/src/plugin/loader.bun.ts create mode 100644 packages/tui/src/plugin/loader.node.ts create mode 100644 packages/tui/test/plugin-reactivity.test.tsx diff --git a/packages/tui/package.json b/packages/tui/package.json index 215bd69a89..d4413cddff 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -77,6 +77,11 @@ "bun": "./src/plugin/runtime-plugin-support.bun.ts", "node": "./src/plugin/runtime-plugin-support.node.ts", "default": "./src/plugin/runtime-plugin-support.node.ts" + }, + "#plugin-loader": { + "bun": "./src/plugin/loader.bun.ts", + "node": "./src/plugin/loader.node.ts", + "default": "./src/plugin/loader.node.ts" } }, "dependencies": { diff --git a/packages/tui/src/plugin/context.tsx b/packages/tui/src/plugin/context.tsx index 34dcc3d33f..5a4328412b 100644 --- a/packages/tui/src/plugin/context.tsx +++ b/packages/tui/src/plugin/context.tsx @@ -7,6 +7,7 @@ import type { Page, Slot, SlotName } from "@opencode-ai/plugin/tui/context" import { createStore, produce, reconcile as reconcileStore } from "solid-js/store" import { isDeepEqual } from "remeda" import "#runtime-plugin-support" +import { preparePlugin } from "#plugin-loader" import { useConfig } from "../config" import { useTuiLifecycle } from "../context/runtime" import { errorMessage } from "../util/error" @@ -215,7 +216,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }> const memo = local ? undefined : npmFailures.get(target) const resolved = memo ? { status: "failed" as const, error: memo } - : await resolvePlugin(target, local, options, previous, props.packages).catch((error) => ({ + : await resolvePlugin(target, local, options, previous, props.packages, host.paths.state).catch((error) => ({ status: "failed" as const, error: errorMessage(error), })) @@ -439,6 +440,7 @@ async function resolvePlugin( options: Readonly> | undefined, previous: Registration | undefined, packages: PackageResolver, + state: string, ) { // Package entrypoints never change within a session, so a loaded previous // version needs no re-resolution (which could otherwise hit npm). @@ -451,7 +453,7 @@ async function resolvePlugin( const version = local ? freshSpecifier(entrypoint, (await stat(new URL(entrypoint))).mtimeMs) : entrypoint if (previous && previous.version === version && sameOptions(previous.options, options)) return { status: "unchanged" as const, plugin: previous.plugin, version } - const mod: { readonly default?: unknown } = await import(version) + const mod: { readonly default?: unknown } = await import(await preparePlugin(entrypoint, version, state)) if (!isPlugin(mod.default)) throw new Error(`Invalid V2 TUI plugin module: ${spec}`) return { status: "loaded" as const, plugin: mod.default, version } } diff --git a/packages/tui/src/plugin/loader.bun.ts b/packages/tui/src/plugin/loader.bun.ts new file mode 100644 index 0000000000..8844df65de --- /dev/null +++ b/packages/tui/src/plugin/loader.bun.ts @@ -0,0 +1,44 @@ +import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin" +import { isCoreRuntimeModuleSpecifier, runtimeModuleIdForSpecifier } from "@opentui/core/runtime-plugin" +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { fileURLToPath } from "node:url" + +const runtime = new Set([ + "@opentui/solid", + "@opentui/solid/components", + "@opentui/solid/jsx-runtime", + "@opentui/solid/jsx-dev-runtime", + "solid-js", + "solid-js/store", +]) + +export async function preparePlugin(entrypoint: string, version: string, state: string) { + const source = fileURLToPath(entrypoint) + if (!source.endsWith(".tsx") && !source.endsWith(".jsx")) return version + const result = await Bun.build({ + entrypoints: [source], + target: "bun", + format: "esm", + sourcemap: "inline", + plugins: [ + createSolidTransformPlugin({ + moduleName: runtimeModuleIdForSpecifier("@opentui/solid"), + resolvePath(specifier) { + if (!runtime.has(specifier) && !isCoreRuntimeModuleSpecifier(specifier)) return null + return runtimeModuleIdForSpecifier(specifier) + }, + }), + ], + external: [...runtime, "@opentui/core", "@opentui/core/testing"].flatMap((specifier) => [ + specifier, + runtimeModuleIdForSpecifier(specifier), + ]), + }) + if (!result.success) throw new Error(result.logs.join("\n")) + const directory = path.join(state, "tui-plugin-cache") + const output = path.join(directory, `${Bun.hash(version).toString(16)}.mjs`) + await mkdir(directory, { recursive: true }) + await Bun.write(output, result.outputs[0]!) + return output +} diff --git a/packages/tui/src/plugin/loader.node.ts b/packages/tui/src/plugin/loader.node.ts new file mode 100644 index 0000000000..92123d1ac6 --- /dev/null +++ b/packages/tui/src/plugin/loader.node.ts @@ -0,0 +1,3 @@ +export async function preparePlugin(_entrypoint: string, version: string, _state: string) { + return version +} diff --git a/packages/tui/test/plugin-reactivity.test.tsx b/packages/tui/test/plugin-reactivity.test.tsx new file mode 100644 index 0000000000..0a9a8d3051 --- /dev/null +++ b/packages/tui/test/plugin-reactivity.test.tsx @@ -0,0 +1,72 @@ +import { expect, test } from "bun:test" +import { createComponent, createSignal, type JSX } from "solid-js" +import { testRender } from "@opentui/solid" +import { mkdir, writeFile } from "node:fs/promises" +import path from "node:path" +import { preparePlugin } from "../src/plugin/loader.bun" +import "../src/plugin/runtime-plugin-support.bun" +import { tmpdir } from "./fixture/fixture" + +test("an external TSX plugin uses the host Solid runtime", async () => { + await using tmp = await tmpdir() + const root = path.join(tmp.path, ".opencode") + const source = path.join(root, "plugins", "tui", "reactive.tsx") + const helper = path.join(root, "plugins", "tui", "signal.ts") + const localSolid = path.join(root, "node_modules", "solid-js") + const localOpenTui = path.join(root, "node_modules", "@opentui", "solid") + await Promise.all([ + mkdir(path.dirname(source), { recursive: true }), + mkdir(localSolid, { recursive: true }), + mkdir(localOpenTui, { recursive: true }), + ]) + await Promise.all([ + writeFile( + path.join(localSolid, "package.json"), + JSON.stringify({ name: "solid-js", type: "module", main: "index.js" }), + ), + writeFile( + path.join(localSolid, "index.js"), + "export const createSignal = () => { throw new Error('local Solid used') }\n", + ), + writeFile( + path.join(localOpenTui, "package.json"), + JSON.stringify({ name: "@opentui/solid", type: "module", main: "index.js" }), + ), + writeFile(path.join(localOpenTui, "index.js"), "throw new Error('local OpenTUI used')\n"), + writeFile(helper, 'export { createSignal, onCleanup } from "solid-js"\n'), + writeFile( + source, + ` +import { createSignal, onCleanup } from "./signal" + +export const signal = createSignal + +export default { + id: "test.reactive", + setup(context: any) { + context.ui.slot("home.footer", () => { + const [count, setCount] = createSignal(0) + const timer = setTimeout(() => setCount(1), 10) + onCleanup(() => clearTimeout(timer)) + return count:{count()} + }) + }, +} +`, + ), + ]) + + const plugin = await import(await preparePlugin(new URL(`file://${source}`).href, `${source}?mtime=1`, tmp.path)) + expect(plugin.signal).toBe(createSignal) + let slot: ((input: object) => JSX.Element) | undefined + await plugin.default.setup({ ui: { slot: (_name: string, render: typeof slot) => (slot = render) } }) + if (!slot) throw new Error("Plugin did not register its slot") + + const setup = await testRender(() => createComponent(slot!, {}), { width: 20, height: 2 }) + try { + expect(await setup.waitForFrame((frame) => frame.includes("count:0"))).toContain("count:0") + expect(await setup.waitForFrame((frame) => frame.includes("count:1"))).toContain("count:1") + } finally { + setup.renderer.destroy() + } +})