diff --git a/packages/plugin/src/tui/context.ts b/packages/plugin/src/tui/context.ts index 5f775167ff..6a77ab0e47 100644 --- a/packages/plugin/src/tui/context.ts +++ b/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( key: string, options: { readonly initial: Value }, ): readonly [Store, (mutation: (draft: Value) => void) => Promise] + /** + * 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( + key: string, + options: { + readonly initial: Value + }, + ): readonly [Store, (mutation: (draft: Value) => void) => void] } interface LocationCollection { @@ -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 } diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 4bd2dd02b7..c373774bae 100644 --- a/packages/tui/src/app.tsx +++ b/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" diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 92f7fab061..fdbff6af76 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/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() diff --git a/packages/tui/src/context/storage.tsx b/packages/tui/src/context/storage.tsx index a3e1d4f2b8..441d32c2d6 100644 --- a/packages/tui/src/context/storage.tsx +++ b/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 = { } type Entry = readonly [Store, (mutation: (draft: Value) => void) => Promise] +type MemoryEntry = readonly [Store, (mutation: (draft: Value) => void) => void] export interface Storage { store( key: string, options: Options, ): readonly [Store, (mutation: (draft: Value) => void) => Promise] + /** + * 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(key: string, options: { readonly initial: Value }): MemoryEntry } function clone(value: Value) { @@ -37,6 +45,7 @@ function segment(value: string) { function createStorage(root: string, channel: string) { const entries = new Map; readonly reload: () => void }>() + const memories = new Map>() 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, reload }) return entry }, + memory(key: string, options: { readonly initial: Value }) { + const existing = memories.get(key) + if (existing) return existing as MemoryEntry + const [store, setStore] = createStore(options.initial) + const entry = [store, (mutation: (draft: Value) => void) => setStore(produce(mutation))] as const + memories.set(key, entry as MemoryEntry) + return entry + }, } const watcher = watch(directory, () => entries.forEach((entry) => entry.reload())) diff --git a/packages/tui/src/plugin/api.tsx b/packages/tui/src/plugin/api.tsx new file mode 100644 index 0000000000..d49cc30d32 --- /dev/null +++ b/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 + +// 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 + id: string + options: Readonly> | 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) => ( + {render()} + ) + 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(resolve: (value: T) => void) { + let settled = false + return (value: T) => { + if (settled) return + settled = true + resolve(value) + } +} + +function createDialogApi(dialog: ReturnType, 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((resolve) => { + const done = settle(resolve) + api.show(() => , done) + }) + }, + confirm(options) { + return new Promise((resolve) => { + const done = settle(resolve) + api.show( + () => ( + done(true)} + onCancel={() => done(false)} + /> + ), + () => done(undefined), + ) + }) + }, + prompt(options) { + return new Promise((resolve) => { + const done = settle(resolve) + api.show( + () => ( + {options.description} : 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( + () => ( + ({ ...option }))} + current={options.current} + onSelect={(option) => { + done(option.value) + api.clear() + }} + /> + ), + () => done(undefined), + ) + }) + }, + } + return api +} diff --git a/packages/tui/src/plugin/context.tsx b/packages/tui/src/plugin/context.tsx index 90e4cd5af1..34dcc3d33f 100644 --- a/packages/tui/src/plugin/context.tsx +++ b/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 } 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 readonly registered: () => ReadonlyArray readonly route: (id: string, name: string) => Page["render"] | undefined - readonly slot: (name: Name) => ReadonlyArray> + readonly slot: (name: Name) => ReadonlyArray<{ readonly id: string; readonly render: Slot }> readonly activate: (id: string) => Promise readonly deactivate: (id: string) => Promise } -type Dispose = () => Promise type Registration = { plugin: Plugin.Definition source: RegisteredPlugin["source"] + target?: string + version: string options?: Readonly> active: boolean routes: Record @@ -79,27 +52,15 @@ type Registration = { cleanups: Dispose[] } +// One entry of the desired plugin generation produced by the resolve phase. +type Desired = Pick & { enabled: boolean } + const PluginContext = createContext() 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(() => {render()}, onClose) + 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), }, - set(options) { - dialog.setSize(options.size ?? "medium") - dialog.setCentered(options.centered ?? false) - }, - clear() { - dialog.clear() - }, - alert(options) { - return new Promise((resolve) => { - let settled = false - const done = () => { - if (settled) return - settled = true - resolve() - } - dialogApi.show(() => , done) - }) - }, - confirm(options) { - return new Promise((resolve) => { - let settled = false - const done = (result: boolean | undefined) => { - if (settled) return - settled = true - resolve(result) - } - dialogApi.show( - () => ( - done(true)} - onCancel={() => done(false)} - /> - ), - () => done(undefined), - ) - }) - }, - prompt(options) { - return new Promise((resolve) => { - let settled = false - const done = (result: string | undefined) => { - if (settled) return - settled = true - resolve(result) - } - dialogApi.show( - () => ( - {options.description} : 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( - () => ( - ({ ...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) => {page.render(input)}, - }) - 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]) => ( - {render(input)} - )) - 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 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 = (task: () => Promise) => { + 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 | 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() + 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() + 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)), - ) - 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" }, - ]) } + + // 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("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() + 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() 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 [] + // 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> | 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(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 ( - - {(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]) - } - - ) -} diff --git a/packages/tui/src/plugin/discovery.ts b/packages/tui/src/plugin/discovery.ts index fb81da1244..b1589c7d22 100644 --- a/packages/tui/src/plugin/discovery.ts +++ b/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}` +} diff --git a/packages/tui/src/plugin/render.tsx b/packages/tui/src/plugin/render.tsx new file mode 100644 index 0000000000..f07a6d0be3 --- /dev/null +++ b/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 ( + { + // 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} + + ) +} + +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. + + {(item) => ( + + {item.render ? createComponent(item.render, { data: item.data }) : props.fallback(item.id, item.name)} + + )} + + ) +} + +export function PluginSlot(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 ( + + {(item) => ( + + { + // 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]) + } + + )} + + ) +} diff --git a/packages/tui/src/plugin/watch.ts b/packages/tui/src/plugin/watch.ts new file mode 100644 index 0000000000..f58b866f5b --- /dev/null +++ b/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>() + const watched = new Map | 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 } +} diff --git a/packages/tui/src/routes/home.tsx b/packages/tui/src/routes/home.tsx index 7b4644fedb..d44e627ee8 100644 --- a/packages/tui/src/routes/home.tsx +++ b/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 diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index dcfd10750d..e93ec3021f 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/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() { + { + await appendFile(${JSON.stringify(marker)}, "${version}:setup\\n") + return () => appendFile(${JSON.stringify(marker)}, "${version}:cleanup\\n") + }, +} +` +} + +async function until(read: () => Promise, 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 +}) diff --git a/packages/tui/test/plugin-reload.test.ts b/packages/tui/test/plugin-reload.test.ts new file mode 100644 index 0000000000..dc9f405354 --- /dev/null +++ b/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) +})