Skip to content

tui: support reactive external TSX plugins in packaged CLI #39986

Open

Description

@kitlangton

Problem

The packaged Bun CLI does not reliably apply OpenTUI's runtime Solid transform to external TUI plugin TSX. A plugin can either fail while resolving react/jsx-dev-runtime or load project-local solid-js / @opentui/solid copies. The latter renders its initial frame, but signal updates do not repaint because the plugin and host use separate reactive graphs. Imperative renderable mutation works because it bypasses Solid reactivity.

This is specific to dynamically loaded external plugin source in the packaged executable. The same plugin works in source-mode Bun.

Reproduction

  1. Run the packaged CLI with an external .opencode/plugins/tui/reactive.tsx plugin.
  2. Give the plugin a project-local solid-js and @opentui/solid, as documented for local plugin dependencies.
  3. Register a slot that renders a signal and updates it from a timer.
  4. Observe either a react/jsx-dev-runtime resolution failure or an initial frame that never repaints.

The reproduction must also cover the supported index.ts -> view.tsx layout, not only a .tsx entrypoint.

Rejected approach

PR #39983 attempted to precompile external JSX with Bun.build and map selected Solid/OpenTUI imports to the host runtime. We closed it because runtime bundling changed plugin module semantics and still missed supported shapes:

  • .ts and .js entrypoints importing TSX bypassed preparation.
  • Bundling relocated import.meta.url, breaking plugin-relative assets, workers, WASM, and dynamic imports.
  • OpenTUI's transform excludes JSX under node_modules, so npm TSX could still fail.
  • Package-prefix externalization could leave unsupported solid-js/* imports unresolved or load another runtime.
  • Cache identity used only entrypoint mtime despite embedding the full dependency graph.
  • The persistent generated-bundle cache was unbounded and shared across processes.
  • Tests invoked the preparation helper directly rather than exercising the packaged executable.

Do not revive graph-wide runtime bundling without resolving those compatibility constraints.

Plan

  1. Keep a minimal packaged-CLI regression fixture that proves a signal update visibly repaints.
  2. Cover both a direct .tsx entrypoint and a .ts entrypoint importing .tsx.
  3. Run the fixture against Bun 1.4/canary, where related runtime onLoad fixes have landed.
  4. If the newer Bun runtime fixes the reproduction, upgrade when a suitable release is available.
  5. Otherwise reduce it to an OpenTUI reproduction and add an OpenTUI-owned external-module loading API or upstream fix. OpenTUI should own its runtime module registry and transform contract.
  6. Keep OpenCode loading plugins as modules. Do not bundle arbitrary third-party dependency graphs into the OpenCode state directory.

Acceptance criteria

  • Reactive external TSX visibly repaints in the packaged CLI.
  • .ts -> .tsx plugin graphs work.
  • Plugin and host share the required Solid/OpenTUI singleton modules.
  • Ordinary dependencies retain normal module identity and resolution behavior.
  • Plugin-relative assets and import.meta.url retain their original meaning.
  • The regression runs against the packaged artifact, not only source-mode Bun.

Activity

  1. pistonex commented on Sep 14, 2026

    @pistonex

    Data point from the v2 line + a workaround validated in production on v2.0.3 (originally reported in #48883, closed as duplicate of this issue / #33884).

    Reproduces on v2.0.3 with a package TUI plugin (opencode plugin add, install under ~/.cache/opencode/npm/...): the widget paints its initial frame and then never repaints, even though the async fetcher resolves — verified with a non-reactive trace surfaced through a host dialog (fetch settles 200 in ~500 ms, resource resolves ok=true, UI frozen). Without local solid-js/@opentui copies in the install, the plugin instead fails to load entirely:

    plugin operation failed ... stage=load
    error="Cannot find package '@opentui/solid' imported from .../node_modules/<pkg>/src/tui.tsx"
    

    Additional detail on the resolution split (reading @opentui/core/runtime-plugin.js against observed v2.0.3 behavior):

    • Bare specifiers written in the plugin source (import ... from "solid-js", "@opencode/plugin/tui") do get rewritten to host runtime modules even inside node_modules — the prescan path (nodeModulesRuntimeSpecifiers: true) installs exact-path rewrite loaders for node_modules files whose source imports match the runtime map.
    • The @opentui/solid/jsx-runtime / jsx-dev-runtime import generated by the tsx transform is emitted after the textual rewrite, so it is never rewritten. Bun then resolves it from the node_modules importer against the package's local copy (dual runtime), or fails when no copy exists.
    • Net result: the plugin's signals/resources run on the host's solid-js, while the component tree runs on the package's local @opentui/solid — the initial frame renders, nothing ever repaints.

    Workaround — works today with no host changes. Publish the plugin importing the host modules through their virtual ids, and avoid JSX syntax (explicit jsx()/jsxs() calls with per-expression getters — the same output shape the Solid JSX compiler emits, so no auto-generated bare JSX-runtime import exists):

    import { createMemo, createResource, createSignal, onCleanup, Show } from "opentui:runtime-module:solid-js"
    import { Plugin, usePlugin } from "opentui:runtime-module:%40opencode%2Fplugin%2Ftui"
    import { jsx, jsxs } from "opentui:runtime-module:%40opentui%2Fsolid%2Fjsx-runtime"

    Notes:

    • Virtual opentui:runtime-module:* ids resolve through build.module registrations, which work regardless of the importer's location — the one path that reliably lands on host instances from inside node_modules.
    • Import from jsx-runtime, not jsx-dev-runtime: the latter only re-exports Fragment and jsxDEV, the former exports jsx and jsxs as well.
    • Declaring solid-js/@opentui/* peers as optional (peerDependenciesMeta) keeps npm from auto-installing shadow copies into plugin installs; with virtual imports the local copies are irrelevant anyway.

    Live reference: @pistonex/opencode-go-usage on npm — 0.1.2 exhibits the frozen-UI failure, 0.1.3 carries the workaround. Installed as a package, data loads, hot language switching, 5-minute auto-refresh and collapse/expand all work.

    For the eventual fix, the two candidate seams this analysis points at are: (a) letting the tsx transform emit the virtual runtime-module id for the JSX runtime import, or (b) enabling bare-specifier rewriting for plugin install directories — both would make the documented author shape (plain JSX + peerDependencies) work as published.

  2. schemaitat commented on Oct 12, 2026

    @schemaitat

    Data point on v2.0.27 (packaged Bun CLI, Linux): a GitHub-installed TUI plugin fails at load time with the react resolution error — i.e. obstacle (a) "the Solid JSX transform never runs", not just the frozen-reactivity symptom. A shipped plugin-side workaround below makes it load and stay reactive from node_modules.

    Environment

    • opencode v2.0.27, installed via opencode plugin add github:schemaitat/opencode-plugin-auto-classifier
    • Install path: ~/.cache/opencode/npm/git-<hash>/<ts>/node_modules/opencode-plugin-auto-classifier/
    • Server half (exports["."]) loads fine; only exports["./tui"] fails, on every startup.

    Symptom

    plugin operation failed component=plugin stage=load role=cli
    error="Cannot find package 'react' imported from .../node_modules/opencode-plugin-auto-classifier/tui.tsx"
    

    Confirmed mechanism

    1. The plugin ships raw tui.tsx (JSX) as its ./tui entry, and does not install @opentui/solid / solid-js locally (declared as optional peers), so there is no local jsx-runtime to fall back to.
    2. Using the Bun embedded in the packaged CLI, the exact failure reproduces:
      BUN_BE_BUN=1 opencode build tui.tsx --target=bun
      → error: Could not resolve: "react/jsx-dev-runtime". Maybe you need to "bun install"?
      
      With a /** @jsxImportSource @opentui/solid */ pragma it instead becomes
      Could not resolve: "@opentui/solid/jsx-dev-runtime" — confirming the config isn't the fix.
    3. Reading the packaged CLI confirms the OpenTUI Solid plugin's hardcoded exclusion:
      r = t.resolvePath
        ? /^(?!.*[/\\]node_modules[/\\]).*\.[cm]?[jt]sx?(?:[?#].*)?$/
        : /^(?!.*[/\\]node_modules[/\\]).*\.[cm]?[jt]sx(?:[?#].*)?$/;
    4. Also confirmed the virtual-id construction and registry:
      Ef = "opentui:runtime-module:"
      _f = (t) => `${Ef}${encodeURIComponent(t)}`
      YCt = { "@opentui/solid":…, "@opentui/solid/jsx-runtime":…, "@opentui/solid/jsx-dev-runtime":…,
              "solid-js":…, "solid-js/store":… }   // + "@opencode/plugin/tui" via additional
    5. Shipping tsconfig.json (with jsxImportSource) is not sufficient — it only moves the failure to Cannot find package '@opentui/solid', since the transform-generated jsx-runtime import is emitted after the text-level runtime-module rewrite and never bridged.

    Workaround that ships and works

    Same pattern @pistonex validated: make the entry a plain .ts (no JSX) and import host singletons through the virtual ids, then build the tree with jsx()/jsxs() and per-expression get accessors.

    import { Plugin } from "opentui:runtime-module:%40opencode%2Fplugin%2Ftui"
    import { For, createSignal, onCleanup } from "opentui:runtime-module:solid-js"
    import { jsx, jsxs } from "opentui:runtime-module:%40opentui%2Fsolid%2Fjsx-runtime"
    • exports["./tui"] → ./tui.ts; keep @opentui/* and solid-js as optional peers so npm/git installs don't nest shadow copies.
    • No JSX syntax ⇒ no auto-generated bare jsx-runtime import ⇒ nothing to bridge at transform time.

    Shipped in schemaitat/opencode-plugin-auto-classifier@0d3512a; verified opencode plugin update re-resolves the commit and plugin list reports the new plugin id/version.

    Suggested seams (unchanged from this thread)

    (a) Let the TSX transform emit the virtual opentui:runtime-module:* id for the JSX-runtime import it generates, and/or (b) relax the Solid transform's node_modules exclusion (or enable specifier rewriting) for the plugin install tree. Until then, the virtual-id + no-JSX shape is the only path I found that both loads and stays reactive from an npm/git install.

  3. dskanakoglou commented on Oct 16, 2026

    @dskanakoglou

    Hello, I am a researcher from DTU Denmark. I am using opencode for a workshop and ran into this with github:-installed TUI plugins (reported as #56110, which I'm closing as a duplicate). It reproduces on 2.0.29 and 2.0.31: a plugin in the npm cache renders once and never repaints, while the same folder loaded by path works.

    Following step 5 of the plan, I have an OpenTUI-side fix ready: an include option on the Solid transform (and on ensureRuntimePluginSupport), so a host can name directories whose .jsx/.tsx files are compiled even under node_modules. OpenCode would then pass its npm cache directory. It doesn't bundle, so modules load normally and import.meta.url keeps its meaning. It has a unit test and a reactivity test, and the OpenTUI Solid suite passes.

    I also tested a ~20-line OpenCode-only version of the same idea, which reuses OpenTUI's loader for <cache>/npm/**/*.tsx. In a locally built packaged binary, a reactive .tsx entry and a .ts → .tsx entry both repaint, and so does opencode-sidebar-plus installed via github:. An unpatched build from the same commit stays frozen. What I haven't done yet is a regression test that runs against the packaged artifact.

    Would you prefer a PR to OpenTUI, or the OpenCode-only change as a stopgap?

  4. dskanakoglou commented on Oct 17, 2026

    @dskanakoglou

    Still reproduces on 2.0.32. Happy to open either PR (OpenTUI include option or the OpenCode-side stopgap), whichever you prefer.

  5. dskanakoglou commented on Oct 22, 2026

    @dskanakoglou

    Opened the OpenTUI side as anomalyco/opentui#1741 (an include option on the Solid transform). Once it's released, OpenCode would pass its npm cache directory to ensureRuntimePluginSupport.

  6. rizal72 commented on Oct 25, 2026

    @rizal72

    Same failure on v2.1.4, from a published plugin rather than a local one — and it is the shape the publish docs recommend today.

    @rizal72/oc-coffee@0.1.0 ships exports: { "./tui": "./tui.tsx" } with the @opentui/* and solid-js peers, exactly as build/plugins/cli → "Publish and load" shows. Installed from npm:

    plugin operation failed component=plugin stage=read
    error="Cannot find package 'react' imported from ~/.cache/opencode/npm/@rizal72/oc-coffee@latest/…/node_modules/@rizal72/oc-coffee/tui.tsx"
    

    Reproduced minimally in a clean HOME with one probe plugin whose path contains node_modules; the same directory moved outside node_modules loads.

    A workaround that works today, and is probably worth documenting until this is fixed: ship the TUI entry compiled. babel-preset-solid (moduleName: "@opentui/solid", generate: "universal") plus exports["./tui"] = "./tui.js" loads cleanly on 2.1.4, with no peerDependencies needed — the host resolves @opentui/solid, solid-js and @opencode/plugin/tui from its own registry. No bundling is involved, so the reasons #39983 was closed do not apply.

    @rizal72/oc-coffee@0.1.1 ships that shape and loads.

  7. pistonex commented on Oct 25, 2026

    @pistonex

    Confirming from the other side of @rizal72's report: a published npm TUI plugin still works on v2.1.4 with the virtual-import pattern, so the "published package" shape is not the blocker — the JSX transform path is.

    Environment: opencode v2.1.4, Linux x64, opencode plugin add @pistonex/opencode-go-usage. Plugin listed as 0.2.0 / source @pistonex/opencode-go-usage, and the plugin log shows clean reconciliations with zero load/setup errors after the update.

    What it does (no build step):

    • exports["./tui"] points at a .tsx file that contains no JSX syntax — the tree is built with jsx()/jsxs() and per-expression get accessors.
    • Host singletons arrive via virtual ids (opentui:runtime-module:solid-js, ...%40opencode%2Fplugin%2Ftui, ...%40opentui%2Fsolid%2Fjsx-runtime).
    • @opentui/* / solid-js stay optional peers, so npm installs no shadow copies.
    • No auto-generated JSX-runtime import exists, so there is nothing for the transform to miss.

    Durability so far: the same shape survived the v2.0.11 theme API change (function → ResolvedTheme object) with only token renames — no structural change. On 2.1.4, ResolvedTheme and credential.list are unchanged, and the plugin also moved its API key source to context.client.credential.list() (the legacy account.json goes stale on rotation), which confirms the host client in the plugin context is the right seam for that too.

    On the two workarounds: @rizal72's precompiled-JS approach is a good option when authors want to keep JSX; the virtual-import approach needs no toolchain but requires hand-written jsx() calls. Either way, once the include option from anomalyco/opentui#1741 lands, both can be dropped in favor of plain TSX + peers as documented.

  8. opencodejr commented on Oct 28, 2026

    @opencodejr

    I reproduced this on @opencode/cli@2.2.0 (packaged binary, latest release), and it still happens on the latest dev (1c49e51) run from source.

    The failure depends on whether the plugin's path contains node_modules:

    • Plugins in .opencode/plugins/<name>/ work. Both a tui.tsx entry and a tui.ts entry importing view.tsx render and repaint (the timer-driven counter keeps advancing), with project-local solid-js@1.9.17 and @opentui/solid@0.7.0 installed.
    • The same two plugins fail when loaded as packages under .opencode/node_modules/…. That is how npm/github-installed plugins are laid out. Both fail with:
      Cannot find package 'react' imported from …/.opencode/node_modules/repro-reactive/tui.tsx
      
      For the .ts → .tsx plugin, the error points at view.tsx instead. The TUI shows "2 plugins failed".

    This matches the react resolution symptom. I didn't see the "first frame renders but never repaints" variant here, because the transform fails before it gets that far. The script below sets up both layouts, and the recording and screenshots show the counters advancing and the plugin error dialog.

    recording.mp4

    Screenshot

    Screenshot

    Reproduction script (drive.ts)
    // Reproduces anomalyco/opencode#39986: external TUI plugin TSX with project-local solid-js
    // Plugins under .opencode/plugins (no node_modules in path) load and repaint, but the same TSX plugins
    // loaded from a path under node_modules (like npm/github-installed plugins) fail with
    // "Cannot find package 'react'" because the Solid transform skips node_modules.
    // Self-contained: writes the plugins and installs project-local solid-js/@opentui/solid into /tmp/opencode/repro-39986.
    import { Effect } from "effect"
    import { OpenCodeDriver } from "opencode-drive"
    import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
    import { execSync } from "node:child_process"
    import path from "node:path"
    
    const root = "/tmp/opencode/repro-39986"
    const reactiveTsx = (id: string, label: string) => `import { Plugin } from "@opencode/plugin/tui"
    import { createSignal } from "solid-js"
    
    export default Plugin.define({
      id: "${id}",
      setup(context) {
        const [count, setCount] = createSignal(0)
        setInterval(() => setCount((c) => c + 1), 500)
        context.ui.slot({ append: "home.footer", render: () => <text>${label}: {count()}</text> })
      },
    })
    `
    const entryTs = (id: string) => `import { Plugin } from "@opencode/plugin/tui"
    import { View } from "./view.tsx"
    
    export default Plugin.define({
      id: "${id}",
      setup(context) {
        context.ui.slot({ append: "home.footer", render: () => View() })
      },
    })
    `
    const viewTsx = (label: string) => `import { createSignal } from "solid-js"
    
    export function View() {
      const [count, setCount] = createSignal(0)
      setInterval(() => setCount((c) => c + 1), 500)
      return <text>${label}: {count()}</text>
    }
    `
    const sources: Record<string, string> = {
      "package.json": JSON.stringify({ name: "repro-plugins", private: true, dependencies: { "solid-js": "1.9.17", "@opentui/solid": "0.7.0" } }),
      // Local plugin directories (outside node_modules): direct .tsx entry and .ts -> .tsx entry.
      "plugins/reactive/tui.tsx": reactiveTsx("repro.reactive", "TSX tick"),
      "plugins/reactivets/tui.ts": entryTs("repro.reactivets"),
      "plugins/reactivets/view.tsx": viewTsx("TS-to-TSX tick"),
      // The same plugins as packages under node_modules (the shape of npm/github-installed plugins).
      "node_modules/repro-reactive/package.json": JSON.stringify({ name: "repro-reactive", type: "module", exports: { "./tui": "./tui.tsx" } }),
      "node_modules/repro-reactive/tui.tsx": reactiveTsx("repro.nm-reactive", "NM TSX tick"),
      "node_modules/repro-reactivets/package.json": JSON.stringify({ name: "repro-reactivets", type: "module", exports: { "./tui": "./tui.ts" } }),
      "node_modules/repro-reactivets/tui.ts": entryTs("repro.nm-reactivets"),
      "node_modules/repro-reactivets/view.tsx": viewTsx("NM TS-to-TSX tick"),
    }
    mkdirSync(path.join(root, ".opencode"), { recursive: true })
    writeFileSync(path.join(root, ".opencode/package.json"), sources["package.json"])
    if (!existsSync(path.join(root, ".opencode/node_modules/solid-js"))) execSync("bun install", { cwd: path.join(root, ".opencode"), stdio: "inherit" })
    for (const [rel, text] of Object.entries(sources)) {
      mkdirSync(path.dirname(path.join(root, ".opencode", rel)), { recursive: true })
      writeFileSync(path.join(root, ".opencode", rel), text)
    }
    const include = ["plugins", "package.json", "node_modules/solid-js", "node_modules/seroval", "node_modules/seroval-plugins", "node_modules/@opentui/solid", "node_modules/repro-reactive", "node_modules/repro-reactivets"]
    const files: Record<string, Uint8Array> = {}
    const walk = (rel: string) => {
      const abs = path.join(root, rel)
      if (statSync(abs).isDirectory()) for (const e of readdirSync(abs)) walk(path.join(rel, e))
      else files[rel] = readFileSync(abs)
    }
    for (const p of include) walk(path.join(".opencode", p))
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        project: { git: true, files },
        // Same plugins, but loaded from a path under node_modules (the shape of an npm/github-installed plugin).
        setup: ({ tuiConfig }: any) => Effect.sync(() => { tuiConfig.plugins = ["./node_modules/repro-reactive", "./node_modules/repro-reactivets"] }),
        tui: { recording: true },
      },
      ({ ui }) =>
        Effect.gen(function* () {
          yield* ui.waitFor("tick:").pipe(Effect.timeout("30 seconds"), Effect.ignore)
          yield* ui.screenshot("first")
          const a = yield* ui.capture()
          yield* Effect.sleep(process.env.HOLD ? "40 seconds" : "4 seconds")
          yield* ui.screenshot("after-4s")
          const b = yield* ui.capture()
          const lines = (f: any) => (f.lines as any[]).map((l) => l.spans.map((s: any) => s.text).join("").trimEnd()).filter((l) => /tick|jsx|react|plugin|error|fail/i.test(l))
          // Open the plugin manager so the load failures of the node_modules copies are visible.
          yield* ui.submit("/plugins").pipe(Effect.ignore)
          yield* Effect.sleep("2 seconds")
          yield* ui.screenshot("plugins-dialog")
          yield* ui.enter().pipe(Effect.ignore)
          yield* Effect.sleep("2 seconds")
          yield* ui.screenshot("plugin-error")
          const c = yield* ui.capture()
          yield* Effect.log("PLUGINS:\n" + (c as any).lines.map((l: any) => l.spans.map((s: any) => s.text).join("").trimEnd()).filter((l: string) => l.trim()).join("\n"))
          yield* Effect.log("FIRST:\n" + lines(a).join("\n"))
          yield* Effect.log("AFTER 4s:\n" + lines(b).join("\n"))
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

  9. tuzelko-familiar commented on Nov 7, 2026

    @tuzelko-familiar

    A setup that works for us on v2.2.5, in case it helps another author:

    • TUI entry is a raw tui.ts (exports["./tui"] = "./tui.ts"), no JSX syntax and no TUI build step.
    • Imports go through the host runtime ids:
    import { createEffect, createSignal, onCleanup } from "opentui:runtime-module:solid-js"
    import { jsx } from "opentui:runtime-module:%40opentui%2Fsolid%2Fjsx-runtime"
    • The tree is explicit jsx() calls with getters for every reactive expression:
    jsx("box", {
      get height() {
        return count() > 0 ? 1 : 0
      },
      get children() {
        return jsx("text", {
          get children() {
            return count() > 0 ? `⏰ ${count()} delayed` : ""
          },
        })
      },
    })
    • @opentui/core, @opentui/solid, and solid-js are optional peers (peerDependenciesMeta), so npm does not install shadow copies.
    • The RPC contract is a plain object with a type-only Rpc import, so the TUI part has no runtime @opencode/plugin import.

    With that, an async pending load and live context.data.on("rpc.…") updates both re-render from the installed package. Shipped in @tuzelko/opencode-delayed-message@0.2.7; source: https://github.com/TuzelKO/opencode-delayed-message/blob/main/tui.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions