Uh oh!
There was an error while loading. Please reload this page.
feat(tui): hot-reload local TUI plugins - #39776
Conversation
7dbb3c0 to
53632dfComparekitlangton
commented
Jul 31, 2026
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.mp4What it shows, in order:
One harness note: the drive sandbox isolates the simulated project from the checkout's node_modules, so the fixture includes a |
kitlangton
commented
Jul 31, 2026
Second demo, this one exercising the new recording-d85f0ace-e87e-48f7-ae0b-8f9ad2fc7a14.mp4
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 |
kitlangton
commented
Jul 31, 2026
Restructured per review of my own diff: split |
# ------------------------ >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
commented
Jul 31, 2026
Round 3 fixes in fb9e9b2, addressing six findings from an out-of-band review:
|
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, andloadPlugin's plainimport()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(theContextsurface 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.tsxfs.watchon the discovery directory and each local entrypoint's parent directory (parent because atomic-save editors replace the inode, matchingConfigProvider), 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.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.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).PluginSlotandPluginRoutewrap plugin JSX in a per-pluginErrorBoundarythat renders nothing and toasts once (<id> crashed in slot <name>: <error>).packages/tui/src/plugin/discovery.tslocalSource(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.tsxsession.composer.topslot 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.Scope
plugins/tuiafter startup is not detected until the next reconcile; ancestors are deliberately not watched to avoid churn from unrelated.opencodechanges.freshSpecifierintopackages/utilto share with the core supervisor, and a sharedwatchPathhelper withConfigProvider.Testing
bun typecheckandbun run testinpackages/tui: 578 pass / 0 fail.test/plugin-reload.test.ts:localSourcespec mapping (platform-neutral assertions),freshSpecifiercache-busting re-import.test/plugin-hot-reload.test.tsx(real app boots against a fake server, real fs, real watcher):storage.memorystate survives a hot reload (counter continues across generations)Demo
Real end-to-end run of the dev TUI in a scratch project with isolated XDG paths. The plugin contributes a
home.footerslot and a toast; saving an edit reloads it live, no restart:tui-hot-demo.mp4