Преглед на файлове

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

Kit Langton преди 1 седмица
родител
ревизия
102086c50f

+ 18 - 0
packages/plugin/src/tui/context.ts

@@ -26,12 +26,27 @@ import type { JSX } from "@opentui/solid"
 import type { Store } from "solid-js/store"
 
 export interface Storage {
+  /**
+   * Durable JSON state: persisted to disk, survives hot reloads and TUI
+   * restarts, and stays live-synced across running TUI instances.
+   */
   store<Value extends object>(
     key: string,
     options: {
       readonly initial: Value
     },
   ): readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
+  /**
+   * Ephemeral in-memory state: survives plugin hot reloads (old and new
+   * generations share the same live store) and is gone when the TUI exits.
+   * Updates are synchronous and values need not be JSON-serializable.
+   */
+  memory<Value extends object>(
+    key: string,
+    options: {
+      readonly initial: Value
+    },
+  ): readonly [Store<Value>, (mutation: (draft: Value) => void) => void]
 }
 
 interface LocationCollection<Value> {
@@ -136,6 +151,9 @@ export interface SlotMap {
     readonly sessionID?: string
     readonly mode: "normal" | "shell"
   }
+  readonly "session.composer.top": {
+    readonly sessionID: string
+  }
   readonly "sidebar.content": {
     readonly sessionID: string
   }

+ 2 - 1
packages/tui/src/app.tsx

@@ -82,7 +82,8 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
 import open from "open"
 import { PromptRefProvider, usePromptRef } from "./context/prompt"
 import { Config, ConfigProvider, useConfig } from "./config"
-import { PluginProvider, PluginRoute, PluginSlot, usePlugin, type PackageResolver } from "./plugin/context"
+import { PluginProvider, usePlugin, type PackageResolver } from "./plugin/context"
+import { PluginRoute, PluginSlot } from "./plugin/render"
 import { CommandPaletteDialog } from "./component/command-palette"
 import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
 

+ 1 - 1
packages/tui/src/component/prompt/index.tsx

@@ -53,7 +53,7 @@ import { useData } from "../../context/data"
 import { useLocation } from "../../context/location"
 import { Keymap, type KeymapCommand } from "../../context/keymap"
 import { abbreviateHome } from "../../runtime"
-import { PluginSlot } from "../../plugin/context"
+import { PluginSlot } from "../../plugin/render"
 
 registerOpencodeSpinner()
 

+ 18 - 1
packages/tui/src/context/storage.tsx

@@ -1,5 +1,5 @@
 import { batch, createContext, onCleanup, useContext, type ParentProps } from "solid-js"
-import { createStore, reconcile, type Store } from "solid-js/store"
+import { createStore, produce, reconcile, type Store } from "solid-js/store"
 import path from "path"
 import { mkdirSync, readFileSync, watch } from "fs"
 import { Flock } from "@opencode-ai/util/flock"
@@ -13,12 +13,20 @@ type Options<Value extends object> = {
 }
 
 type Entry<Value extends object> = readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
+type MemoryEntry<Value extends object> = readonly [Store<Value>, (mutation: (draft: Value) => void) => void]
 
 export interface Storage {
   store<Value extends object>(
     key: string,
     options: Options<Value>,
   ): readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
+  /**
+   * Ephemeral in-process state. Entries are memoized here, above consumer
+   * lifecycles, so the same live store survives plugin hot reloads; it is
+   * gone when the TUI exits. Updates are synchronous and values need not be
+   * JSON-serializable.
+   */
+  memory<Value extends object>(key: string, options: { readonly initial: Value }): MemoryEntry<Value>
 }
 
 function clone<Value extends object>(value: Value) {
@@ -37,6 +45,7 @@ function segment(value: string) {
 
 function createStorage(root: string, channel: string) {
   const entries = new Map<string, { readonly value: Entry<object>; readonly reload: () => void }>()
+  const memories = new Map<string, MemoryEntry<object>>()
   const directory = path.join(root, segment(channel), "tui")
   const locks = path.join(root, segment(channel), "locks")
   mkdirSync(directory, { recursive: true })
@@ -73,6 +82,14 @@ function createStorage(root: string, channel: string) {
       entries.set(file, { value: entry as Entry<object>, reload })
       return entry
     },
+    memory<Value extends object>(key: string, options: { readonly initial: Value }) {
+      const existing = memories.get(key)
+      if (existing) return existing as MemoryEntry<Value>
+      const [store, setStore] = createStore(options.initial)
+      const entry = [store, (mutation: (draft: Value) => void) => setStore(produce(mutation))] as const
+      memories.set(key, entry as MemoryEntry<object>)
+      return entry
+    },
   }
 
   const watcher = watch(directory, () => entries.forEach((entry) => entry.reload()))

+ 274 - 0
packages/tui/src/plugin/api.tsx

@@ -0,0 +1,274 @@
+import { PluginContextProvider } from "@opencode-ai/plugin/tui"
+import type { JSX } from "solid-js"
+import type { Context, Dialog, Page, Slot, SlotMap, Toast } from "@opencode-ai/plugin/tui/context"
+import { useRenderer } from "@opentui/solid"
+import { useClient } from "../context/client"
+import { useData } from "../context/data"
+import { Keymap } from "../context/keymap"
+import { useRoute } from "../context/route"
+import { useTuiApp, useTuiPaths } from "../context/runtime"
+import { useLocation } from "../context/location"
+import { useThemes } from "../context/theme"
+import { DialogAlert } from "../ui/dialog-alert"
+import { DialogConfirm } from "../ui/dialog-confirm"
+import { DialogPrompt } from "../ui/dialog-prompt"
+import { DialogSelect } from "../ui/dialog-select"
+import { useDialog } from "../ui/dialog"
+import { useToast } from "../ui/toast"
+import { useAttention } from "../context/attention"
+import { useStorage } from "../context/storage"
+import { useSessionTabs } from "../context/session-tabs"
+import { abbreviateHome } from "../util/path-format"
+
+export type Dispose = () => Promise<void>
+
+// The provider's registration store, narrowed to what a plugin context needs:
+// route/slot registration lands there, but ordering and lifecycle stay owned
+// by the provider.
+export type Registry = {
+  has(kind: "routes" | "slots", name: string): boolean
+  set(kind: "routes", name: string, page: Page): void
+  set(kind: "slots", name: string, slot: Slot): void
+  remove(kind: "routes" | "slots", name: string): void
+  active(): boolean
+}
+
+// The host services a plugin context adapts. Collected once by the provider
+// (hooks must run during component setup) and shared by every activation.
+export function usePluginHost() {
+  return {
+    renderer: useRenderer(),
+    client: useClient(),
+    data: useData(),
+    route: useRoute(),
+    keymap: Keymap.use(),
+    shortcuts: Keymap.useShortcuts(),
+    keymapState: Keymap.useState(),
+    app: useTuiApp(),
+    paths: useTuiPaths(),
+    location: useLocation(),
+    themes: useThemes(),
+    dialog: useDialog(),
+    toast: useToast(),
+    attention: useAttention(),
+    storage: useStorage(),
+    sessionTabs: useSessionTabs(),
+  }
+}
+
+// Build the API surface handed to one plugin activation: host services
+// adapted to the plugin contract, with everything registered through it
+// unwinding via `owned` when the activation is disposed.
+export function createPluginContext(input: {
+  host: ReturnType<typeof usePluginHost>
+  id: string
+  options: Readonly<Record<string, any>> | undefined
+  owned: Dispose[]
+  registry: Registry
+}): Context {
+  const host = input.host
+  let context: Context
+  // Every dialog and registered render is wrapped so plugin components can
+  // reach their own context through usePlugin().
+  const provide = (render: () => JSX.Element) => (
+    <PluginContextProvider value={context}>{render()}</PluginContextProvider>
+  )
+  const dialogApi = createDialogApi(host.dialog, provide)
+  const toastApi: Toast = {
+    show(options) {
+      host.toast.show({ ...options, variant: options.variant ?? "info" })
+    },
+  }
+  // Unregistering after deactivation is a no-op: deactivate already resets
+  // the registration's routes and slots wholesale.
+  const registration = (kind: "routes" | "slots", name: string) => {
+    let registered = true
+    const unregister = () => {
+      if (!registered) return
+      registered = false
+      if (!input.registry.active()) return
+      input.registry.remove(kind, name)
+    }
+    input.owned.push(async () => unregister())
+    return unregister
+  }
+  context = {
+    options: input.options ?? {},
+    get location() {
+      return host.location.current
+    },
+    app: { version: host.app.version, channel: host.app.channel },
+    renderer: host.renderer,
+    client: host.client.api,
+    data: host.data,
+    attention: host.attention,
+    get theme() {
+      return host.themes.currentTokens()
+    },
+    keymap: {
+      layer: Keymap.createLayer,
+      dispatch: host.keymap.dispatch,
+      shortcuts: host.shortcuts.list,
+      commands: host.keymapState.commands,
+      pending: host.keymapState.pending,
+      active: host.keymapState.active,
+      mode: host.keymap.mode,
+    },
+    storage: {
+      store: (key, options) => host.storage.store(`plugin.${input.id}.${key}`, options),
+      memory: (key, options) => host.storage.memory(`plugin.${input.id}.${key}`, options),
+    },
+    ui: {
+      dialog: dialogApi,
+      toast: toastApi,
+      format: {
+        path: (value) => abbreviateHome(value, host.paths.home),
+      },
+      router: {
+        register(page) {
+          if (input.registry.has("routes", page.name)) throw new Error(`Route already registered: ${page.name}`)
+          input.registry.set("routes", page.name, {
+            ...page,
+            render: (data) => provide(() => page.render(data)),
+          })
+          return registration("routes", page.name)
+        },
+        navigate(destination) {
+          if (destination.type === "plugin") {
+            host.route.navigate({ ...destination, id: "id" in destination ? destination.id : input.id })
+            return
+          }
+          host.route.navigate(destination)
+        },
+        current() {
+          return host.route.data
+        },
+      },
+      tabs: {
+        enabled: host.sessionTabs.enabled,
+        list: () =>
+          host.sessionTabs.tabs().map((tab) => ({
+            ...tab,
+            active: host.sessionTabs.current() === tab.sessionID,
+            ...host.sessionTabs.status(tab.sessionID),
+          })),
+        open(sessionID) {
+          if (!host.sessionTabs.enabled()) return false
+          host.sessionTabs.select(sessionID)
+          return true
+        },
+        focus(sessionID) {
+          if (!host.sessionTabs.enabled()) return false
+          if (!host.sessionTabs.tabs().some((tab) => tab.sessionID === sessionID)) return false
+          host.sessionTabs.select(sessionID)
+          return true
+        },
+        close(sessionID) {
+          if (!host.sessionTabs.enabled()) return false
+          const target = sessionID ?? host.sessionTabs.current()
+          if (!target || !host.sessionTabs.tabs().some((tab) => tab.sessionID === target)) return false
+          host.sessionTabs.close(target)
+          return true
+        },
+      },
+      slot(name, render) {
+        if (input.registry.has("slots", name)) throw new Error(`Slot already registered: ${name}`)
+        // The registration map erases the slot-specific input type.
+        input.registry.set("slots", name, ((slotInput: SlotMap[typeof name]) =>
+          provide(() => render(slotInput))) as Slot)
+        return registration("slots", name)
+      },
+    },
+  }
+  return context
+}
+
+// A dialog promise must settle exactly once even when confirm and close
+// callbacks both fire.
+function settle<T>(resolve: (value: T) => void) {
+  let settled = false
+  return (value: T) => {
+    if (settled) return
+    settled = true
+    resolve(value)
+  }
+}
+
+function createDialogApi(dialog: ReturnType<typeof useDialog>, provide: (render: () => JSX.Element) => JSX.Element) {
+  const api: Dialog = {
+    show(render, onClose) {
+      dialog.replace(() => provide(render), onClose)
+    },
+    set(options) {
+      dialog.setSize(options.size ?? "medium")
+      dialog.setCentered(options.centered ?? false)
+    },
+    clear() {
+      dialog.clear()
+    },
+    alert(options) {
+      return new Promise<void>((resolve) => {
+        const done = settle(resolve)
+        api.show(() => <DialogAlert title={options.title} message={options.message} onConfirm={done} />, done)
+      })
+    },
+    confirm(options) {
+      return new Promise<boolean | undefined>((resolve) => {
+        const done = settle(resolve)
+        api.show(
+          () => (
+            <DialogConfirm
+              title={options.title}
+              message={options.message}
+              label={options.label}
+              onConfirm={() => done(true)}
+              onCancel={() => done(false)}
+            />
+          ),
+          () => done(undefined),
+        )
+      })
+    },
+    prompt(options) {
+      return new Promise<string | undefined>((resolve) => {
+        const done = settle(resolve)
+        api.show(
+          () => (
+            <DialogPrompt
+              title={options.title}
+              description={options.description ? () => <text>{options.description}</text> : undefined}
+              placeholder={options.placeholder}
+              value={options.value}
+              onConfirm={(value) => {
+                done(value)
+                api.clear()
+              }}
+            />
+          ),
+          () => done(undefined),
+        )
+      })
+    },
+    select(options) {
+      return new Promise((resolve) => {
+        const done = settle<(typeof options.options)[number]["value"] | undefined>(resolve)
+        api.show(
+          () => (
+            <DialogSelect
+              title={options.title}
+              placeholder={options.placeholder}
+              options={options.options.map((option) => ({ ...option }))}
+              current={options.current}
+              onSelect={(option) => {
+                done(option.value)
+                api.clear()
+              }}
+            />
+          ),
+          () => done(undefined),
+        )
+      })
+    },
+  }
+  return api
+}

+ 276 - 385
packages/tui/src/plugin/context.tsx

@@ -1,53 +1,25 @@
-import { PluginContextProvider, type Plugin } from "@opencode-ai/plugin/tui"
-import {
-  batch,
-  createComponent,
-  createContext,
-  createEffect,
-  createMemo,
-  For,
-  mergeProps,
-  on,
-  onCleanup,
-  onMount,
-  useContext,
-  type JSX,
-  type ParentProps,
-} from "solid-js"
+import type { Plugin } from "@opencode-ai/plugin/tui"
+import { batch, createContext, createEffect, on, onCleanup, onMount, useContext, type ParentProps } from "solid-js"
 import path from "path"
 import { stat } from "fs/promises"
 import { fileURLToPath, pathToFileURL } from "url"
-import type { Context, Dialog, Page, Slot, SlotMap, SlotName, Toast } from "@opencode-ai/plugin/tui/context"
+import type { Page, Slot, SlotName } from "@opencode-ai/plugin/tui/context"
 import { createStore, produce, reconcile as reconcileStore } from "solid-js/store"
-import { useRenderer } from "@opentui/solid"
+import { isDeepEqual } from "remeda"
 import "#runtime-plugin-support"
 import { useConfig } from "../config"
-import { useClient } from "../context/client"
-import { useData } from "../context/data"
-import { Keymap } from "../context/keymap"
-import { useRoute } from "../context/route"
-import { useTuiApp, useTuiLifecycle, useTuiPaths } from "../context/runtime"
-import { useLocation } from "../context/location"
-import { useThemes } from "../context/theme"
-import { DialogAlert } from "../ui/dialog-alert"
-import { DialogConfirm } from "../ui/dialog-confirm"
-import { DialogPrompt } from "../ui/dialog-prompt"
-import { DialogSelect } from "../ui/dialog-select"
-import { useDialog } from "../ui/dialog"
-import { useToast } from "../ui/toast"
-import { useAttention } from "../context/attention"
-import { useStorage } from "../context/storage"
-import { useSessionTabs } from "../context/session-tabs"
-import { abbreviateHome } from "../util/path-format"
+import { useTuiLifecycle } from "../context/runtime"
+import { errorMessage } from "../util/error"
 import { builtins } from "./builtins"
-import { discoverTuiPlugins } from "./discovery"
+import { createPluginContext, usePluginHost, type Dispose } from "./api"
+import { createSourceWatcher } from "./watch"
+import { discoverTuiPlugins, freshSpecifier, localSource, tuiPluginDirectory } from "./discovery"
 
 export interface PackageResolver {
   readonly resolve: (spec: string) => Promise<string | undefined>
 }
 
 type State =
-  | { readonly target: string; readonly status: "loading" }
   | { readonly target: string; readonly id: string; readonly status: "active" | "inactive" }
   | { readonly target: string; readonly status: "unsupported" }
   | { readonly target: string; readonly status: "failed"; readonly error: string }
@@ -63,15 +35,16 @@ type Value = {
   readonly list: () => ReadonlyArray<State>
   readonly registered: () => ReadonlyArray<RegisteredPlugin>
   readonly route: (id: string, name: string) => Page["render"] | undefined
-  readonly slot: <Name extends SlotName>(name: Name) => ReadonlyArray<Slot<Name>>
+  readonly slot: <Name extends SlotName>(name: Name) => ReadonlyArray<{ readonly id: string; readonly render: Slot<Name> }>
   readonly activate: (id: string) => Promise<boolean>
   readonly deactivate: (id: string) => Promise<boolean>
 }
 
-type Dispose = () => Promise<void>
 type Registration = {
   plugin: Plugin.Definition
   source: RegisteredPlugin["source"]
+  target?: string
+  version: string
   options?: Readonly<Record<string, any>>
   active: boolean
   routes: Record<string, Page>
@@ -79,27 +52,15 @@ type Registration = {
   cleanups: Dispose[]
 }
 
+// One entry of the desired plugin generation produced by the resolve phase.
+type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "options"> & { enabled: boolean }
+
 const PluginContext = createContext<Value>()
 
 export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>) {
-  const renderer = useRenderer()
-  const client = useClient()
-  const data = useData()
-  const route = useRoute()
+  const host = usePluginHost()
   const config = useConfig()
-  const keymap = Keymap.use()
-  const shortcuts = Keymap.useShortcuts()
-  const keymapState = Keymap.useState()
   const lifecycle = useTuiLifecycle()
-  const app = useTuiApp()
-  const paths = useTuiPaths()
-  const location = useLocation()
-  const themes = useThemes()
-  const dialog = useDialog()
-  const toast = useToast()
-  const attention = useAttention()
-  const storage = useStorage()
-  const sessionTabs = useSessionTabs()
   const directory = config.path ? path.dirname(config.path) : process.cwd()
   const [store, setStore] = createStore({
     ready: false,
@@ -117,223 +78,26 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
       setStore("registrations", id, "cleanups", [])
     })
     const owned: Dispose[] = []
-    let context: Context
-    const dialogApi: Dialog = {
-      show(render, onClose) {
-        dialog.replace(() => <PluginContextProvider value={context}>{render()}</PluginContextProvider>, onClose)
-      },
-      set(options) {
-        dialog.setSize(options.size ?? "medium")
-        dialog.setCentered(options.centered ?? false)
-      },
-      clear() {
-        dialog.clear()
-      },
-      alert(options) {
-        return new Promise<void>((resolve) => {
-          let settled = false
-          const done = () => {
-            if (settled) return
-            settled = true
-            resolve()
-          }
-          dialogApi.show(() => <DialogAlert title={options.title} message={options.message} onConfirm={done} />, done)
-        })
-      },
-      confirm(options) {
-        return new Promise<boolean | undefined>((resolve) => {
-          let settled = false
-          const done = (result: boolean | undefined) => {
-            if (settled) return
-            settled = true
-            resolve(result)
-          }
-          dialogApi.show(
-            () => (
-              <DialogConfirm
-                title={options.title}
-                message={options.message}
-                label={options.label}
-                onConfirm={() => done(true)}
-                onCancel={() => done(false)}
-              />
-            ),
-            () => done(undefined),
-          )
-        })
-      },
-      prompt(options) {
-        return new Promise<string | undefined>((resolve) => {
-          let settled = false
-          const done = (result: string | undefined) => {
-            if (settled) return
-            settled = true
-            resolve(result)
-          }
-          dialogApi.show(
-            () => (
-              <DialogPrompt
-                title={options.title}
-                description={options.description ? () => <text>{options.description}</text> : undefined}
-                placeholder={options.placeholder}
-                value={options.value}
-                onConfirm={(value) => {
-                  done(value)
-                  dialogApi.clear()
-                }}
-              />
-            ),
-            () => done(undefined),
-          )
-        })
-      },
-      select(options) {
-        return new Promise((resolve) => {
-          let settled = false
-          const done = (result: (typeof options.options)[number]["value"] | undefined) => {
-            if (settled) return
-            settled = true
-            resolve(result)
-          }
-          dialogApi.show(
-            () => (
-              <DialogSelect
-                title={options.title}
-                placeholder={options.placeholder}
-                options={options.options.map((option) => ({ ...option }))}
-                current={options.current}
-                onSelect={(option) => {
-                  done(option.value)
-                  dialogApi.clear()
-                }}
-              />
-            ),
-            () => done(undefined),
-          )
-        })
-      },
-    }
-    const toastApi: Toast = {
-      show(options) {
-        toast.show({ ...options, variant: options.variant ?? "info" })
-      },
-    }
-    context = {
-      options: item.options ?? {},
-      get location() {
-        return location.current
-      },
-      app: { version: app.version, channel: app.channel },
-      renderer,
-      client: client.api,
-      data,
-      attention,
-      get theme() {
-        return themes.currentTokens()
-      },
-      keymap: {
-        layer: Keymap.createLayer,
-        dispatch: keymap.dispatch,
-        shortcuts: shortcuts.list,
-        commands: keymapState.commands,
-        pending: keymapState.pending,
-        active: keymapState.active,
-        mode: keymap.mode,
-      },
-      storage: {
-        store: (key, options) => storage.store(`plugin.${item.plugin.id}.${key}`, options),
-      },
-      ui: {
-        dialog: dialogApi,
-        toast: toastApi,
-        format: {
-          path: (value) => abbreviateHome(value, paths.home),
-        },
-        router: {
-          register(page) {
-            if (store.registrations[item.plugin.id]?.routes[page.name])
-              throw new Error(`Route already registered: ${page.name}`)
-            setStore("registrations", item.plugin.id, "routes", page.name, {
-              ...page,
-              render: (input) => <PluginContextProvider value={context}>{page.render(input)}</PluginContextProvider>,
-            })
-            let registered = true
-            const unregister = () => {
-              if (!registered) return
-              registered = false
-              if (!store.registrations[item.plugin.id]?.active) return
-              setStore(
-                "registrations",
-                produce((registrations) => {
-                  if (!registrations[item.plugin.id]) return
-                  delete registrations[item.plugin.id].routes[page.name]
-                }),
-              )
-            }
-            owned.push(async () => unregister())
-            return unregister
-          },
-          navigate(destination) {
-            if (destination.type === "plugin") {
-              route.navigate({ ...destination, id: "id" in destination ? destination.id : item.plugin.id })
-              return
-            }
-            route.navigate(destination)
-          },
-          current() {
-            return route.data
-          },
-        },
-        tabs: {
-          enabled: sessionTabs.enabled,
-          list: () =>
-            sessionTabs.tabs().map((tab) => ({
-              ...tab,
-              active: sessionTabs.current() === tab.sessionID,
-              ...sessionTabs.status(tab.sessionID),
-            })),
-          open(sessionID) {
-            if (!sessionTabs.enabled()) return false
-            sessionTabs.select(sessionID)
-            return true
-          },
-          focus(sessionID) {
-            if (!sessionTabs.enabled()) return false
-            if (!sessionTabs.tabs().some((tab) => tab.sessionID === sessionID)) return false
-            sessionTabs.select(sessionID)
-            return true
-          },
-          close(sessionID) {
-            if (!sessionTabs.enabled()) return false
-            const target = sessionID ?? sessionTabs.current()
-            if (!target || !sessionTabs.tabs().some((tab) => tab.sessionID === target)) return false
-            sessionTabs.close(target)
-            return true
-          },
-        },
-        slot(name, render) {
-          if (store.registrations[item.plugin.id]?.slots[name]) throw new Error(`Slot already registered: ${name}`)
-          setStore("registrations", item.plugin.id, "slots", name, () => (input: SlotMap[typeof name]) => (
-            <PluginContextProvider value={context}>{render(input)}</PluginContextProvider>
-          ))
-          let registered = true
-          const unregister = () => {
-            if (!registered) return
-            registered = false
-            if (!store.registrations[item.plugin.id]?.active) return
-            setStore(
-              "registrations",
-              produce((registrations) => {
-                if (!registrations[item.plugin.id]) return
-                delete registrations[item.plugin.id].slots[name]
-              }),
-            )
-          }
-          owned.push(async () => unregister())
-          return unregister
-        },
+    const context = createPluginContext({
+      host,
+      id,
+      options: item.options,
+      owned,
+      registry: {
+        has: (kind, name) => Boolean(store.registrations[id]?.[kind][name]),
+        set: (kind: "routes" | "slots", name: string, value: Page | Slot) =>
+          setStore("registrations", id, kind, name, () => value),
+        remove: (kind, name) =>
+          setStore(
+            "registrations",
+            produce((registrations) => {
+              if (!registrations[id]) return
+              delete registrations[id][kind][name]
+            }),
+          ),
+        active: () => Boolean(store.registrations[id]?.active),
       },
-    }
+    })
     const cleanup = await setup(item.plugin, context, owned).catch((error) => {
       setStore("registrations", id, "routes", reconcileStore({}))
       setStore("registrations", id, "slots", reconcileStore({}))
@@ -376,94 +140,219 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
     return true
   }
 
-  const reconcile = async (configured = config.data.plugins ?? []) => {
-    await Promise.all(
-      Object.entries(store.registrations)
-        .filter(([, registration]) => registration.active)
-        .map(([id]) => deactivate(id)),
+  // Cleanup failures must not stop a swap or teardown, but they should not
+  // vanish either: the old generation may still own listeners or intervals.
+  const deactivateNoisily = (id: string) =>
+    deactivate(id).catch((error) =>
+      host.toast.show({ variant: "error", title: "Plugin", message: `${id}: cleanup failed: ${errorMessage(error)}` }),
     )
-    const entries = [...(await discoverTuiPlugins(paths.cwd)), ...configured]
-    batch(() => {
-      setStore("registrations", reconcileStore({}))
-      setStore("states", [])
-    })
 
-    for (const plugin of builtins) {
-      setStore("registrations", plugin.id, {
-        plugin,
-        source: "builtin",
-        active: false,
-        routes: {},
-        slots: {},
-        cleanups: [],
-      })
-      await activate(plugin.id)
-    }
+  // Every lifecycle mutation — reconciles, manual dialog toggles, shutdown —
+  // is serialized through one chain so generations can never interleave.
+  let loading = Promise.resolve()
+  const enqueue = <T,>(task: () => Promise<T>) => {
+    const result = loading.catch(() => undefined).then(task)
+    loading = result.then(
+      () => undefined,
+      () => undefined,
+    )
+    return result
+  }
+
+  // Hot-reload local plugin sources: watch the discovery directory and any
+  // local entrypoints (see watch.ts for the mechanics), debounced into a
+  // serialized reconcile so bursts of events rebuild the generation once.
+  let pending: ReturnType<typeof setTimeout> | undefined
+  const watcher = createSourceWatcher(() => {
+    clearTimeout(pending)
+    pending = setTimeout(() => {
+      // Observe failures immediately: a plugin cleanup that throws would
+      // otherwise surface as an unhandled rejection until the next trigger.
+      void enqueue(reconcile).catch(() => undefined)
+    }, 100)
+  })
+  onCleanup(() => {
+    clearTimeout(pending)
+    watcher.dispose()
+  })
+
+  // Rebuild the plugin generation as resolve → compare → swap, mirroring the
+  // core plugin registry: fold the ordered entries into a desired end state
+  // (importing only new or changed sources, before anything running is
+  // touched), no-op when the generation is unchanged, and restart only the
+  // plugins that differ. Membership or order changes rebuild the whole
+  // generation to preserve slot-order semantics.
+  // Package resolution failures would otherwise retry a full npm install on
+  // every watch event; remember them until the configuration changes.
+  const npmFailures = new Map<string, string>()
+  const reconcile = async () => {
+    const entries = [...(await discoverTuiPlugins(host.paths.cwd)), ...(config.data.plugins ?? [])]
+    watcher.add(tuiPluginDirectory(host.paths.cwd))
 
+    // Resolve: fold entries into one desired generation. A source that fails
+    // to import keeps its running previous version and only reports failure.
+    const desired = new Map<string, Desired>()
+    for (const plugin of builtins) desired.set(plugin.id, { plugin, source: "builtin", version: "builtin", enabled: true })
+    const failures: State[] = []
     for (const entry of entries) {
       const target = typeof entry === "string" ? entry : entry.package
       if (target.startsWith("-")) {
-        for (const id of Object.keys(store.registrations).filter((id) => matches(target.slice(1), id)))
-          await deactivate(id)
+        for (const item of desired.values()) if (matches(target.slice(1), item.plugin.id)) item.enabled = false
         continue
       }
 
-      const selected = Object.keys(store.registrations).filter((id) => matches(target, id))
+      const selected = [...desired.values()].filter((item) => matches(target, item.plugin.id))
       if (selected.length || target === "*" || target.endsWith(".*") || target.startsWith("opencode.")) {
-        for (const id of selected) await activate(id)
+        for (const item of selected) item.enabled = true
         continue
       }
 
       const options = typeof entry === "string" ? undefined : entry.options
-      setStore("states", (items) => [...items, { target, status: "loading" }])
-      const plugin = await loadPlugin(target, directory, props.packages).catch((error) => {
-        setStore("states", (items) =>
-          items.map((state) =>
-            state.target === target
-              ? { target, status: "failed", error: error instanceof Error ? error.message : String(error) }
-              : state,
-          ),
-        )
-        return undefined
-      })
-      if (!plugin) {
-        setStore("states", (items) =>
-          items.map((state) =>
-            state.target === target && state.status !== "failed" ? { target, status: "unsupported" } : state,
-          ),
-        )
+      // Watch even when the resolve below fails so fixing a broken plugin reloads it.
+      const local = localSource(target, directory)
+      if (local) watcher.add(fileURLToPath(local))
+      const previous = Object.values(store.registrations).find((registration) => registration.target === target)
+      const memo = local ? undefined : npmFailures.get(target)
+      const resolved = memo
+        ? { status: "failed" as const, error: memo }
+        : await resolvePlugin(target, local, options, previous, props.packages).catch((error) => ({
+            status: "failed" as const,
+            error: errorMessage(error),
+          }))
+      if (resolved.status === "unsupported") {
+        failures.push({ target, status: "unsupported" })
         continue
       }
-
-      setStore("registrations", plugin.id, {
-        plugin,
+      if (resolved.status === "failed") {
+        if (!local && !previous) npmFailures.set(target, resolved.error)
+        failures.push({
+          target,
+          status: "failed",
+          error: previous?.active ? `${resolved.error} (previous version still active)` : resolved.error,
+        })
+        if (previous)
+          desired.set(previous.plugin.id, {
+            plugin: previous.plugin,
+            source: previous.source,
+            target,
+            version: previous.version,
+            options: previous.options,
+            enabled: previous.active,
+          })
+        continue
+      }
+      desired.set(resolved.plugin.id, {
+        plugin: resolved.plugin,
         source: "external",
+        target,
+        version: resolved.version,
         options,
-        active: false,
-        routes: {},
-        slots: {},
-        cleanups: [],
+        enabled: true,
       })
-      const error = await activate(plugin.id).then(
-        () => undefined,
-        (error) => (error instanceof Error ? error.message : String(error)),
+    }
+
+    // Compare: unchanged plugins are never touched, and a fully unchanged
+    // generation is a no-op, so spurious watch events cost nothing.
+    const currentIds = Object.keys(store.registrations)
+    const desiredIds = [...desired.keys()]
+    const structural = currentIds.length !== desiredIds.length || currentIds.some((id, index) => desiredIds[index] !== id)
+    if (structural) {
+      await Promise.all(
+        Object.entries(store.registrations)
+          .filter(([, registration]) => registration.active)
+          .map(([id]) => deactivateNoisily(id)),
       )
-      setStore("states", (items) => [
-        ...items.filter((state) => state.target !== target && (!("id" in state) || state.id !== plugin.id)),
-        error
-          ? { target, status: "failed", error }
-          : { target, id: plugin.id, status: "active" },
-      ])
+      setStore("registrations", reconcileStore({}))
     }
+    const changed = structural
+      ? desiredIds
+      : desiredIds.filter((id) => {
+          const registration = store.registrations[id]!
+          const item = desired.get(id)!
+          // enabled derives from config directives alone, so config wins over
+          // manual dialog toggles on every reconcile — the same semantics
+          // config saves had before hot reload existed, just more frequent.
+          return (
+            registration.version !== item.version ||
+            !sameOptions(registration.options, item.options) ||
+            registration.active !== item.enabled
+          )
+        })
+
+    // Swap: cleanup failures surface as a toast, never propagate, so one
+    // broken plugin cannot take the rest of the generation down.
+    const errors = new Map<string, string>()
+    for (const id of changed) {
+      const item = desired.get(id)!
+      const registration = store.registrations[id]
+      const replaced =
+        !registration || registration.version !== item.version || !sameOptions(registration.options, item.options)
+      // Snapshot the running version before it is overwritten: an import
+      // failure keeps last-good in the resolve phase, and a setup failure
+      // must not cost the previous version either.
+      const fallback: Desired | undefined =
+        replaced && registration
+          ? {
+              plugin: registration.plugin,
+              source: registration.source,
+              target: registration.target,
+              version: registration.version,
+              options: registration.options,
+              enabled: registration.active,
+            }
+          : undefined
+      if (replaced) {
+        if (registration) await deactivateNoisily(id)
+        // In-place replacement keeps the registration's key position, which
+        // slot ordering (mode "replace" takes the last one) depends on.
+        setStore("registrations", id, toRegistration(item))
+      }
+      if (!item.enabled) {
+        await deactivateNoisily(id)
+        continue
+      }
+      const error = await activate(id).then(() => undefined, errorMessage)
+      if (!error) continue
+      errors.set(id, error)
+      if (!fallback) continue
+      setStore("registrations", id, toRegistration(fallback))
+      if (!fallback.enabled) continue
+      const restored = await activate(id).then(
+        () => true,
+        () => false,
+      )
+      if (restored) errors.set(id, `${error} (previous version still active)`)
+    }
+
+    const failedTargets = new Set(failures.map((failure) => failure.target))
+    const states: State[] = [
+      ...[...desired.values()].flatMap((item): State[] => {
+        if (item.target === undefined) return []
+        // A failed reload keeps this item running; the failure entry covers it.
+        if (failedTargets.has(item.target)) return []
+        const error = errors.get(item.plugin.id)
+        if (error) return [{ target: item.target, status: "failed", error }]
+        const status = store.registrations[item.plugin.id]?.active ? "active" : "inactive"
+        return [{ target: item.target, id: item.plugin.id, status }]
+      }),
+      ...failures,
+    ]
+    // Surface newly failing plugins; repeated reconciles stay silent.
+    for (const state of states)
+      if (
+        state.status === "failed" &&
+        !store.states.some((prev) => prev.status === "failed" && prev.target === state.target && prev.error === state.error)
+      )
+        host.toast.show({ variant: "error", title: "Plugin", message: `${state.target}: ${state.error}` })
+    setStore("states", reconcileStore(states))
   }
-  let loading = Promise.resolve()
+  const slotItems = new WeakMap<Slot, { readonly id: string; readonly render: Slot }>()
   createEffect(
     on(
       () => JSON.stringify(config.data.plugins ?? []),
       () => {
-        const configured = config.data.plugins ?? []
-        loading = loading.catch(() => undefined).then(() => reconcile(configured))
-        void loading.then(
+        npmFailures.clear()
+        void enqueue(reconcile).then(
           () => setStore("ready", true),
           () => setStore("ready", true),
         )
@@ -480,7 +369,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
           Promise.all(
             Object.entries(store.registrations)
               .filter(([, registration]) => registration.active)
-              .map(([id]) => deactivate(id)),
+              .map(([id]) => deactivate(id).catch(() => undefined)),
           ),
         )
         .then(() => setStore("registrations", reconcileStore({})))
@@ -502,11 +391,22 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
           Object.entries(store.registrations).map(([id, plugin]) => ({ id, source: plugin.source, active: plugin.active })),
         route: (id, name) => store.registrations[id]?.routes[name]?.render,
         slot: (name) =>
-          Object.values(store.registrations).flatMap((registration) =>
-            registration.active && registration.slots[name] ? [registration.slots[name]] : [],
-          ),
-        activate,
-        deactivate,
+          Object.entries(store.registrations).flatMap(([id, registration]) => {
+            const render = registration.active ? registration.slots[name] : undefined
+            if (!render) return []
+            // <For> diffs rows by reference; a stable wrapper per render
+            // function keeps untouched plugins' slot rows (and their state)
+            // alive across other plugins' reloads.
+            const cached = slotItems.get(render)
+            if (cached) return [cached]
+            const item = { id, render }
+            slotItems.set(render, item)
+            return [item]
+          }),
+        // Manual dialog toggles join the same chain as reconciles so a
+        // toggle mid-reload cannot mix registrations across generations.
+        activate: (id) => enqueue(() => activate(id)),
+        deactivate: (id) => enqueue(() => deactivate(id)),
       }}
     >
       {props.children}
@@ -533,17 +433,45 @@ function matches(selector: string, id: string) {
   return selector === "*" || selector === id || (selector.endsWith(".*") && id.startsWith(selector.slice(0, -1)))
 }
 
-async function loadPlugin(spec: string, directory: string, packages: PackageResolver) {
-  const local = spec.startsWith("file://")
-    ? new URL(spec)
-    : spec.startsWith("./") || spec.startsWith("../") || path.isAbsolute(spec)
-      ? pathToFileURL(path.resolve(directory, spec))
-      : undefined
+async function resolvePlugin(
+  spec: string,
+  local: URL | undefined,
+  options: Readonly<Record<string, any>> | undefined,
+  previous: Registration | undefined,
+  packages: PackageResolver,
+) {
+  // Package entrypoints never change within a session, so a loaded previous
+  // version needs no re-resolution (which could otherwise hit npm).
+  if (!local && previous && sameOptions(previous.options, options))
+    return { status: "unchanged" as const, plugin: previous.plugin, version: previous.version }
   const entrypoint = local ? await resolveLocal(local) : await packages.resolve(spec)
-  if (!entrypoint) return
-  const mod: { readonly default?: unknown } = await import(entrypoint)
+  if (!entrypoint) return { status: "unsupported" as const }
+  // The cache-busted specifier doubles as the version: unique per entrypoint
+  // and mtime, so equal versions mean an identical module.
+  const version = local ? freshSpecifier(entrypoint, (await stat(new URL(entrypoint))).mtimeMs) : entrypoint
+  if (previous && previous.version === version && sameOptions(previous.options, options))
+    return { status: "unchanged" as const, plugin: previous.plugin, version }
+  const mod: { readonly default?: unknown } = await import(version)
   if (!isPlugin(mod.default)) throw new Error(`Invalid V2 TUI plugin module: ${spec}`)
-  return mod.default
+  return { status: "loaded" as const, plugin: mod.default, version }
+}
+
+function toRegistration(item: Desired): Registration {
+  return {
+    plugin: item.plugin,
+    source: item.source,
+    target: item.target,
+    version: item.version,
+    options: item.options,
+    active: false,
+    routes: {},
+    slots: {},
+    cleanups: [],
+  }
+}
+
+function sameOptions(a: Registration["options"], b: Registration["options"]) {
+  return isDeepEqual(a ?? null, b ?? null)
 }
 
 async function resolveLocal(url: URL) {
@@ -578,40 +506,3 @@ export function usePlugin() {
   if (!value) throw new Error("PluginProvider is missing")
   return value
 }
-
-export function PluginRoute(props: { readonly fallback: (id: string, name: string) => JSX.Element }) {
-  const plugins = usePlugin()
-  const route = useRoute()
-  const content = createMemo(() => {
-    if (route.data.type !== "plugin") return
-    const render = plugins.route(route.data.id, route.data.name)
-    if (!render) return props.fallback(route.data.id, route.data.name)
-    return createComponent(render, { data: route.data.data })
-  })
-  return <>{content()}</>
-}
-
-export function PluginSlot<Name extends SlotName>(props: {
-  readonly name: Name
-  readonly input: SlotMap[Name]
-  readonly mode: "all" | "replace"
-}) {
-  const plugins = usePlugin()
-  const renderers = createMemo(() => {
-    const items = plugins.slot(props.name)
-    if (props.mode === "replace") return items.slice(-1)
-    return items
-  })
-  return (
-    <For each={renderers()}>
-      {(render) =>
-        // Component semantics: the render body runs once and untracked, so
-        // signals and intervals created inside are stable, while props stay
-        // reactive through the merged getter. A bare render(props.input) call
-        // would run inside the host's tracked scope and re-execute the whole
-        // body (resetting plugin state) on every tracked read.
-        createComponent(render, mergeProps(() => props.input) as SlotMap[Name])
-      }
-    </For>
-  )
-}

+ 22 - 1
packages/tui/src/plugin/discovery.ts

@@ -1,10 +1,15 @@
 import { readdir } from "node:fs/promises"
 import path from "node:path"
+import { fileURLToPath, pathToFileURL } from "node:url"
 
 const extensions = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"])
 
+export function tuiPluginDirectory(cwd: string) {
+  return path.join(cwd, ".opencode", "plugins", "tui")
+}
+
 export async function discoverTuiPlugins(cwd: string) {
-  const directory = path.join(cwd, ".opencode", "plugins", "tui")
+  const directory = tuiPluginDirectory(cwd)
   const entries = await readdir(directory, { withFileTypes: true }).catch((error: unknown) => {
     if (error && typeof error === "object" && Reflect.get(error, "code") === "ENOENT") return []
     return Promise.reject(error)
@@ -14,3 +19,19 @@ export async function discoverTuiPlugins(cwd: string) {
     .map((entry) => path.join(directory, entry.name))
     .sort()
 }
+
+export function localSource(spec: string, directory: string) {
+  if (spec.startsWith("file://")) return new URL(spec)
+  if (spec.startsWith("./") || spec.startsWith("../") || path.isAbsolute(spec))
+    return pathToFileURL(path.resolve(directory, spec))
+  return undefined
+}
+
+// Key local plugin imports by mtime so edited sources re-import fresh instead
+// of hitting the ESM cache. Bun ignores query params when caching file:// URL
+// imports, so bust with a plain path there; Node keys its cache on the full
+// URL. Mirrors the core plugin supervisor's loader.
+export function freshSpecifier(entrypoint: string, mtime: number) {
+  if (typeof Bun !== "undefined") return `${fileURLToPath(entrypoint).replaceAll("\\", "/")}?mtime=${mtime}`
+  return `${entrypoint}?mtime=${mtime}`
+}

+ 84 - 0
packages/tui/src/plugin/render.tsx

@@ -0,0 +1,84 @@
+import { createComponent, createMemo, ErrorBoundary, For, mergeProps, onMount, Show, type JSX, type ParentProps } from "solid-js"
+import type { SlotMap, SlotName } from "@opencode-ai/plugin/tui/context"
+import { useRoute } from "../context/route"
+import { useToast } from "../ui/toast"
+import { errorMessage } from "../util/error"
+import { usePlugin } from "./context"
+
+// Contain render-time plugin crashes: a throwing slot or route must not take
+// down the app or the other plugins. The crash surfaces as one error toast.
+function PluginBoundary(props: ParentProps<{ id: string; where: string }>) {
+  const toast = useToast()
+  return (
+    <ErrorBoundary
+      fallback={(error) => {
+        // One toast per crash: onMount is untracked, so prop updates while
+        // the boundary is latched cannot re-toast.
+        onMount(() =>
+          toast.show({
+            variant: "error",
+            title: "Plugin",
+            message: `${props.id} crashed in ${props.where}: ${errorMessage(error)}`,
+          }),
+        )
+        return null
+      }}
+    >
+      {props.children}
+    </ErrorBoundary>
+  )
+}
+
+export function PluginRoute(props: { readonly fallback: (id: string, name: string) => JSX.Element }) {
+  const plugins = usePlugin()
+  const route = useRoute()
+  const current = createMemo(() => {
+    if (route.data.type !== "plugin") return
+    return {
+      id: route.data.id,
+      name: route.data.name,
+      render: plugins.route(route.data.id, route.data.name),
+      data: route.data.data,
+    }
+  })
+  return (
+    // Keyed so navigation or a hot reload recreates the boundary; otherwise
+    // one crash would latch every future plugin route into the fallback.
+    <Show keyed when={current()}>
+      {(item) => (
+        <PluginBoundary id={item.id} where="route">
+          {item.render ? createComponent(item.render, { data: item.data }) : props.fallback(item.id, item.name)}
+        </PluginBoundary>
+      )}
+    </Show>
+  )
+}
+
+export function PluginSlot<Name extends SlotName>(props: {
+  readonly name: Name
+  readonly input: SlotMap[Name]
+  readonly mode: "all" | "replace"
+}) {
+  const plugins = usePlugin()
+  const renderers = createMemo(() => {
+    const items = plugins.slot(props.name)
+    if (props.mode === "replace") return items.slice(-1)
+    return items
+  })
+  return (
+    <For each={renderers()}>
+      {(item) => (
+        <PluginBoundary id={item.id} where={`slot ${props.name}`}>
+          {
+            // Component semantics: the render body runs once and untracked, so
+            // signals and intervals created inside are stable, while props stay
+            // reactive through the merged getter. A bare item.render(props.input)
+            // call would run inside the host's tracked scope and re-execute the
+            // whole body (resetting plugin state) on every tracked read.
+            createComponent(item.render, mergeProps(() => props.input) as SlotMap[Name])
+          }
+        </PluginBoundary>
+      )}
+    </For>
+  )
+}

+ 75 - 0
packages/tui/src/plugin/watch.ts

@@ -0,0 +1,75 @@
+import path from "path"
+import { existsSync, watch } from "fs"
+import { lstat, realpath, stat } from "fs/promises"
+
+// Watch plugin sources for changes. Files are watched through their parent
+// directory (editors that save by rename replace the inode, which silently
+// kills a direct file watch) and filtered by basename so bursts in busy
+// directories stay quiet. Symlinked files are additionally watched at their
+// resolved target, since edits there emit nothing at the link's location.
+// Directory targets are watched at their root only: edits to nested helper
+// files do not change the entrypoint mtime and are not detected. Watches are
+// never torn down individually (a stale watch costs one fs handle and a
+// spurious onChange); all die with dispose(). Failed or vanished watches are
+// forgotten so a later add() can re-arm once the path exists.
+export function createSourceWatcher(onChange: () => void) {
+  const watchers = new Map<string, ReturnType<typeof watch>>()
+  const watched = new Map<string, Set<string> | null>()
+  let disposed = false
+  const forget = (dir: string) => {
+    watchers.get(dir)?.close()
+    watchers.delete(dir)
+    watched.delete(dir)
+  }
+  const arm = (target: string) => {
+    stat(target)
+      .then((info) => {
+        if (disposed) return
+        const dir = info.isDirectory() ? target : path.dirname(target)
+        // Directories accept every filename (null); files accept their basename.
+        const name = info.isDirectory() ? null : path.basename(target)
+        const existing = watched.get(dir)
+        if (existing !== undefined) {
+          if (name === null) watched.set(dir, null)
+          else existing?.add(name)
+          return
+        }
+        watched.set(dir, name === null ? null : new Set([name]))
+        const watcher = watch(dir, (_event, filename) => {
+          // A replaced directory keeps this watcher on the dead inode (Linux
+          // emits rename, not error); forget it so a later add() re-arms on
+          // the recreated path, and still schedule so reconcile runs now.
+          if (!existsSync(dir)) {
+            forget(dir)
+            onChange()
+            return
+          }
+          // A null filename (platform-dependent) always schedules.
+          const accept = watched.get(dir)
+          if (filename && accept && !accept.has(filename.toString())) return
+          onChange()
+        })
+        // A watched directory can disappear out from under us; without a
+        // listener the error event would crash the process. Forget the path
+        // so a later add can re-arm once it exists again.
+        watcher.on("error", () => forget(dir))
+        watchers.set(dir, watcher)
+      })
+      .catch(() => undefined)
+  }
+  const add = (target: string) => {
+    arm(target)
+    // A symlinked source receives edits at its resolved target.
+    lstat(target)
+      .then((info) => {
+        if (!info.isSymbolicLink()) return
+        return realpath(target).then(arm)
+      })
+      .catch(() => undefined)
+  }
+  const dispose = () => {
+    disposed = true
+    for (const watcher of watchers.values()) watcher.close()
+  }
+  return { add, dispose }
+}

+ 1 - 1
packages/tui/src/routes/home.tsx

@@ -9,7 +9,7 @@ import { useEditorContext } from "../context/editor"
 import { useData } from "../context/data"
 import { useLocation } from "../context/location"
 import { FormPrompt } from "./session/form"
-import { PluginSlot } from "../plugin/context"
+import { PluginSlot } from "../plugin/render"
 import { useTerminalDimensions } from "@opentui/solid"
 
 let once = false

+ 2 - 0
packages/tui/src/routes/session/index.tsx

@@ -80,6 +80,7 @@ import { collapseToolOutput } from "../../util/collapse-tool-output"
 import { Keymap, type KeymapCommand } from "../../context/keymap"
 import { usePathFormatter } from "../../context/path-format"
 import { useLocation } from "../../context/location"
+import { PluginSlot } from "../../plugin/render"
 import {
   cacheReuseDrop,
   createSessionRows,
@@ -1011,6 +1012,7 @@ export function Session() {
               </Show>
             </scrollbox>
             <box flexShrink={0}>
+              <PluginSlot name="session.composer.top" input={{ sessionID: route.sessionID }} mode="all" />
               <Composer
                 sessionID={route.sessionID}
                 open={composer.open || (!!session()?.parentID && forms().length === 0)}

+ 1 - 1
packages/tui/src/routes/session/sidebar.tsx

@@ -2,7 +2,7 @@ import { useData } from "../../context/data"
 import { createMemo, Show } from "solid-js"
 import { useTheme } from "../../context/theme"
 import { useConfig } from "../../config"
-import { PluginSlot } from "../../plugin/context"
+import { PluginSlot } from "../../plugin/render"
 import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
 
 import { getScrollAcceleration } from "../../util/scroll"

+ 263 - 0
packages/tui/test/plugin-hot-reload.test.tsx

@@ -0,0 +1,263 @@
+import { expect, mock, test } from "bun:test"
+import { createTestRenderer } from "@opentui/core/testing"
+import { Effect, FileSystem } from "effect"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { Global } from "@opencode-ai/util/global"
+import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"
+import path from "node:path"
+import { createEventStream, createFetch } from "./fixture/tui-client"
+import { tmpdir } from "./fixture/fixture"
+
+function lifecycleSource(marker: string, id: string, version: string) {
+  return `
+import { appendFile } from "node:fs/promises"
+export default {
+  id: ${JSON.stringify(id)},
+  setup: async () => {
+    await appendFile(${JSON.stringify(marker)}, "${version}:setup\\n")
+    return () => appendFile(${JSON.stringify(marker)}, "${version}:cleanup\\n")
+  },
+}
+`
+}
+
+async function until(read: () => Promise<string>, expected: (value: string | undefined) => boolean) {
+  let value: string | undefined
+  for (let attempt = 0; attempt < 200; attempt++) {
+    value = await read().catch(() => undefined)
+    if (expected(value)) return value
+    await Bun.sleep(50)
+  }
+  return value
+}
+
+async function bootApp(directory: string) {
+  const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
+  const core = await import("@opentui/core")
+  mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
+  const events = createEventStream()
+  const calls = createFetch(undefined, events)
+  const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
+  const cwd = process.cwd()
+  process.chdir(directory)
+  const { run } = await import("../src/app")
+  const task = Effect.runPromise(
+    run({
+      app: { name: "test", version: "test", channel: "test" },
+      server: { endpoint: { url: server.url.toString() } },
+      config: { get: async () => ({}), update: async () => ({}) },
+      packages: { resolve: async () => undefined },
+      args: {},
+      log: () => {},
+    }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
+  )
+  return {
+    task,
+    async [Symbol.asyncDispose]() {
+      process.chdir(cwd)
+      if (!setup.renderer.isDestroyed) setup.renderer.destroy()
+      await server.stop()
+      mock.restore()
+    },
+  }
+}
+
+test("editing a discovered TUI plugin hot-reloads its fresh module", async () => {
+  await using tmp = await tmpdir()
+  const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
+  await mkdir(directory, { recursive: true })
+  const marker = path.join(tmp.path, "marker.txt")
+  const source = path.join(directory, "hot.ts")
+  await writeFile(source, lifecycleSource(marker, "test.hot", "v1"))
+
+  await using app = await bootApp(tmp.path)
+  const read = () => readFile(marker, "utf8")
+  expect(await until(read, (value) => value === "v1:setup\n")).toBe("v1:setup\n")
+
+  await writeFile(source, lifecycleSource(marker, "test.hot", "v2"))
+  expect(await until(read, (value) => value?.includes("v2:setup") ?? false)).toBe("v1:setup\nv1:cleanup\nv2:setup\n")
+
+  process.emit("SIGHUP")
+  await app.task
+})
+
+test("a plugin whose slot render throws does not take down the TUI", async () => {
+  await using tmp = await tmpdir()
+  const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
+  await mkdir(directory, { recursive: true })
+  const markerA = path.join(tmp.path, "a.txt")
+  const markerCrash = path.join(tmp.path, "crash.txt")
+  const sourceA = path.join(directory, "a.ts")
+  await writeFile(sourceA, lifecycleSource(markerA, "test.a", "a1"))
+  await writeFile(
+    path.join(directory, "crash.ts"),
+    `
+import { appendFile } from "node:fs/promises"
+export default {
+  id: "test.crash",
+  setup: async (context: any) => {
+    context.ui.slot("home.footer", () => {
+      throw new Error("boom")
+    })
+    await appendFile(${JSON.stringify(markerCrash)}, "setup\\n")
+  },
+}
+`,
+  )
+
+  await using app = await bootApp(tmp.path)
+  const readA = () => readFile(markerA, "utf8")
+  expect(await until(readA, (value) => value === "a1:setup\n")).toBe("a1:setup\n")
+  // The crashing plugin genuinely loaded and registered its slot; without
+  // this the rest of the test would pass even if it never imported.
+  expect(await until(() => readFile(markerCrash, "utf8"), (value) => value === "setup\n")).toBe("setup\n")
+
+  // The app survives the crashing slot: hot reload still works for others.
+  // The render-time boundary itself (fallback + toast) is not exercisable
+  // here: the test renderer never executes slot render bodies, so render
+  // containment is verified in the real TUI (see PluginBoundary in
+  // src/plugin/render.tsx and the demo runs on the PR).
+  await writeFile(sourceA, lifecycleSource(markerA, "test.a", "a2"))
+  expect(await until(readA, (value) => value?.includes("a2:setup") ?? false)).toBe("a1:setup\na1:cleanup\na2:setup\n")
+
+  process.emit("SIGHUP")
+  await app.task
+})
+
+test("editing one plugin leaves others untouched and a broken save keeps the last good version", async () => {
+  await using tmp = await tmpdir()
+  const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
+  await mkdir(directory, { recursive: true })
+  const markerA = path.join(tmp.path, "a.txt")
+  const markerB = path.join(tmp.path, "b.txt")
+  const sourceA = path.join(directory, "a.ts")
+  const sourceB = path.join(directory, "b.ts")
+  await writeFile(sourceA, lifecycleSource(markerA, "test.a", "a1"))
+  await writeFile(sourceB, lifecycleSource(markerB, "test.b", "b1"))
+
+  await using app = await bootApp(tmp.path)
+  const readA = () => readFile(markerA, "utf8")
+  const readB = () => readFile(markerB, "utf8")
+  await until(readA, (value) => value === "a1:setup\n")
+  await until(readB, (value) => value === "b1:setup\n")
+
+  // Editing B restarts only B: A sees no cleanup and no second setup.
+  await writeFile(sourceB, lifecycleSource(markerB, "test.b", "b2"))
+  expect(await until(readB, (value) => value?.includes("b2:setup") ?? false)).toBe("b1:setup\nb1:cleanup\nb2:setup\n")
+  expect(await readA()).toBe("a1:setup\n")
+
+  // A broken save keeps the last good version running: b2 is never cleaned
+  // up. Editing A afterwards provides a positive completion signal — once
+  // A's swap lands, the serialized reconcile has processed the broken save.
+  await writeFile(sourceB, "export default {")
+  await writeFile(sourceA, lifecycleSource(markerA, "test.a", "a2"))
+  expect(await until(readA, (value) => value?.includes("a2:setup") ?? false)).toBe("a1:setup\na1:cleanup\na2:setup\n")
+  expect(await readB()).toBe("b1:setup\nb1:cleanup\nb2:setup\n")
+
+  // Fixing the file replaces the kept version and leaves A alone.
+  await writeFile(sourceB, lifecycleSource(markerB, "test.b", "b3"))
+  expect(await until(readB, (value) => value?.includes("b3:setup") ?? false)).toBe(
+    "b1:setup\nb1:cleanup\nb2:setup\nb2:cleanup\nb3:setup\n",
+  )
+  expect(await readA()).toBe("a1:setup\na1:cleanup\na2:setup\n")
+
+  process.emit("SIGHUP")
+  await app.task
+})
+
+test("a save whose setup throws restores the previous version", async () => {
+  await using tmp = await tmpdir()
+  const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
+  await mkdir(directory, { recursive: true })
+  const marker = path.join(tmp.path, "a.txt")
+  const source = path.join(directory, "a.ts")
+  await writeFile(source, lifecycleSource(marker, "test.a", "a1"))
+
+  await using app = await bootApp(tmp.path)
+  const read = () => readFile(marker, "utf8")
+  expect(await until(read, (value) => value === "a1:setup\n")).toBe("a1:setup\n")
+
+  // The module imports fine but its setup throws — unlike an import failure,
+  // the swap has already torn down a1, so keep-last-good means restoring it.
+  await writeFile(
+    source,
+    `
+export default {
+  id: "test.a",
+  setup: async () => {
+    throw new Error("setup boom")
+  },
+}
+`,
+  )
+  expect(await until(read, (value) => value === "a1:setup\na1:cleanup\na1:setup\n")).toBe(
+    "a1:setup\na1:cleanup\na1:setup\n",
+  )
+
+  // Fixing the file swaps out the restored version normally.
+  await writeFile(source, lifecycleSource(marker, "test.a", "a2"))
+  expect(await until(read, (value) => value?.includes("a2:setup") ?? false)).toBe(
+    "a1:setup\na1:cleanup\na1:setup\na1:cleanup\na2:setup\n",
+  )
+
+  process.emit("SIGHUP")
+  await app.task
+})
+
+test("editing a symlinked plugin's target hot-reloads it", async () => {
+  await using tmp = await tmpdir()
+  const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
+  await mkdir(directory, { recursive: true })
+  const marker = path.join(tmp.path, "a.txt")
+  // The real source lives outside the discovery directory; only a symlink
+  // is discovered. Edits land at the target, which emits no event in the
+  // plugin directory itself.
+  const target = path.join(tmp.path, "elsewhere", "a.ts")
+  await mkdir(path.dirname(target), { recursive: true })
+  await writeFile(target, lifecycleSource(marker, "test.a", "a1"))
+  await symlink(target, path.join(directory, "a.ts"))
+
+  await using app = await bootApp(tmp.path)
+  const read = () => readFile(marker, "utf8")
+  expect(await until(read, (value) => value === "a1:setup\n")).toBe("a1:setup\n")
+
+  await writeFile(target, lifecycleSource(marker, "test.a", "a2"))
+  expect(await until(read, (value) => value?.includes("a2:setup") ?? false)).toBe("a1:setup\na1:cleanup\na2:setup\n")
+
+  process.emit("SIGHUP")
+  await app.task
+})
+
+test("memory storage survives hot reload while disk storage persists", async () => {
+  await using tmp = await tmpdir()
+  const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
+  await mkdir(directory, { recursive: true })
+  const marker = path.join(tmp.path, "counter.txt")
+  const source = path.join(directory, "counter.ts")
+  const counterSource = (note: string) => `
+import { appendFile } from "node:fs/promises"
+// ${note}
+export default {
+  id: "test.counter",
+  setup: async (context: any) => {
+    const [state, update] = context.storage.memory("counter", { initial: { count: 0 } })
+    update((draft: any) => {
+      draft.count += 1
+    })
+    await appendFile(${JSON.stringify(marker)}, "count:" + state.count + "\\n")
+  },
+}
+`
+  await writeFile(source, counterSource("v1"))
+
+  await using app = await bootApp(tmp.path)
+  const read = () => readFile(marker, "utf8")
+  expect(await until(read, (value) => value === "count:1\n")).toBe("count:1\n")
+
+  // The reloaded generation shares the same live store: the count continues.
+  await writeFile(source, counterSource("v2"))
+  expect(await until(read, (value) => value?.includes("count:2") ?? false)).toBe("count:1\ncount:2\n")
+
+  process.emit("SIGHUP")
+  await app.task
+})

+ 30 - 0
packages/tui/test/plugin-reload.test.ts

@@ -0,0 +1,30 @@
+import { writeFile } from "node:fs/promises"
+import path from "node:path"
+import { pathToFileURL } from "node:url"
+import { expect, test } from "bun:test"
+import { freshSpecifier, localSource } from "../src/plugin/discovery"
+import { tmpdir } from "./fixture/fixture"
+
+test("localSource resolves file URLs and local paths but not package specs", () => {
+  const base = process.cwd()
+  const absolute = path.resolve(base, "abs", "plugin.ts")
+  expect(localSource("file:///tmp/plugin.ts", base)?.href).toBe("file:///tmp/plugin.ts")
+  expect(localSource("./plugin.ts", base)?.href).toBe(pathToFileURL(path.join(base, "plugin.ts")).href)
+  expect(localSource("../plugin.ts", path.join(base, "nested"))?.href).toBe(
+    pathToFileURL(path.join(base, "plugin.ts")).href,
+  )
+  expect(localSource(absolute, base)?.href).toBe(pathToFileURL(absolute).href)
+  expect(localSource("some-package", base)).toBeUndefined()
+  expect(localSource("@scope/some-package", base)).toBeUndefined()
+})
+
+test("freshSpecifier re-imports a plugin source after it changes", async () => {
+  await using tmp = await tmpdir()
+  const file = path.join(tmp.path, "plugin.ts")
+  await writeFile(file, "export default 1")
+  const first: { readonly default?: unknown } = await import(freshSpecifier(pathToFileURL(file).href, 1))
+  await writeFile(file, "export default 2")
+  const second: { readonly default?: unknown } = await import(freshSpecifier(pathToFileURL(file).href, 2))
+  expect(first.default).toBe(1)
+  expect(second.default).toBe(2)
+})