Skip to content

tui: plugin code block renderer crashes TUI when the block is replaced during streaming ("remove expects a renderable child object") #62051

Open

Description

@ailonn

Description

A TUI plugin that registers a custom markdown code block renderer (context.markdown.registerCodeBlockRenderer) and builds its renderable with its own @opentui/core copy crashes the whole TUI with Error: remove expects a renderable child object as soon as the host replaces the block — which happens on every token change during assistant streaming. 100 % repro on host 2.2.5 (3/3 instances); any plugin following the documented renderer pattern hits it.

Expected: the custom renderable is replaced/destroyed like any host-owned renderable, and the updated block renders.
Actual: the TUI crashes with the error above.

Plugins

Two local-path TUI plugins (one registers a doc code block renderer) + envsitter-guard@latest.

Reference plugin: https://github.com/ailonn/opencode-plugin-docs-open (public, branch main) — v0.3.0 (unpublished development tag) had the custom renderer active and crashed the TUI on every streaming token; v0.4.0 (first public release) ships the fence-passthrough workaround (see its README § Limitations / CHANGELOG v0.4.0).

OpenCode version

2.2.5 (standalone binary, latest channel)

Steps to reproduce

  1. A TUI plugin (its own node_modules, installed from a local path) calls context.markdown.registerCodeBlockRenderer("doc", renderer) in setup — the pattern documented at https://opencode.ai/v2/docs/build/plugins/cli/ (§ Markdown); the docs' own example declares @opentui/core as a peer dependency, i.e. the plugin builds renderables from its own copy.
  2. The renderer builds its block with the plugin's own @opentui/core (new BoxRenderable(context.renderer, …) + TextRenderable children). The host core is not reachable from a plugin (see Additional context).
  3. In a session, the assistant emits a ```doc fenced code block.
  4. As the block streams (its token changes on each update) — or on the final update — the host replaces the block renderable → crash.

Screenshot

N/A — terminal crash; error text in Description.

Operating System

Linux 7.0.0-42-generic (Ubuntu, x86_64)

Terminal

xterm-256color (truecolor)

Additional context

Diagnosis (binary inspection of the 2.2.5 host + published @opentui/core 0.8.2 source):

  • add / insertBefore on the host's renderable tree accept a child if it carries the brand Symbol.for("@opentui/core/Renderable") (verified in published 0.8.2: isRenderable(obj) = !!obj?.[BrandedRenderable], with BrandedRenderable = Symbol.for("@opentui/core/Renderable")). Because that brand is a global symbol shared by every copy of @opentui/core, the plugin's renderable (built from its own copy) is accepted and renders fine.
  • remove, however, does child instanceof BaseRenderable — a strict instanceof against the host bundle's own base Renderable class (0.8.2 published source, remove(child) of the base renderable class) — a different class object than the plugin's copy, so the check fails and the host throws.
  • Block replacement is the normal path for custom renderers: in published 0.8.2 the markdown layer computes canUpdateInPlace = custom.renderable === custom.defaultResult?.renderable for code blocks, i.e. false whenever the renderer returns its own renderable — so the host destroys and recreates the block on every token change during streaming.
  • A plugin cannot build renderables with the host's core instance: the opentui:runtime-module: specifier rewrite is applied at host bundle time only — at plugin load time it resolves to Cannot find package 'opentui:runtime-module:@opentui'.

Related issues (same error, all closed without a fix for this path):

Workaround: do not register a custom code block renderer (render the fence as the host's standard code block — what the reference plugin does since v0.4.0). Reported because plugins following the documented registerCodeBlockRenderer pattern cannot avoid the crash.

Activity

  1. opencodejr commented on Nov 5, 2026

    @opencodejr

    Thanks for the detailed write-up! I tried to reproduce this on 2.2.5 but couldn't.

    What I tried

    • I wrote a local TUI plugin with its own node_modules/@opentui/core@0.8.2, installed once with bun and once with pnpm (symlinked). It calls context.markdown.registerCodeBlockRenderer("doc", …) and returns a fresh new BoxRenderable(context.renderer, …) with a TextRenderable child, both imported from @opentui/core.
    • I loaded it two ways: as a TUI-only plugin from cli.json plugins, and with a server part from opencode.json plugins.
    • A simulated model streamed a ```doc fence in small chunks.

    What happened
    The renderer was called about 10 times per response, so the host replaced the block on every update. The custom block rendered correctly and the TUI never crashed.

    Why it didn't crash here
    Inside the plugin's setup, context.renderer.root instanceof Renderable (using the plugin's own @opentui/core import) was true. The host's runtime plugin loader rewrites a plugin's @opentui/core imports to the host's own copy. Plugin renderables are therefore host Renderables, and the instanceof check in remove passes.

    The opentui:runtime-module: rewrite only runs inside the OpenCode host. Under plain bun test, as in your test/harness, it isn't active, which would explain the Cannot find package 'opentui:runtime-module:@opentui' result from your probe.

    What would help

    • The exact v0.3.0 renderer code that crashed, or a minimal plugin that crashes 2.2.5 when loaded by opencode itself.
    • How the plugin was loaded: the cli.json/opencode.json entry or opencode plugin add, and the resolved path.
    • Whether @opentui/core was imported in a plugin source file, or only inside a dependency or a dynamically loaded file.
    • The full stack trace from the crash screen.

    Related: #43693, #43696, #43699 (same error text, from crash reports during plugin reload); #54126 (the code-block renderer API).

  2. opencodejr commented on Nov 5, 2026

    @opencodejr

    Thanks for the minimal repro: the nested file was the missing piece. I can confirm the crash.

    Steps: OpenCode 2.2.5, with a plugin loaded from an absolute path in opencode.json plugins that has its own @opentui/core@0.8.2. src/tui.ts imports ./adapters/doc-block.js (a .ts file on disk), and that file imports BoxRenderable/TextRenderable and registers a doc code block renderer. A simulated model streams a ```doc fence in small chunks.

    Result:

    • Inside the nested file, context.renderer.root instanceof Renderable is false.
    • On the first block replacement the TUI shows the crash screen with remove expects a renderable child object. As in your stack trace, the host's remove is called from the plugin's own copy of @opentui/core (destroy / destroyRecursively).
    • With the import written as ./adapters/doc-block.ts, the check is true and streaming finishes without a crash. That matches your matrix: the .js spelling in a nested file is what triggers it.

    The crash also still happens on the latest dev (386229c). The recording and screenshot below show it, and the script below creates the probe plugin and reproduces it.

    Until this is fixed, it should be enough to import nested files with a .ts specifier, or to keep the @opentui/core imports in the entry file.

    recording.mp4

    Screenshot

    Screenshot

    Reproduction script (drive.ts)
    // Repro for anomalyco/opencode#62051: a TUI plugin's code block renderer whose
    // @opentui/core import lives in a nested file imported with a ".js" specifier
    // gets the plugin's own @opentui/core copy (the host's runtime-module rewrite
    // misses that file). When the host replaces the streamed block, the host's
    // Renderable.remove() throws "remove expects a renderable child object".
    // Run: OPENCODE_DRIVE_MEDIA_DIR=/workspace/media opencode-drive run drive.ts
    // (OC_DEV=/path/to/opencode-checkout to run from source.)
    import { Effect } from "effect"
    import { mkdirSync, writeFileSync } from "node:fs"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    const dir = "/tmp/opencode/probe-crash"
    mkdirSync(`${dir}/src/adapters`, { recursive: true })
    writeFileSync(`${dir}/package.json`, JSON.stringify({
      name: "probe-crash", version: "0.0.0", type: "module",
      exports: { ".": "./src/server.ts", "./tui": "./src/tui.ts" },
      dependencies: { "@opentui/core": "0.8.2" },
    }, null, 2))
    writeFileSync(`${dir}/src/server.ts`, `import { Plugin } from "@opencode/plugin"
    export default Plugin.define({ id: "probe-crash", setup: () => {} })
    `)
    writeFileSync(`${dir}/src/tui.ts`, `import { Plugin } from "@opencode/plugin/tui"
    import { registerDocRenderer } from "./adapters/doc-block.js" // .ts on disk; ".ts" here avoids the crash
    export default Plugin.define({ id: "probe-crash", setup(context) { registerDocRenderer(context) } })
    `)
    writeFileSync(`${dir}/src/adapters/doc-block.ts`, `import { BoxRenderable, TextRenderable } from "@opentui/core"
    export function registerDocRenderer(context: any) {
      context.markdown.registerCodeBlockRenderer("doc", (token: { text: string }) => {
        const block = new BoxRenderable(context.renderer, { width: "100%", border: true, title: "DOC" })
        for (const line of token.text.split("\\n")) block.add(new TextRenderable(context.renderer, { content: line }))
        return block
      })
    }
    `)
    // The plugin gets its own @opentui/core copy in its node_modules.
    const install = Bun.spawnSync(["bun", "install"], { cwd: dir, stdout: "inherit", stderr: "inherit" })
    if (install.exitCode !== 0) throw new Error("bun install failed for the probe plugin")
    
    const fence = "```"
    const answer = `Here is the doc:\n\n${fence}doc\nline one of the doc block\nline two of the doc block\nline three of the doc block\n${fence}\n\nDone.`
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        tui: { recording: true },
        config: { plugins: [dir] },
      },
      ({ ui, llm }) =>
        Effect.gen(function* () {
          yield* ui.screenshot("home")
          // Two responses: one may be consumed by the background title request.
          yield* llm.queue(Llm.text(answer, { delay: 60, chunkSize: 6 }))
          yield* llm.queue(Llm.text(answer, { delay: 60, chunkSize: 6 }))
          yield* ui.submit("show me a doc block")
          yield* Effect.sleep("6 seconds")
          yield* ui.screenshot("crash") // shows the "OpenCode crashed" screen
        }),
    )

    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.

  3. ailonn commented on Nov 5, 2026

    @ailonn
    Author

    Apologies in advance — my earlier version of this comment went up by accident (a misclick on my side) and I deleted it; here it is, reviewed.

    I also see you've since confirmed the crash with your own repro — thank you, that independently validates the diagnosis.


    Your repro is correct for the entry file — the rewrite does apply there. The discriminating factor is where the @opentui/core import lives: in your plugin it is in the entry file; in ours it is in a nested source file imported with a .js specifier (Bun's standard TS convention). In that case the rewrite loader is never installed for that file, the bare import resolves natively to the plugin's own @opentui/core copy, and remove (strict instanceof against the host class) throws on the first block replacement during streaming.

    Your four asks

    1. Exact v0.3.0 code that crashed — two-part plugin; the @opentui/core import is not in the entry:

    // src/tui.ts  (entry — no @opentui/core import)
    import { docBlockRenderer } from "./adapters/doc-block.js"  // a .ts file on disk
    
    export default Plugin.define({
      id: "opencode-plugin-docs-open",
      setup(context) {
        context.markdown.registerCodeBlockRenderer(
          "doc",
          docBlockRenderer(opener, context.theme, reportOpenError, context.renderer),
        )
        // … keymap, click handling
      },
    })
    // src/adapters/doc-block.ts  (nested — the value imports are here)
    import { BoxRenderable, createTextAttributes, TextRenderable, /* types */ } from "@opentui/core"
    
    // builds a BoxRenderable on context.renderer, one TextRenderable child
    // per line, wires hover/click, returns the block

    2. How it was loaded — global opencode.jsonc → plugins:

    "plugins": [ "/home/$USER/bin/opencode-tools/opencode-docs" ]  // absolute path, symlinked directory

    Package exports: . → ./src/server.ts, ./tui → ./src/tui.ts. The host resolves the TUI part as subpath opencode-plugin-docs-open/tui via Bun.resolveSync (package self-reference) → real path …/opencode-docs/src/tui.ts → prepareSource → plain import().

    3. Where @opentui/core is imported — in a plugin source file, but not the entry: src/adapters/doc-block.ts, imported from the entry by a relative path with the .js specifier. Not a dependency, not dynamic.

    4. Full stack trace — reproduced today on 2.2.5 (minimal plugin below, identical shape), captured from the crash screen ("Copy report"):

    Error: remove expects a renderable child object
        at remove (/$bunfs/root/chunk-1rf07frg.js:177:81353)
        at run (/tmp/opencode/tui-probe/probe-crash/node_modules/.pnpm/@opentui+core@0.8.2/node_modules/@opentui/src/Renderable.ts:487:9)
        at <anonymous> (/tmp/opencode/tui-probe/probe-crash/node_modules/.pnpm/@opentui+core@0.8.2/node_modules/@opentui/src/Renderable.ts:2085:9)
        at run (/tmp/opencode/tui-probe/probe-crash/node_modules/.pnpm/@opentui+core@0.8.2/node_modules/@opentui/src/Renderable.ts:487:9)
        at <anonymous> (/tmp/opencode/tui-probe/probe-crash/node_modules/.pnpm/@opentui+core@0.8.2/node_modules/@opentui/src/Renderable.ts:2188:20)
        at runCleanup (/tmp/opencode/tui-probe/probe-crash/node_modules/.pnpm/@opentui+core@0.8.2/node_modules/@opentui/src/Renderable.ts:494:7)
        at destroyLayoutBacking (/tmp/opencode/tui-probe/probe-crash/node_modules/.pnpm/@opentui+core@0.8.2/node_modules/@opentui/src/Renderable.ts:2173:10)
        at destroy (/tmp/opencode/tui-probe/probe-crash/node_modules/.pnpm/@opentui+core@0.8.2/node_modules/@opentui/src/Renderable.ts:2074:10)
        at run (/tmp/opencode/tui-probe/probe-crash/node_modules/.pnpm/@opentui+core@0.8.2/node_modules/@opentui/src/Renderable.ts:487:9)
        at <anonymous> (/tmp/opencode/tui-probe/probe-crash/node_modules/.pnpm/@opentui+core@0.8.2/node_modules/@opentui/src/Renderable.ts:2134:9)
        at runCleanup (/tmp/opencode/tui-probe/probe-crash/node_modules/.pnpm/@opentui+core@0.8.2/node_modules/@opentui/src/Renderable.ts:494:7)
        at destroyRecursively (/tmp/opencode/tui-probe/probe-crash/node_modules/.pnpm/@opentui+core@0.8.2/node_modules/@opentui/src/Renderable.ts:2137:5)
        at updateTopLevelBlocks (/$bunfs/root/chunk-4ze3mjpc.js:72:12452)
        at updateBlocks (/$bunfs/root/chunk-4ze3mjpc.js:72:13755)
        at streaming (/$bunfs/root/chunk-4ze3mjpc.js:70:18747)
        at setProperty (/$bunfs/root/chunk-tst3pm4c.js:3:8323)
        at setProp (/$bunfs/root/chunk-tst3pm4c.js:3:4372)
        at <anonymous> (/$bunfs/root/chunk-5z4e91dj.js:2606:92820)
        at Ve (/$bunfs/root/chunk-22y7qh0k.js:2:9876)
        at K (/$bunfs/root/chunk-22y7qh0k.js:2:9657)
        at k1 (/$bunfs/root/chunk-22y7qh0k.js:2:3655)
        at children (/$bunfs/root/chunk-5z4e91dj.js:2606:92524)
        at <anonymous> (/$bunfs/root/chunk-22y7qh0k.js:2:20386)
        at Ve (/$bunfs/root/chunk-22y7qh0k.js:2:9876)
        at K (/$bunfs/root/chunk-22y7qh0k.js:2:9657)
        at E (/$bunfs/root/chunk-22y7qh0k.js:2:4084)
        at Us (/$bunfs/root/chunk-22y7qh0k.js:2:6899)
        at <anonymous> (/$bunfs/root/chunk-22y7qh0k.js:2:20876)
        at Ve (/$bunfs/root/chunk-22y7qh0k.js:2:9876)
        at K (/$bunfs/root/chunk-22y7qh0k.js:2:9657)
        at E (/$bunfs/root/chunk-22y7qh0k.js:2:4084)
        at Us (/$bunfs/root/chunk-22y7qh0k.js:2:6899)
        at Ve (/$bunfs/root/chunk-22y7qh0k.js:2:9876)
        at K (/$bunfs/root/chunk-22y7qh0k.js:2:9657)
        at E (/$bunfs/root/chunk-22y7qh0k.js:2:4084)
        at Us (/$bunfs/root/chunk-22y7qh0k.js:2:6899)
        at Ve (/$bunfs/root/chunk-22y7qh0k.js:2:9876)
        at K (/$bunfs/root/chunk-22y7qh0k.js:2:9657)
        at E (/$bunfs/root/chunk-22y7qh0k.js:2:4084)
        at Us (/$bunfs/root/chunk-22y7qh0k.js:2:6899)
        at Ve (/$bunfs/root/chunk-22y7qh0k.js:2:9876)
        at K (/$bunfs/root/chunk-22y7qh0k.js:2:9657)
        at E (/$bunfs/root/chunk-22y7qh0k.js:2:4084)
        at <anonymous> (/$bunfs/root/chunk-ydttf3wp.js:54:7759)
        at rD (/$bunfs/root/chunk-22y7qh0k.js:2:3223)
        at <anonymous> (/$bunfs/root/chunk-22y7qh0k.js:2:16355)
        at Us (/$bunfs/root/chunk-22y7qh0k.js:2:6899)
        at Ve (/$bunfs/root/chunk-22y7qh0k.js:2:9876)
        at K (/$bunfs/root/chunk-22y7qh0k.js:2:9657)
        at Z (/$bunfs/root/chunk-22y7qh0k.js:2:11342)
    

    Note the split: remove runs from the host bundle (/$bunfs), the 12 frames above it from the plugin's own copy of @opentui/core (probe-crash/node_modules/.pnpm/@opentui+core@0.8.2) — two module instances in one call chain. (Frames 17+: SolidJS reactive scheduler + host render loop, all in /$bunfs.)

    Your probe, run in our shape

    In the live repro, context.renderer.root instanceof Renderable — evaluated inside src/adapters/doc-block.ts (the nested file) — is false. The rewrite does not cover that file. (Your true was with the import in the entry file — see probe B below.)

    Why the rewrite misses the nested file

    Per @opentui/core 0.8.x runtime-plugin.js (installed by ensureRuntimePluginSupport, which the host calls from packages/tui/src/plugin/runtime-plugin-support.bun.ts):

    1. A global onResolve maps each specifier to a source path via import.meta.resolve(specifier, importer), then calls installRewriteLoader(path).
    2. installRewriteLoader canonicalizes the path with realpathSync — the failure is caught silently (fallback to the input path) — and registers an onLoad filtered on that canonical path exactly.
    3. The entry file arrives with a real path (the host's Bun.resolveSync returns the existing .ts file) → realpathSync succeeds → the filter matches the file Bun actually loads → the specifier is rewritten to the host module. ✓ (your case)
    4. A nested file imported as ./adapters/doc-block.js arrives with the .js spelling: import.meta.resolve returns …/doc-block.js — no TS substitution is performed, and no such file exists on disk. realpathSync fails → the filter is registered on a nonexistent path → when Bun loads the real doc-block.ts, the onLoad filter never matches → the file is loaded natively, untransformed → its bare @opentui/core import also resolves natively (the eager onResolve hook for the runtime specifiers never fires for imports of natively-loaded modules — verified with an instrumented runtime-plugin.js: zero fires, zero @opentui/core entries from that file).

    Control: writing the same import as ./adapters/doc-block.ts makes the rewrite work (identity true) — the spelling is the failure point.

    Clean-room matrix

    bun 1.4.4 (the version embedded in the 2.2.5 binary), published @opentui/core@0.8.0 + @opentui/solid@0.8.0 (the versions the host bundles per the v2.2.5 catalog), real ensureRuntimePluginSupport, plugin with its own node_modules copy:

    Probe Shape plugin.BoxRenderable === host
    A two-part, import nested (.js spec), symlinked directory (our config) false → remove throws
    B two-part, import in the entry (your shape) true
    C as A but real path (no symlink) false → remove throws
    D as A but .ts spec true

    So the symlink is not a factor (A = C); entry-vs-nested is the discriminator (B/D pass, A/C fail) — consistent with both your non-repro and our 100% repro.

    Minimal repro (crashes 2.2.5, 100% of runs)

    probe-crash/
    ├── package.json
    ├── src/server.ts
    ├── src/tui.ts
    └── src/adapters/doc-block.ts
    
    // package.json
    { "name": "probe-crash", "type": "module",
      "exports": { ".": "./src/server.ts", "./tui": "./src/tui.ts" },
      "devDependencies": { "@opentui/core": "0.8.2" } }
    // src/server.ts
    import { Plugin } from "@opencode/plugin"
    export default Plugin.define({ id: "probe-crash", setup: () => {} })
    // src/tui.ts — the @opentui/core import is NOT here
    import { Plugin } from "@opencode/plugin/tui"
    import { registerDocRenderer } from "./adapters/doc-block.js"
    export default Plugin.define({
      id: "probe-crash",
      setup(context) { registerDocRenderer(context) },
    })
    // src/adapters/doc-block.ts — the import is HERE
    import { BoxRenderable, TextRenderable } from "@opentui/core"
    export function registerDocRenderer(context: any) {
      context.markdown.registerCodeBlockRenderer("doc", (token: { text: string }) => {
        const block = new BoxRenderable(context.renderer, { width: "100%" })
        for (const line of token.text.split("\n"))
          block.add(new TextRenderable(context.renderer, { content: line }))
        return block
      })
    }

    Load it: "plugins": ["/abs/path/probe-crash"], then prompt the model for a streamed ```doc fence whose content arrives in ≥ 2 chunks (e.g. "reply with one ```doc fence containing these 38 lines…"). The renderer is called on every streaming update; the second call replaces the first block → remove throws.

    On the bun test remark

    Agreed: the rewrite only runs inside the OpenCode host, and the test/harness in my original report ran under plain bun, where the opentui:runtime-module: identifier is not resolved. The live repro above supersedes that harness evidence.

    Suggested fix (happy to open a PR on either repo)

    • installRewriteLoader should resolve the path to the file that actually exists on disk before registering the exact-path onLoad filter (TS .js → .ts substitution, as the loader's own resolution already does).
    • As a safety net: remove could accept the global brand (Symbol.for("@opentui/core/Renderable")) that add already uses, so a stray plugin-side copy cannot crash the host.
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