feat(tui): hot-reload local TUI plugins (#39776)
This commit is contained in:
parent
06e26d89ad
commit
102086c50f
14 changed files with 1067 additions and 391 deletions
|
|
@ -26,12 +26,27 @@ import type { JSX } from "@opentui/solid"
|
||||||
import type { Store } from "solid-js/store"
|
import type { Store } from "solid-js/store"
|
||||||
|
|
||||||
export interface Storage {
|
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>(
|
store<Value extends object>(
|
||||||
key: string,
|
key: string,
|
||||||
options: {
|
options: {
|
||||||
readonly initial: Value
|
readonly initial: Value
|
||||||
},
|
},
|
||||||
): readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
|
): 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> {
|
interface LocationCollection<Value> {
|
||||||
|
|
@ -136,6 +151,9 @@ export interface SlotMap {
|
||||||
readonly sessionID?: string
|
readonly sessionID?: string
|
||||||
readonly mode: "normal" | "shell"
|
readonly mode: "normal" | "shell"
|
||||||
}
|
}
|
||||||
|
readonly "session.composer.top": {
|
||||||
|
readonly sessionID: string
|
||||||
|
}
|
||||||
readonly "sidebar.content": {
|
readonly "sidebar.content": {
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,8 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
|
||||||
import open from "open"
|
import open from "open"
|
||||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||||
import { Config, ConfigProvider, useConfig } from "./config"
|
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 { CommandPaletteDialog } from "./component/command-palette"
|
||||||
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
|
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ import { useData } from "../../context/data"
|
||||||
import { useLocation } from "../../context/location"
|
import { useLocation } from "../../context/location"
|
||||||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||||
import { abbreviateHome } from "../../runtime"
|
import { abbreviateHome } from "../../runtime"
|
||||||
import { PluginSlot } from "../../plugin/context"
|
import { PluginSlot } from "../../plugin/render"
|
||||||
|
|
||||||
registerOpencodeSpinner()
|
registerOpencodeSpinner()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { batch, createContext, onCleanup, useContext, type ParentProps } from "solid-js"
|
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 path from "path"
|
||||||
import { mkdirSync, readFileSync, watch } from "fs"
|
import { mkdirSync, readFileSync, watch } from "fs"
|
||||||
import { Flock } from "@opencode-ai/util/flock"
|
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 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 {
|
export interface Storage {
|
||||||
store<Value extends object>(
|
store<Value extends object>(
|
||||||
key: string,
|
key: string,
|
||||||
options: Options<Value>,
|
options: Options<Value>,
|
||||||
): readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
|
): 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) {
|
function clone<Value extends object>(value: Value) {
|
||||||
|
|
@ -37,6 +45,7 @@ function segment(value: string) {
|
||||||
|
|
||||||
function createStorage(root: string, channel: string) {
|
function createStorage(root: string, channel: string) {
|
||||||
const entries = new Map<string, { readonly value: Entry<object>; readonly reload: () => void }>()
|
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 directory = path.join(root, segment(channel), "tui")
|
||||||
const locks = path.join(root, segment(channel), "locks")
|
const locks = path.join(root, segment(channel), "locks")
|
||||||
mkdirSync(directory, { recursive: true })
|
mkdirSync(directory, { recursive: true })
|
||||||
|
|
@ -73,6 +82,14 @@ function createStorage(root: string, channel: string) {
|
||||||
entries.set(file, { value: entry as Entry<object>, reload })
|
entries.set(file, { value: entry as Entry<object>, reload })
|
||||||
return entry
|
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()))
|
const watcher = watch(directory, () => entries.forEach((entry) => entry.reload()))
|
||||||
|
|
|
||||||
274
packages/tui/src/plugin/api.tsx
Normal file
274
packages/tui/src/plugin/api.tsx
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -1,53 +1,25 @@
|
||||||
import { PluginContextProvider, type Plugin } from "@opencode-ai/plugin/tui"
|
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||||
import {
|
import { batch, createContext, createEffect, on, onCleanup, onMount, useContext, type ParentProps } from "solid-js"
|
||||||
batch,
|
|
||||||
createComponent,
|
|
||||||
createContext,
|
|
||||||
createEffect,
|
|
||||||
createMemo,
|
|
||||||
For,
|
|
||||||
mergeProps,
|
|
||||||
on,
|
|
||||||
onCleanup,
|
|
||||||
onMount,
|
|
||||||
useContext,
|
|
||||||
type JSX,
|
|
||||||
type ParentProps,
|
|
||||||
} from "solid-js"
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { stat } from "fs/promises"
|
import { stat } from "fs/promises"
|
||||||
import { fileURLToPath, pathToFileURL } from "url"
|
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 { createStore, produce, reconcile as reconcileStore } from "solid-js/store"
|
||||||
import { useRenderer } from "@opentui/solid"
|
import { isDeepEqual } from "remeda"
|
||||||
import "#runtime-plugin-support"
|
import "#runtime-plugin-support"
|
||||||
import { useConfig } from "../config"
|
import { useConfig } from "../config"
|
||||||
import { useClient } from "../context/client"
|
import { useTuiLifecycle } from "../context/runtime"
|
||||||
import { useData } from "../context/data"
|
import { errorMessage } from "../util/error"
|
||||||
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 { builtins } from "./builtins"
|
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 {
|
export interface PackageResolver {
|
||||||
readonly resolve: (spec: string) => Promise<string | undefined>
|
readonly resolve: (spec: string) => Promise<string | undefined>
|
||||||
}
|
}
|
||||||
|
|
||||||
type State =
|
type State =
|
||||||
| { readonly target: string; readonly status: "loading" }
|
|
||||||
| { readonly target: string; readonly id: string; readonly status: "active" | "inactive" }
|
| { readonly target: string; readonly id: string; readonly status: "active" | "inactive" }
|
||||||
| { readonly target: string; readonly status: "unsupported" }
|
| { readonly target: string; readonly status: "unsupported" }
|
||||||
| { readonly target: string; readonly status: "failed"; readonly error: string }
|
| { readonly target: string; readonly status: "failed"; readonly error: string }
|
||||||
|
|
@ -63,15 +35,16 @@ type Value = {
|
||||||
readonly list: () => ReadonlyArray<State>
|
readonly list: () => ReadonlyArray<State>
|
||||||
readonly registered: () => ReadonlyArray<RegisteredPlugin>
|
readonly registered: () => ReadonlyArray<RegisteredPlugin>
|
||||||
readonly route: (id: string, name: string) => Page["render"] | undefined
|
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 activate: (id: string) => Promise<boolean>
|
||||||
readonly deactivate: (id: string) => Promise<boolean>
|
readonly deactivate: (id: string) => Promise<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
type Dispose = () => Promise<void>
|
|
||||||
type Registration = {
|
type Registration = {
|
||||||
plugin: Plugin.Definition
|
plugin: Plugin.Definition
|
||||||
source: RegisteredPlugin["source"]
|
source: RegisteredPlugin["source"]
|
||||||
|
target?: string
|
||||||
|
version: string
|
||||||
options?: Readonly<Record<string, any>>
|
options?: Readonly<Record<string, any>>
|
||||||
active: boolean
|
active: boolean
|
||||||
routes: Record<string, Page>
|
routes: Record<string, Page>
|
||||||
|
|
@ -79,27 +52,15 @@ type Registration = {
|
||||||
cleanups: Dispose[]
|
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>()
|
const PluginContext = createContext<Value>()
|
||||||
|
|
||||||
export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>) {
|
export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>) {
|
||||||
const renderer = useRenderer()
|
const host = usePluginHost()
|
||||||
const client = useClient()
|
|
||||||
const data = useData()
|
|
||||||
const route = useRoute()
|
|
||||||
const config = useConfig()
|
const config = useConfig()
|
||||||
const keymap = Keymap.use()
|
|
||||||
const shortcuts = Keymap.useShortcuts()
|
|
||||||
const keymapState = Keymap.useState()
|
|
||||||
const lifecycle = useTuiLifecycle()
|
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 directory = config.path ? path.dirname(config.path) : process.cwd()
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
ready: false,
|
ready: false,
|
||||||
|
|
@ -117,223 +78,26 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||||
setStore("registrations", id, "cleanups", [])
|
setStore("registrations", id, "cleanups", [])
|
||||||
})
|
})
|
||||||
const owned: Dispose[] = []
|
const owned: Dispose[] = []
|
||||||
let context: Context
|
const context = createPluginContext({
|
||||||
const dialogApi: Dialog = {
|
host,
|
||||||
show(render, onClose) {
|
id,
|
||||||
dialog.replace(() => <PluginContextProvider value={context}>{render()}</PluginContextProvider>, onClose)
|
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<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 cleanup = await setup(item.plugin, context, owned).catch((error) => {
|
const cleanup = await setup(item.plugin, context, owned).catch((error) => {
|
||||||
setStore("registrations", id, "routes", reconcileStore({}))
|
setStore("registrations", id, "routes", reconcileStore({}))
|
||||||
setStore("registrations", id, "slots", reconcileStore({}))
|
setStore("registrations", id, "slots", reconcileStore({}))
|
||||||
|
|
@ -376,94 +140,219 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const reconcile = async (configured = config.data.plugins ?? []) => {
|
// Cleanup failures must not stop a swap or teardown, but they should not
|
||||||
await Promise.all(
|
// vanish either: the old generation may still own listeners or intervals.
|
||||||
Object.entries(store.registrations)
|
const deactivateNoisily = (id: string) =>
|
||||||
.filter(([, registration]) => registration.active)
|
deactivate(id).catch((error) =>
|
||||||
.map(([id]) => deactivate(id)),
|
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) {
|
// Every lifecycle mutation — reconciles, manual dialog toggles, shutdown —
|
||||||
setStore("registrations", plugin.id, {
|
// is serialized through one chain so generations can never interleave.
|
||||||
plugin,
|
let loading = Promise.resolve()
|
||||||
source: "builtin",
|
const enqueue = <T,>(task: () => Promise<T>) => {
|
||||||
active: false,
|
const result = loading.catch(() => undefined).then(task)
|
||||||
routes: {},
|
loading = result.then(
|
||||||
slots: {},
|
() => undefined,
|
||||||
cleanups: [],
|
() => undefined,
|
||||||
})
|
)
|
||||||
await activate(plugin.id)
|
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) {
|
for (const entry of entries) {
|
||||||
const target = typeof entry === "string" ? entry : entry.package
|
const target = typeof entry === "string" ? entry : entry.package
|
||||||
if (target.startsWith("-")) {
|
if (target.startsWith("-")) {
|
||||||
for (const id of Object.keys(store.registrations).filter((id) => matches(target.slice(1), id)))
|
for (const item of desired.values()) if (matches(target.slice(1), item.plugin.id)) item.enabled = false
|
||||||
await deactivate(id)
|
|
||||||
continue
|
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.")) {
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = typeof entry === "string" ? undefined : entry.options
|
const options = typeof entry === "string" ? undefined : entry.options
|
||||||
setStore("states", (items) => [...items, { target, status: "loading" }])
|
// Watch even when the resolve below fails so fixing a broken plugin reloads it.
|
||||||
const plugin = await loadPlugin(target, directory, props.packages).catch((error) => {
|
const local = localSource(target, directory)
|
||||||
setStore("states", (items) =>
|
if (local) watcher.add(fileURLToPath(local))
|
||||||
items.map((state) =>
|
const previous = Object.values(store.registrations).find((registration) => registration.target === target)
|
||||||
state.target === target
|
const memo = local ? undefined : npmFailures.get(target)
|
||||||
? { target, status: "failed", error: error instanceof Error ? error.message : String(error) }
|
const resolved = memo
|
||||||
: state,
|
? { status: "failed" as const, error: memo }
|
||||||
),
|
: await resolvePlugin(target, local, options, previous, props.packages).catch((error) => ({
|
||||||
)
|
status: "failed" as const,
|
||||||
return undefined
|
error: errorMessage(error),
|
||||||
})
|
}))
|
||||||
if (!plugin) {
|
if (resolved.status === "unsupported") {
|
||||||
setStore("states", (items) =>
|
failures.push({ target, status: "unsupported" })
|
||||||
items.map((state) =>
|
|
||||||
state.target === target && state.status !== "failed" ? { target, status: "unsupported" } : state,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if (resolved.status === "failed") {
|
||||||
setStore("registrations", plugin.id, {
|
if (!local && !previous) npmFailures.set(target, resolved.error)
|
||||||
plugin,
|
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",
|
source: "external",
|
||||||
|
target,
|
||||||
|
version: resolved.version,
|
||||||
options,
|
options,
|
||||||
active: false,
|
enabled: true,
|
||||||
routes: {},
|
|
||||||
slots: {},
|
|
||||||
cleanups: [],
|
|
||||||
})
|
})
|
||||||
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<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(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => JSON.stringify(config.data.plugins ?? []),
|
() => JSON.stringify(config.data.plugins ?? []),
|
||||||
() => {
|
() => {
|
||||||
const configured = config.data.plugins ?? []
|
npmFailures.clear()
|
||||||
loading = loading.catch(() => undefined).then(() => reconcile(configured))
|
void enqueue(reconcile).then(
|
||||||
void loading.then(
|
|
||||||
() => setStore("ready", true),
|
() => setStore("ready", true),
|
||||||
() => setStore("ready", true),
|
() => setStore("ready", true),
|
||||||
)
|
)
|
||||||
|
|
@ -480,7 +369,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||||
Promise.all(
|
Promise.all(
|
||||||
Object.entries(store.registrations)
|
Object.entries(store.registrations)
|
||||||
.filter(([, registration]) => registration.active)
|
.filter(([, registration]) => registration.active)
|
||||||
.map(([id]) => deactivate(id)),
|
.map(([id]) => deactivate(id).catch(() => undefined)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.then(() => setStore("registrations", reconcileStore({})))
|
.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 })),
|
Object.entries(store.registrations).map(([id, plugin]) => ({ id, source: plugin.source, active: plugin.active })),
|
||||||
route: (id, name) => store.registrations[id]?.routes[name]?.render,
|
route: (id, name) => store.registrations[id]?.routes[name]?.render,
|
||||||
slot: (name) =>
|
slot: (name) =>
|
||||||
Object.values(store.registrations).flatMap((registration) =>
|
Object.entries(store.registrations).flatMap(([id, registration]) => {
|
||||||
registration.active && registration.slots[name] ? [registration.slots[name]] : [],
|
const render = registration.active ? registration.slots[name] : undefined
|
||||||
),
|
if (!render) return []
|
||||||
activate,
|
// <For> diffs rows by reference; a stable wrapper per render
|
||||||
deactivate,
|
// 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}
|
{props.children}
|
||||||
|
|
@ -533,17 +433,45 @@ function matches(selector: string, id: string) {
|
||||||
return selector === "*" || selector === id || (selector.endsWith(".*") && id.startsWith(selector.slice(0, -1)))
|
return selector === "*" || selector === id || (selector.endsWith(".*") && id.startsWith(selector.slice(0, -1)))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadPlugin(spec: string, directory: string, packages: PackageResolver) {
|
async function resolvePlugin(
|
||||||
const local = spec.startsWith("file://")
|
spec: string,
|
||||||
? new URL(spec)
|
local: URL | undefined,
|
||||||
: spec.startsWith("./") || spec.startsWith("../") || path.isAbsolute(spec)
|
options: Readonly<Record<string, any>> | undefined,
|
||||||
? pathToFileURL(path.resolve(directory, spec))
|
previous: Registration | undefined,
|
||||||
: 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)
|
const entrypoint = local ? await resolveLocal(local) : await packages.resolve(spec)
|
||||||
if (!entrypoint) return
|
if (!entrypoint) return { status: "unsupported" as const }
|
||||||
const mod: { readonly default?: unknown } = await import(entrypoint)
|
// 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}`)
|
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) {
|
async function resolveLocal(url: URL) {
|
||||||
|
|
@ -578,40 +506,3 @@ export function usePlugin() {
|
||||||
if (!value) throw new Error("PluginProvider is missing")
|
if (!value) throw new Error("PluginProvider is missing")
|
||||||
return value
|
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
import { readdir } from "node:fs/promises"
|
import { readdir } from "node:fs/promises"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
|
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||||
|
|
||||||
const extensions = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"])
|
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) {
|
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) => {
|
const entries = await readdir(directory, { withFileTypes: true }).catch((error: unknown) => {
|
||||||
if (error && typeof error === "object" && Reflect.get(error, "code") === "ENOENT") return []
|
if (error && typeof error === "object" && Reflect.get(error, "code") === "ENOENT") return []
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
|
|
@ -14,3 +19,19 @@ export async function discoverTuiPlugins(cwd: string) {
|
||||||
.map((entry) => path.join(directory, entry.name))
|
.map((entry) => path.join(directory, entry.name))
|
||||||
.sort()
|
.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
packages/tui/src/plugin/render.tsx
Normal file
84
packages/tui/src/plugin/render.tsx
Normal file
|
|
@ -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
packages/tui/src/plugin/watch.ts
Normal file
75
packages/tui/src/plugin/watch.ts
Normal file
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,7 @@ import { useEditorContext } from "../context/editor"
|
||||||
import { useData } from "../context/data"
|
import { useData } from "../context/data"
|
||||||
import { useLocation } from "../context/location"
|
import { useLocation } from "../context/location"
|
||||||
import { FormPrompt } from "./session/form"
|
import { FormPrompt } from "./session/form"
|
||||||
import { PluginSlot } from "../plugin/context"
|
import { PluginSlot } from "../plugin/render"
|
||||||
import { useTerminalDimensions } from "@opentui/solid"
|
import { useTerminalDimensions } from "@opentui/solid"
|
||||||
|
|
||||||
let once = false
|
let once = false
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ import { collapseToolOutput } from "../../util/collapse-tool-output"
|
||||||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||||
import { usePathFormatter } from "../../context/path-format"
|
import { usePathFormatter } from "../../context/path-format"
|
||||||
import { useLocation } from "../../context/location"
|
import { useLocation } from "../../context/location"
|
||||||
|
import { PluginSlot } from "../../plugin/render"
|
||||||
import {
|
import {
|
||||||
cacheReuseDrop,
|
cacheReuseDrop,
|
||||||
createSessionRows,
|
createSessionRows,
|
||||||
|
|
@ -1011,6 +1012,7 @@ export function Session() {
|
||||||
</Show>
|
</Show>
|
||||||
</scrollbox>
|
</scrollbox>
|
||||||
<box flexShrink={0}>
|
<box flexShrink={0}>
|
||||||
|
<PluginSlot name="session.composer.top" input={{ sessionID: route.sessionID }} mode="all" />
|
||||||
<Composer
|
<Composer
|
||||||
sessionID={route.sessionID}
|
sessionID={route.sessionID}
|
||||||
open={composer.open || (!!session()?.parentID && forms().length === 0)}
|
open={composer.open || (!!session()?.parentID && forms().length === 0)}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useData } from "../../context/data"
|
||||||
import { createMemo, Show } from "solid-js"
|
import { createMemo, Show } from "solid-js"
|
||||||
import { useTheme } from "../../context/theme"
|
import { useTheme } from "../../context/theme"
|
||||||
import { useConfig } from "../../config"
|
import { useConfig } from "../../config"
|
||||||
import { PluginSlot } from "../../plugin/context"
|
import { PluginSlot } from "../../plugin/render"
|
||||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||||
|
|
||||||
import { getScrollAcceleration } from "../../util/scroll"
|
import { getScrollAcceleration } from "../../util/scroll"
|
||||||
|
|
|
||||||
263
packages/tui/test/plugin-hot-reload.test.tsx
Normal file
263
packages/tui/test/plugin-hot-reload.test.tsx
Normal file
|
|
@ -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
packages/tui/test/plugin-reload.test.ts
Normal file
30
packages/tui/test/plugin-reload.test.ts
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue