Skip to content

feat(tui): hot-reload local TUI plugins - #39776

Merged
kitlangton merged 16 commits into
v2from
tui-plugin-reload
Jul 31, 2026
Merged

feat(tui): hot-reload local TUI plugins#39776
kitlangton merged 16 commits into
v2from
tui-plugin-reload

Conversation

@kitlangton

@kitlangtonkitlangton commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

What

Editing a local TUI plugin now takes effect in the running TUI without a restart, and a broken plugin — failed import, failed setup, or a crash inside its rendered JSX — is contained to that plugin instead of taking down the generation or the whole app.

Closes#39777.

Stacked on#39916 (storage.memory) and #39917 (component-semantics render fix), which were split out of this branch for independent review; this branch currently contains their commits and will be rebased once they merge.

Before / After

Before: save a change to .opencode/plugins/tui/foo.tsx (or a config-declared local plugin) and nothing happens — nothing watches plugin sources, and loadPlugin's plain import() returns the stale ESM-cached module even when reconcile re-runs. Additionally, every reconcile (config change) tore down and re-ran every plugin including builtins; one throwing cleanup aborted reconcile after deactivating everything and before rebuilding anything (all plugins silently dead); and a throw inside a plugin's slot/route JSX crashed the entire TUI to the stack-trace screen.

After: saving a plugin file swaps that one plugin within ~100ms. Other plugins and builtins are never touched. Saving a broken file keeps the previous version running and shows one error toast; fixing the file swaps in the fix. A crash inside plugin-rendered JSX is contained by a per-plugin boundary and surfaces as one toast.

How

The reconcile now mirrors the core plugin registry's contract (packages/core/src/plugin.ts): recompute the full desired generation, no-op when unchanged, preflight imports before touching anything running — plus one TUI-specific upgrade: per-plugin diffing, because TUI plugins hold visible state (slots, routes, signals) that backend hook registrations don't.

The plugin module is split by responsibility: context.tsx (provider + reconcile lifecycle), api.tsx (the Context surface handed to one plugin activation: dialogs, toasts, router/slot registration, storage namespacing), watch.ts (source watcher), render.tsx (PluginSlot/PluginRoute/PluginBoundary).

packages/tui/src/plugin/context.tsx, watch.ts, render.tsx

  • Watch: fs.watch on the discovery directory and each local entrypoint's parent directory (parent because atomic-save editors replace the inode, matching ConfigProvider), debounced 100ms into the existing serialized reload chain. Watches are deduped, error-tolerant (a vanished directory re-arms on a later reconcile), and die with the provider.
  • Resolve: fold ordered entries (discovered files + config directives) into a desired generation Map<id, Desired>, importing only new/changed local sources via mtime-busted specifiers. The cache-busted specifier doubles as the version token. Import failures keep the running previous version (keep-last-good) and report. npm package targets are never re-resolved once loaded, and first-resolution failures are memoized until config changes so watch events can't retrigger npm installs.
  • Compare: identical generations are a complete no-op, so spurious watch events cost a readdir and a few stats.
  • Swap: only changed/added/removed plugins restart; replacement is in-place to preserve registration order, which slot semantics depend on (mode: "replace" takes the last registration). Cleanup failures are logged into plugin states, never propagated. Membership/order changes rebuild the whole generation (same semantics as config changes today).
  • Render containment: PluginSlot and PluginRoute wrap plugin JSX in a per-plugin ErrorBoundary that renders nothing and toasts once (<id> crashed in slot <name>: <error>).

packages/tui/src/plugin/discovery.ts

  • localSource (local-spec detection), freshSpecifier (Bun busts with plain path + ?mtime=, Node with the full URL — mirrors the core supervisor loader), tuiPluginDirectory (shared by discovery and the watcher).

packages/plugin/src/tui/context.ts, packages/tui/src/routes/session/index.tsx

  • New session.composer.top slot rendered above the composer in the session route, so plugins have a session-view surface.

packages/tui/src/context/storage.tsx, packages/plugin/src/tui/context.ts (from #39916)

  • context.storage.memory(key, { initial }): entries are memoized above the plugin lifecycle, so old and new generations share the same live store across hot reloads — see feat(tui): ephemeral memory storage for plugins #39916 for the API itself; this PR contributes the hot-reload survival semantics and its e2e test.
sequenceDiagram
participant FS as fs.watch
participant R as reconcile
participant M as ESM loader
participant P as plugins
FS->>R: change (debounced 100ms)
R->>M: preflight import changed sources (path?mtime=...)
alt import fails
M-->>R: error
R->>P: keep previous version running
R-->>FS: toast failure, done
else import succeeds
M-->>R: fresh module
R->>R: diff desired vs current generation
R->>P: deactivate + swap only the changed plugin
end
Loading

Scope

  • npm package plugins do not hot-reload; their entrypoints resolve into the package cache and are treated as immutable per session.
  • Directory-target plugins reload on entrypoint changes only; editing a non-entrypoint helper file inside the directory does not change the entrypoint mtime and is skipped.
  • Creating plugins/tui after startup is not detected until the next reconcile; ancestors are deliberately not watched to avoid churn from unrelated .opencode changes.
  • Manual activate/deactivate toggles from the plugins dialog reset on the next reconcile that touches the plugin (config-wins semantics, same as config saves before hot reload; a broken save of a manually-disabled plugin no longer re-activates it).
  • Stale watches from removed config entries persist until the TUI exits (one fs handle plus a no-op reconcile each).
  • Follow-ups deliberately left out: hoisting freshSpecifier into packages/util to share with the core supervisor, and a shared watchPath helper with ConfigProvider.

Testing

  • bun typecheck and bun run test in packages/tui: 578 pass / 0 fail.
  • test/plugin-reload.test.ts: localSource spec mapping (platform-neutral assertions), freshSpecifier cache-busting re-import.
  • test/plugin-hot-reload.test.tsx (real app boots against a fake server, real fs, real watcher):
    • editing a discovered plugin swaps in the fresh module
    • editing plugin B never reruns plugin A's cleanup or setup
    • a broken save keeps the last good version running (its cleanup never fires); fixing the file swaps it out
    • a plugin whose slot render throws does not take down the TUI, and hot reload keeps working afterward
    • storage.memory state survives a hot reload (counter continues across generations)
  • Manually verified in the real dev TUI: crashing slot renders as one error toast on a healthy home screen instead of the crash screen.

Demo

Real end-to-end run of the dev TUI in a scratch project with isolated XDG paths. The plugin contributes a home.footer slot and a toast; saving an edit reloads it live, no restart:

tui-hot-demo.mp4

@kitlangton

Copy link
Copy Markdown
ContributorAuthor

End-to-end demo, recorded from a real headless TUI run of this branch (driven by opencode-drive with a simulated model; file writes and hot reload are real):

recording-1db256b7-01ad-4297-8d67-877cb9f78f35.mp4

What it shows, in order:

  1. The agent creates .opencode/plugins/tui/clock.tsx mid-conversation and a live ticking clock appears above the composer (the new session.composer.top slot) with no restart.
  2. A second edit swaps the running plugin in place: date added, accent color cycling per second.
  3. A save with a syntax error triggers the failure toast while the previous version keeps ticking (keep-last-good).
  4. The fix swaps in a live seconds progress bar.

One harness note: the drive sandbox isolates the simulated project from the checkout's node_modules, so the fixture includes a solid-js shim pointing at the host instance. A normal project needs no shim; the runtime import rewrite covers it (that's the setup the earlier demo video in the PR body used).

@kitlangton

Copy link
Copy Markdown
ContributorAuthor

Second demo, this one exercising the new storage.memory API (real headless run of this branch via opencode-drive; file writes, hot reloads, and physics are real):

recording-d85f0ace-e87e-48f7-ae0b-8f9ad2fc7a14.mp4
  1. The agent creates a bouncing-ball toy in the session.composer.top slot. Ball state (positions, velocities) lives in context.storage.memory, physics runs on an interval that mutates the store.
  2. A restyle hot-swaps the plugin — double borders, cycling colors — and the ball keeps its position and velocity through the reload, because old and new generations share the same live memory store.
  3. A third edit grows it to five balls with elastic collisions; the original ball never resets.

Building this surfaced a real rendering bug that predates this PR: plugin slot renders were invoked inside a tracked host scope, so any reactive read re-executed the whole render body (resetting plugin-local signals and churning intervals). Fixed in 80532b9 by invoking plugin renders through createComponent with reactive props. A deeper follow-up remains (child expressions still resolve in a host-tracked scope, so store-driven updates currently remount the slot component per frame rather than updating fine-grained) — tracked separately with full instrumentation notes.

@kitlangton

Copy link
Copy Markdown
ContributorAuthor

Restructured per review of my own diff: split storage.memory into #39916 and the render component-semantics fix into #39917 (this branch stacks on both until they merge), then factored the plugin module by responsibility — context.tsx (provider + reconcile, 832 → 465 lines), api.tsx (the plugin-facing Context construction), watch.ts (source watcher), render.tsx (slot/route/boundary components). Pure moves plus two dedups (the dialog promise settle boilerplate and the route/slot unregister dance); no behavior change, suite still 584 green.

# ------------------------ >8 ------------------------
# Do not modify or remove the line above.
# Everything below it will be ignored.
#
# Conflicts:
#	packages/tui/src/routes/session/sidebar.tsx
# ------------------------ >8 ------------------------
# Do not modify or remove the line above.
# Everything below it will be ignored.
#
# Conflicts:
#	packages/tui/src/plugin/context.tsx
# ------------------------ >8 ------------------------
# Do not modify or remove the line above.
# Everything below it will be ignored.
#
# Conflicts:
#	packages/tui/src/routes/home.tsx
@kitlangton

Copy link
Copy Markdown
ContributorAuthor

Round 3 fixes in fb9e9b2, addressing six findings from an out-of-band review:

  1. Setup failures no longer discard the last good generation. The swap loop snapshots the running registration before overwriting it; if the replacement's setup() throws, the previous version is restored and reactivated, with the same (previous version still active) state message import failures use. New regression test ("a save whose setup throws restores the previous version"), verified to fail against the old code.
  2. All lifecycle mutations share one serialization chain. Manual activate/deactivate from the plugins dialog now enqueue onto the same loading chain as watch/config reconciles, so a dialog toggle mid-reload can no longer interleave with a generation swap. Shutdown already awaited the chain, so it now covers manual ops too.
  3. Cleanup failures surface instead of vanishing. The swallowed deactivate().catch(() => undefined) sites now toast (<id>: cleanup failed: ...). The swap still proceeds, one broken cleanup should not block a reload, but it is no longer silent. The PR body's earlier claim that these were "logged into states" was wrong; corrected here.
  4. Symlinked plugins reload on target edits. The watcher lstats each source and, for symlinks, also watches the resolved target path. Discovery already accepted symlinks; now editing the target actually triggers a reload. New e2e test, also verified to fail against the old watcher.
  5. Replaced watched directories re-arm. On an event for a directory that no longer exists (Linux emits rename, not error, for this), the watcher closes, forgets the path, and schedules a reconcile so the recreated directory gets a fresh watch.
  6. The crash test asserts its preconditions. Both readiness polls are now hard assertions, so the test fails if the crashing plugin never loads. Render containment itself (boundary fallback + toast) is documented as not exercisable under the test renderer, which never executes slot render bodies; it is verified in the real TUI and the demo runs above.

@kitlangton
kitlangton merged commit 102086c into v2Jul 31, 2026
11 of 12 checks passed
@kitlangton
kitlangton deleted the tui-plugin-reload branch July 31, 2026 20:35
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@kitlangton