opencode-subagents-viewTerminal UI plugin for OpenCode that adds a live "Subagents" panel to the session sidebar. Unofficial, not affiliated with the OpenCode team.
0
151
32 in 7 days
31.9
Multi-signal model
1 month ago
2026-07-17
Install and configure
opencode.jsonWrites to this project's opencode.json — applies to this repository only.
opencode.json
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-subagents-view@0.3.0"]
}Writes to ~/.config/opencode/opencode.json — applies to every project.
~/.config/opencode/opencode.json
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-subagents-view@0.3.0"]
}If you want to modify the plugin locally, install it into the project and reference the local path.
shell
pnpm add -D opencode-subagents-viewopencode loads npm dependencies through its embedded runtime on startup and caches them locally — no manual global install needed.
A terminal UI plugin for OpenCode that adds a live "Subagents" panel to the session sidebar, showing a colored status icon and label for each tracked child session. Unofficial community project, not affiliated with the OpenCode team.
Installation
From npm
Open the command palette (
ctrl+p) inside opencode and select Install plugin. Typeopencode-subagents-viewas the package name, choose local or global scope (global makes it available in every project), and confirm.This installs the package and writes the matching entry into
tui.jsonfor you (global~/.config/opencode/tui.json, or a project-leveltui.jsonif you chose local scope). It also loads the plugin in the current session immediately, no restart needed. This is the way this plugin is actually tested and verified working, starting from a completely clean state.If you'd rather edit
tui.jsonyourself instead (for example to review the exact change first), the equivalent manual entry is:{ "$schema": "https://opencode.ai/config.json", "plugin": ["opencode-subagents-view"] }This plugin only exports a
tuientrypoint, and opencode resolves TUI-kind plugins through a separate config file dedicated to TUI settings, independent from the mainopencode.json. If you go this route instead of the command palette, quit and restart opencode afterward, since config and plugins are only read at startup.Confirm it actually loaded (not just that config accepted it) by opening the command palette (
ctrl+p) and selecting Plugins. Look forsubagents-viewunder "External" with a green active status.Note:
opencode debug infois not a reliable check for this. It only echoes what the config declares, not whether the plugin actually resolved and loaded at runtime, so it can report success even when nothing actually loaded.
From source (for development)
Clone this repo somewhere on your machine, e.g.:
git clone git@github.com:giovannic96/opencode-subagents-view.git ~/repos/personal/opencode-subagents-viewInstall its dependencies:
cd ~/repos/personal/opencode-subagents-view npm installRegister it in
tui.json/tui.jsoncusing the absolute path to this folder instead of the package name:{ "$schema": "https://opencode.ai/config.json", "plugin": ["/absolute/path/to/opencode-subagents-view"] }Two things worth knowing, both learned the hard way while building this:
- Dropping this folder into
.opencode/plugins/(or~/.config/opencode/plugins/) will not work on its own, since opencode's auto-discovery for that folder only picks up bare.ts/.jsfiles, not a package-shaped directory like this one. - Putting the
pluginentry inopencode.jsoninstead oftui.jsonalso will not work: that only affects server-side plugin loading. TUI-kind plugins (like this one) are resolved by a separate config pipeline that readstui.json/tui.jsoncspecifically.
- Dropping this folder into
Quit and restart opencode, then confirm it loaded the same way as in the npm instructions above.
How it works
This is a plain TUI plugin, not a fork or patch of opencode itself. It hooks into the same sidebar_content slot that the built-in Context, LSP, MCP, and Todo sections use, so it renders as just another section in the existing sidebar rather than a separate window or overlay.
Status: being built incrementally, one small, tested, verified-in-a-real-session step at a time. Currently implemented and verified:
- A "Subagents (N active)" section appears only after the current session spawns its first direct child session (e.g. one spawned via the
tasktool).- After that, it stays visible until the session is disposed, even if all tracked children go idle.
- The section renders one row per tracked child, showing a colored status icon, a label that truncates to fit the sidebar, and a second indented line with the current live activity when available. Wrapped lines align under the text on their own row (not under the icon or the arrow), using a fixed-width prefix column plus a growing text column, since the terminal renderer has no built-in hanging-indent for wrapped text.
- The section header can be collapsed and expanded with a mouse click.
- When a new run starts (a fresh child session appears after the section had only idle children), the old idle rows are cleared so the section shows only the new run.
See the repo's commit history for what's landed so far.
Code layout
src/child-sessions-tracker.ts: session membership logic plus the live session subscription. Plain data in, data out.src/child-sessions-types.ts: shared child-session and event types.CHILD_SESSION_EVENT_TYPESis defined once here and the event type union is derived from it, so the rest of the code never repeats it.src/labels-ui.ts: all label and activity formatting helpers (formatChildSessionLabel,getChildSessionDisplayLabel,truncateChildSessionLabel,getChildStatusMeta, and the tool/activity label builders). Plain data in, data out, no solid-js.src/tui.tsx: the package's real./tuientrypoint. Contains no plugin logic of its own. Synchronously patches solid-js's exports (see Why this plugin patches solid-js's exports at runtime), then dynamically importssrc/plugin.tsxfor the actual implementation, from a location outsidenode_modulesif needed (see Why this plugin escapes node_modules to load its own JSX).src/plugin.tsx: the actual plugin implementation, and the only file that touches solid-js (createSignal) and JSX, kept as thin as possible on purpose (see "Why all the solid-js code lives in one file" below). Registers the section atorder: 350in the sharedsidebar_contentslot (built-ins: Context=100, MCP=200, LSP=300, Todo=400, Files=500), placing it right after LSP, before Todo. Also owns the collapse/expand signal for the section header. Starts with a@jsxImportSource @opentui/solidpragma (see Why this plugin escapes node_modules to load its own JSX).
Why all the solid-js code lives in one file
Earlier in the project, createSignal/createEffect was split across files and ended up with different solid-js instances, so updates stopped propagating. The fix was to keep all Solid usage in src/plugin.tsx. Plain TypeScript modules can stay separate.
Development
npm test(orbun test --conditions=browser) runs the test suite. Session-tracking rules and the plugin's reactive wiring are tested together intests/child-sessions-tracker.test.tsusing plain mocks and solid-js'screateRoot, without needing a real opencode instance. The--conditions=browserflag exists becausebun testeagerly discovers this project's entire module graph upfront, including files reached only through a dynamicimport(), which defeats the runtime patch described below for the test suite specifically. This does not affect real usage: opencode discovers this plugin only through a runtime string path read fromtui.json, which it can't pre-scan the same way, verified directly against a real opencode instance starting from a genuinely unpatchedsolid-js.npm run typecheckrunstsc --noEmit.
A real bug this project hit, and how it's guarded against now
An early version of the section re-created state on every render and could trigger a feedback loop. The fix caches state per session id for the plugin lifetime, so later renders reuse the settled state instead of starting over. tests/tui.test.ts pins that down.
The current implementation keeps a record for each child session. Active children count toward the sidebar number, idle children stay visible, and deleted children are removed entirely. The section itself stays visible once the first child has appeared, until the session is disposed.
One subtle but important detail: the cache does not store the count itself. It stores the function returned by getOrCreateChildSessionCount, and that function closes over a Solid signal. The signal changes when setChildIds(...) runs, but the cached function stays the same, so every later call to childSessionCount() still reads the latest number. That is why caching the function is enough, even though the cache entry itself is only written once.
The full flow is:
View(...)renders for a specificsession_id.getOrCreateChildSessionCount(...)checks whether that session already has a cached getter.- On the first render for that session, it creates a Solid signal with an empty
Setof child ids. trackChildSessions(...)subscribes to livesession.created,session.updated,session.status,session.idle,session.deleted,session.next.step.ended,session.next.step.failed,session.next.tool.input.started,session.next.tool.called,session.next.retried, andmessage.part.updatedevents.- The live events are handled a little differently:
session.created: a new direct child session was created. If it belongs to the current parent, it is added asactiveand gets the base label like[agent] title.session.updated: an existing session changed. If it still belongs to the current parent and is already tracked, it stays in whatever state it already had; if it no longer belongs, it is removed.session.status: the session reported a status change.busykeeps the child active,idlemarks it idle, andretrymarks it retry.session.idle: the session explicitly went idle. The child stays in the map but becomesidle.session.next.step.ended: the child finished its current step. The row stays visible, but the status becomesidle.session.next.step.failed: the child failed its current step. The row stays visible, but the status becomeserror.session.next.tool.input.started: the child started preparing a tool call. The row only changes if the tool input already contains a target we can show.session.next.tool.called: the tool was called. The row activity becomes a concrete summary likesearching src/**/*.ts,editing README.md, orrunning shell: bun testwhen the input provides a target. If OpenCode does not give us a target, the existing activity stays in place.session.next.retried: the session retried. The row activity becomesretrying N.message.part.updated: a finalized part arrived or changed. The row activity is derived from the part type, for example a raw text snippet fortext,glob: src/**/*.tsfor completed tool output, orsubtask: ...for delegated work.session.deleted: the child session was deleted. If its id is tracked, it is removed from the map.- Those event names are defined once in
src/child-sessions-types.tsand the type is derived from that list, so the code does not repeat the union in multiple places.
- Whenever one of those cases changes the set,
setChildIds(...)replaces the signal with a new set. - Solid notices that
childIds()changed, sochildSessionCount()is re-evaluated. - The sidebar now shows the updated
Subagents (N active)value, plus a per-row status icon, the original[agent] title, and a second indented line for the current activity when available. If all tracked children are idle, the section stays visible asSubagents (0 active)until a new child arrives, at which point old idle rows are cleared and the new run starts fresh. - If the same session is rendered again, the cached getter is reused instead of creating a new signal or a new listener.
- When the plugin/view is being shut down, for example when you quit opencode, close the terminal, or disable/reload the plugin, the
onDispose(...)callback runs. It callsunsubscribe()so the live event listeners stop and the cached entry for that session is removed. - That cleanup matters because otherwise the plugin would keep listening to old session events even after the view is gone.
Real Example
Suppose the current session is A.
- At first, there are no live child-session events yet, so the sidebar stays hidden.
- Later, opencode creates a new session
BwithparentID: "A". - A
session.createdevent arrives. updateChildSessionMembership(...)sees thatBbelongs toA, so it addsBto the set.- The count changes from
0to1, and the sidebar showsSubagents (1 active). - If
Blater becomes idle, thesession.idleorsession.statusevent keeps it in the record map but marks itidle, and the section stays visible. - If every tracked child is idle, the section stays visible as
Subagents (0 active). - When a new child appears after that idle-only state, the old idle rows are cleared so the section shows only the new run.
- If
Bstarts text, the second line stays as-is unless OpenCode later gives concrete text to show. - If
Bcalls a tool, the second line shows a concrete summary likesearching src/**/*.tsorrunning shell: bun testwhen the tool input has a target, and it stays unchanged when the tool has no target or fails. - If
Bemits a finalized part,message.part.updatedrefreshes the second line with that concrete summary only when there is real text to show. - If
Bfinishes a step, thesession.next.step.endedorsession.next.step.failedevent marks itidleorerror, and the section stays visible. - If OpenCode emits a later
session.updatedforB, the plugin keeps it idle instead of reactivating it. - If
Bis deleted, thesession.deletedevent removes it from the map. - The count goes back to
0, but the sidebar stays visible until the session itself is disposed.
Why this plugin patches solid-js's exports at runtime
solid-js publishes a conditional exports map with a "node" condition that points at its server-side rendering build, a one-shot, non-reactive implementation meant for frameworks that render HTML once on a Node.js backend. Bun explicitly supports and matches that "node" condition for compatibility with the wider Node ecosystem.
That's the right choice for a typical Node.js backend, but wrong here: this plugin runs inside an interactive terminal UI, not a one-shot server render, and needs the same reactive build (the one solid-js's "browser"/default condition points to) that @opentui/solid itself expects. Because opencode dynamically imports this plugin's own unbundled node_modules at runtime (rather than bundling it ahead of time the way opencode's own internal UI code is built), Bun's normal condition matching kicks in and picks the wrong one, not just for this plugin's own code, but also for @opentui/solid's own internal solid-js imports, since its Bun-specific build also imports solid-js as a bare specifier.
The symptom, if this isn't patched, is subtle and confusing: createSignal, createEffect, and createMemo all still exist and don't throw, but nothing created with them ever updates after the initial render, because the resolved solid-js build's createEffect is a literal no-op and its signals don't notify subscribers.
This plugin does not bundle solid-js or @opentui/solid into a single prebuilt file. An earlier version tried that, and while it initially seemed to break the sidebar entirely, a corrected version of that same bundling approach (using bun build --target=bun --conditions=browser, so @opentui/solid's actual Bun-specific build gets selected and inlined rather than a generic fallback) was verified live against a real opencode instance and did register successfully. It still did not render the actual "Subagents" section, though, so it was set aside in favor of the fix described below regardless of the initial registration success.
This plugin also does not use a postinstall script, even though that's the more common way to patch a dependency (for example with patch-package). Both approaches were tried and both failed for the same underlying reason: opencode's own "Install Plugin" command, the way a first-time user actually installs this plugin from npm without cloning any source, installs the package without running lifecycle scripts. A postinstall step verified working when installing from source (a real npm install invocation) silently never runs when opencode installs the plugin itself, leaving solid-js unpatched and the sidebar broken for exactly the users this package is published for.
The fix instead lives in the plugin's own module graph. src/tui.tsx, the package's real ./tui entrypoint, contains no plugin logic of its own. It synchronously locates and patches solid-js's package.json (removing the "node" condition, resolving the correct copy via require.resolve("solid-js", { paths: [here] }) relative to its own file location, which always finds the right copy regardless of how this plugin was installed or how deeply it ends up nested), and only then dynamically imports the actual implementation (see Why this plugin escapes node_modules to load its own JSX for what that import actually resolves to), which is the file that statically imports solid-js and @opentui/solid. Because dynamic import() calls in a real runtime module graph (unlike bun test's own eager whole-project scanning) only get resolved at the point they're actually reached, that file's own solid-js import, and @opentui/solid's internal one, both resolve after the patch has already been applied.
This guarantees the patch runs every single time this plugin loads, through any installation path, with no separate install-time step required at all. It was verified end to end starting from a genuinely fresh, unpatched solid-js (confirmed via its package.json before each run), loaded directly by a real opencode instance, spawning a real subagent, and observing the "Subagents" section render correctly with live activity.
Why this plugin escapes node_modules to load its own JSX
src/plugin.tsx uses JSX, and its type of JSX (@opentui/solid's, not React's) is only chosen correctly if the host transpiling it knows to use jsxImportSource: "@opentui/solid". This project's own tsconfig.json configures that, and a per-file @jsxImportSource @opentui/solid pragma comment at the top of src/plugin.tsx does the same thing directly in the source, read by whatever transpiles the file rather than looked up externally.
Neither one is enough for how this plugin is actually installed by a real npm user, whether through a plain npm install into some project or through opencode's own "Install Plugin" command: either way, this plugin's files end up inside a node_modules folder, and Bun applies a different default for any file resolved from a path containing a node_modules segment, ignoring both the project's tsconfig.json and the per-file pragma comment, falling back to assuming React's JSX runtime instead. This was verified directly: the exact same files, with the exact same pragma, render correctly when placed at a path with no node_modules segment in it, and fail to render (without erroring) when placed at a path that has one, even when the only difference between the two is the path itself. Shipping tsconfig.json in the published package was tried and confirmed not to help for the same reason. Prebuilding the JSX away entirely ahead of time (transpiling src/plugin.tsx's JSX into explicit calls at publish time, still without bundling solid-js or @opentui/solid themselves) was also tried, and while it fixed the resolution problem on its own, it broke rendering the same way an earlier attempt at bundling solid-js and @opentui/solid together did: the plugin registered and imported correctly, but the "Subagents" section still didn't render, verified live against a real opencode instance, and with both the "dev" and "production" JSX runtime variants. Both were set aside.
The fix that works is for src/tui.tsx to notice, at load time, whether its own package root sits inside a node_modules folder, and if so, copy src/plugin.tsx and its sibling implementation files to a stable location outside node_modules (under the OS temp directory, namespaced by this package's own version), with node_modules symlinked back to this package's own dependencies so the copy still resolves solid-js and @opentui/solid normally, and dynamically import the implementation from that copied location instead of its original one. When the package root is already outside node_modules, which is the case for the "from source" development flow, this copying step is skipped entirely and the implementation is imported directly.
This was verified with the same rigor as the solid-js fix above: a real npm tarball, installed with --ignore-scripts into a fresh nested project (mirroring exactly how opencode's own installer behaves), and a real opencode instance loaded from that exact nested node_modules installation, spawning a real subagent and rendering the "Subagents" section correctly with live activity, starting from a completely clean state with no prior installation of this plugin at all.
License
MIT, see LICENSE.