Repository navigation
Description
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
- A TUI plugin (its own
node_modules, installed from a local path) callscontext.markdown.registerCodeBlockRenderer("doc", renderer)insetup— the pattern documented at https://opencode.ai/v2/docs/build/plugins/cli/ (§ Markdown); the docs' own example declares@opentui/coreas a peer dependency, i.e. the plugin builds renderables from its own copy. - The renderer builds its block with the plugin's own
@opentui/core(new BoxRenderable(context.renderer, …)+TextRenderablechildren). The host core is not reachable from a plugin (see Additional context). - In a session, the assistant emits a
```docfenced code block. - 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/insertBeforeon the host's renderable tree accept a child if it carries the brandSymbol.for("@opentui/core/Renderable")(verified in published 0.8.2:isRenderable(obj) = !!obj?.[BrandedRenderable], withBrandedRenderable = 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, doeschild instanceof BaseRenderable— a strictinstanceofagainst the host bundle's own baseRenderableclass (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?.renderablefor code blocks, i.e.falsewhenever 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 toCannot find package 'opentui:runtime-module:@opentui'.
Related issues (same error, all closed without a fix for this path):
- TUI crash: remove expects a renderable child object #43693 — closed 2026-08-20 (
completed); the triage bot attributed it to the plugin reload-generation fixes (fix(tui): avoid retrying broken plugin setup #43441 / fix(tui): stabilize plugin reload generations #43447 / fix: eliminate flaky CI races #43522) and noted "fixed in0.0.0-beta-17728and newer" (the crash was reported on0.0.0-beta-17519). - TUI crash: remove expects a renderable child object #43696 — same crash reproducing on
0.0.0-beta-17728(the reported OpenCode version in the issue body is the very beta noted as fixed); closed 2026-10-20 as a duplicate of TUI crash: remove expects a renderable child object #43693 on the strength of that "fixed" note, without re-verification. - TUI crash: remove expects a renderable child object #43699 — same crash, also reported on
0.0.0-beta-17728; closed 2026-08-20 (completed), self-reported by the reporter as a duplicate of TUI crash: remove expects a renderable child object #43696.
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
opencodejr commentedon Nov 5, 2026
Thanks for the detailed write-up! I tried to reproduce this on 2.2.5 but couldn't.
What I tried
node_modules/@opentui/core@0.8.2, installed once with bun and once with pnpm (symlinked). It callscontext.markdown.registerCodeBlockRenderer("doc", …)and returns a freshnew BoxRenderable(context.renderer, …)with aTextRenderablechild, both imported from@opentui/core.cli.jsonplugins, and with a server part fromopencode.jsonplugins.```docfence 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/coreimport) wastrue. The host's runtime plugin loader rewrites a plugin's@opentui/coreimports to the host's own copy. Plugin renderables are therefore hostRenderables, and theinstanceofcheck inremovepasses.The
opentui:runtime-module:rewrite only runs inside the OpenCode host. Under plainbun test, as in yourtest/harness, it isn't active, which would explain theCannot find package 'opentui:runtime-module:@opentui'result from your probe.What would help
opencodeitself.cli.json/opencode.jsonentry oropencode plugin add, and the resolved path.@opentui/corewas imported in a plugin source file, or only inside a dependency or a dynamically loaded file.Related: #43693, #43696, #43699 (same error text, from crash reports during plugin reload); #54126 (the code-block renderer API).
opencodejr commentedon Nov 5, 2026
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.jsonpluginsthat has its own@opentui/core@0.8.2.src/tui.tsimports./adapters/doc-block.js(a.tsfile on disk), and that file importsBoxRenderable/TextRenderableand registers adoccode block renderer. A simulated model streams a```docfence in small chunks.Result:
context.renderer.root instanceof Renderableisfalse.remove expects a renderable child object. As in your stack trace, the host'sremoveis called from the plugin's own copy of@opentui/core(destroy/destroyRecursively)../adapters/doc-block.ts, the check istrueand streaming finishes without a crash. That matches your matrix: the.jsspelling 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
.tsspecifier, or to keep the@opentui/coreimports in the entry file.recording.mp4
Reproduction script (drive.ts)
Run it with opencode-drive:
opencode-drive run ./drive.ts. It drivesopencodefrom yourPATH; setOC_DEVto the path of an OpenCode checkout to run that from source instead.ailonn commentedon Nov 5, 2026
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/coreimport lives: in your plugin it is in the entry file; in ours it is in a nested source file imported with a.jsspecifier (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/corecopy, andremove(strictinstanceofagainst 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/coreimport is not in the entry:2. How it was loaded — global
opencode.jsonc→plugins:Package
exports:.→./src/server.ts,./tui→./src/tui.ts. The host resolves the TUI part as subpathopencode-plugin-docs-open/tuiviaBun.resolveSync(package self-reference) → real path…/opencode-docs/src/tui.ts→prepareSource→ plainimport().3. Where
@opentui/coreis 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.jsspecifier. 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"):
Note the split:
removeruns 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 insidesrc/adapters/doc-block.ts(the nested file) — isfalse. The rewrite does not cover that file. (Yourtruewas with the import in the entry file — see probe B below.)Why the rewrite misses the nested file
Per
@opentui/core0.8.xruntime-plugin.js(installed byensureRuntimePluginSupport, which the host calls frompackages/tui/src/plugin/runtime-plugin-support.bun.ts):onResolvemaps each specifier to a source path viaimport.meta.resolve(specifier, importer), then callsinstallRewriteLoader(path).installRewriteLoadercanonicalizes the path withrealpathSync— the failure is caught silently (fallback to the input path) — and registers anonLoadfiltered on that canonical path exactly.Bun.resolveSyncreturns the existing.tsfile) →realpathSyncsucceeds → the filter matches the file Bun actually loads → the specifier is rewritten to the host module. ✓ (your case)./adapters/doc-block.jsarrives with the.jsspelling:import.meta.resolvereturns…/doc-block.js— no TS substitution is performed, and no such file exists on disk.realpathSyncfails → the filter is registered on a nonexistent path → when Bun loads the realdoc-block.ts, theonLoadfilter never matches → the file is loaded natively, untransformed → its bare@opentui/coreimport also resolves natively (the eageronResolvehook for the runtime specifiers never fires for imports of natively-loaded modules — verified with an instrumentedruntime-plugin.js: zero fires, zero@opentui/coreentries from that file).Control: writing the same import as
./adapters/doc-block.tsmakes the rewrite work (identitytrue) — 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), realensureRuntimePluginSupport, plugin with its ownnode_modulescopy:plugin.BoxRenderable === host.jsspec), symlinked directory (our config)removethrowstrueremovethrows.tsspectrueSo 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)
Load it:
"plugins": ["/abs/path/probe-crash"], then prompt the model for a streamed```docfence 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 →removethrows.On the
bun testremarkAgreed: the rewrite only runs inside the OpenCode host, and the
test/harnessin my original report ran under plain bun, where theopentui:runtime-module:identifier is not resolved. The live repro above supersedes that harness evidence.Suggested fix (happy to open a PR on either repo)
installRewriteLoadershould resolve the path to the file that actually exists on disk before registering the exact-pathonLoadfilter (TS.js→.tssubstitution, as the loader's own resolution already does).removecould accept the global brand (Symbol.for("@opentui/core/Renderable")) thataddalready uses, so a stray plugin-side copy cannot crash the host.