Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton 55b2bb8a3a fix(tui): watch newly created plugin directory 2026-07-31 20:58:53 -04:00
9 changed files with 70 additions and 161 deletions
-5
View File
@@ -77,11 +77,6 @@
"bun": "./src/plugin/runtime-plugin-support.bun.ts",
"node": "./src/plugin/runtime-plugin-support.node.ts",
"default": "./src/plugin/runtime-plugin-support.node.ts"
},
"#plugin-loader": {
"bun": "./src/plugin/loader.bun.ts",
"node": "./src/plugin/loader.node.ts",
"default": "./src/plugin/loader.node.ts"
}
},
"dependencies": {
+2 -4
View File
@@ -7,7 +7,6 @@ import type { Page, Slot, SlotName } from "@opencode-ai/plugin/tui/context"
import { createStore, produce, reconcile as reconcileStore } from "solid-js/store"
import { isDeepEqual } from "remeda"
import "#runtime-plugin-support"
import { preparePlugin } from "#plugin-loader"
import { useConfig } from "../config"
import { useTuiLifecycle } from "../context/runtime"
import { errorMessage } from "../util/error"
@@ -216,7 +215,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
const memo = local ? undefined : npmFailures.get(target)
const resolved = memo
? { status: "failed" as const, error: memo }
: await resolvePlugin(target, local, options, previous, props.packages, host.paths.state).catch((error) => ({
: await resolvePlugin(target, local, options, previous, props.packages).catch((error) => ({
status: "failed" as const,
error: errorMessage(error),
}))
@@ -440,7 +439,6 @@ async function resolvePlugin(
options: Readonly<Record<string, any>> | undefined,
previous: Registration | undefined,
packages: PackageResolver,
state: string,
) {
// Package entrypoints never change within a session, so a loaded previous
// version needs no re-resolution (which could otherwise hit npm).
@@ -453,7 +451,7 @@ async function resolvePlugin(
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(await preparePlugin(entrypoint, version, state))
const mod: { readonly default?: unknown } = await import(version)
if (!isPlugin(mod.default)) throw new Error(`Invalid V2 TUI plugin module: ${spec}`)
return { status: "loaded" as const, plugin: mod.default, version }
}
-44
View File
@@ -1,44 +0,0 @@
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
import { isCoreRuntimeModuleSpecifier, runtimeModuleIdForSpecifier } from "@opentui/core/runtime-plugin"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
const runtime = new Set([
"@opentui/solid",
"@opentui/solid/components",
"@opentui/solid/jsx-runtime",
"@opentui/solid/jsx-dev-runtime",
"solid-js",
"solid-js/store",
])
export async function preparePlugin(entrypoint: string, version: string, state: string) {
const source = fileURLToPath(entrypoint)
if (!source.endsWith(".tsx") && !source.endsWith(".jsx")) return version
const result = await Bun.build({
entrypoints: [source],
target: "bun",
format: "esm",
sourcemap: "inline",
plugins: [
createSolidTransformPlugin({
moduleName: runtimeModuleIdForSpecifier("@opentui/solid"),
resolvePath(specifier) {
if (!runtime.has(specifier) && !isCoreRuntimeModuleSpecifier(specifier)) return null
return runtimeModuleIdForSpecifier(specifier)
},
}),
],
external: [...runtime, "@opentui/core", "@opentui/core/testing"].flatMap((specifier) => [
specifier,
runtimeModuleIdForSpecifier(specifier),
]),
})
if (!result.success) throw new Error(result.logs.join("\n"))
const directory = path.join(state, "tui-plugin-cache")
const output = path.join(directory, `${Bun.hash(version).toString(16)}.mjs`)
await mkdir(directory, { recursive: true })
await Bun.write(output, result.outputs[0]!)
return output
}
-3
View File
@@ -1,3 +0,0 @@
export async function preparePlugin(_entrypoint: string, version: string, _state: string) {
return version
}
+36 -5
View File
@@ -8,23 +8,46 @@ import { lstat, realpath, stat } from "fs/promises"
// 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.
// files do not change the entrypoint mtime and are not detected. A missing
// target temporarily watches its nearest existing parent, filtered to the
// first missing path segment, until the normal source watch can take over.
// Established source watches 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>()
const missing = new Map<string, { dir: string; watcher: ReturnType<typeof watch> }>()
let disposed = false
const forget = (dir: string) => {
watchers.get(dir)?.close()
watchers.delete(dir)
watched.delete(dir)
}
const forgetMissing = (target: string) => {
missing.get(target)?.watcher.close()
missing.delete(target)
}
const armMissing = (target: string) => {
if (disposed) return
const dir = nearestExistingParent(target)
if (!dir) return
if (missing.get(target)?.dir === dir) return
forgetMissing(target)
const name = path.relative(dir, target).split(path.sep)[0]!
const watcher = watch(dir, (_event, filename) => {
if (filename && filename.toString().split(path.sep)[0] !== name) return
forgetMissing(target)
arm(target)
onChange()
})
watcher.on("error", () => forgetMissing(target))
missing.set(target, { dir, watcher })
}
const arm = (target: string) => {
stat(target)
.then((info) => {
if (disposed) return
forgetMissing(target)
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)
@@ -55,7 +78,7 @@ export function createSourceWatcher(onChange: () => void) {
watcher.on("error", () => forget(dir))
watchers.set(dir, watcher)
})
.catch(() => undefined)
.catch(() => armMissing(target))
}
const add = (target: string) => {
arm(target)
@@ -70,6 +93,14 @@ export function createSourceWatcher(onChange: () => void) {
const dispose = () => {
disposed = true
for (const watcher of watchers.values()) watcher.close()
for (const item of missing.values()) item.watcher.close()
}
return { add, dispose }
}
function nearestExistingParent(target: string) {
const dir = path.dirname(target)
if (existsSync(dir)) return dir
if (dir === path.dirname(dir)) return
return nearestExistingParent(dir)
}
@@ -3,10 +3,6 @@ import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "../../../s
export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?: StreamCommit[] } = {}) {
const prompts = new Set<(input: RunPrompt) => void>()
const closes = new Set<() => void>()
let ready!: () => void
const promptReady = new Promise<void>((resolve) => {
ready = resolve
})
const events = input.events ?? []
const commits = input.commits ?? []
const calls: Array<{ type: "event"; value: FooterEvent } | { type: "commit"; value: StreamCommit }> = []
@@ -18,7 +14,6 @@ export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?
},
onPrompt(fn) {
prompts.add(fn)
ready()
return () => prompts.delete(fn)
},
onClose(fn) {
@@ -55,12 +50,9 @@ export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?
events,
commits,
calls,
promptReady,
submit(text: string, mode?: RunPrompt["mode"]) {
if (prompts.size === 0) return false
const prompt: RunPrompt = mode ? { text, parts: [], mode } : { text, parts: [] }
for (const fn of [...prompts]) fn(prompt)
return true
},
}
}
+5 -20
View File
@@ -55,9 +55,6 @@ describe("run interactive runtime", () => {
const api = ui.api
const selected = defer<Awaited<ReturnType<typeof sdk.model.default>>>()
const catalogLoaded = defer<void>()
const defaultModelReloaded = defer<void>()
const modelShown = defer<void>()
const turnStarted = defer<void>()
const model = catalogModel({
id: "resolved",
providerID: "test",
@@ -72,17 +69,7 @@ describe("run interactive runtime", () => {
providers: [catalogProvider("test", "Test Provider")],
models: [model],
})
let defaultModelCalls = 0
const defaultModel = spyOn(sdk.model, "default").mockImplementation(() => {
defaultModelCalls++
if (defaultModelCalls === 2) defaultModelReloaded.resolve()
return selected.promise
})
const emit = api.event.bind(api)
api.event = (event) => {
emit(event)
if (event.type === "model") modelShown.resolve()
}
const defaultModel = spyOn(sdk.model, "default").mockImplementation(() => selected.promise)
const task = runInteractiveDeferredMode(
{
@@ -123,7 +110,6 @@ describe("run interactive runtime", () => {
runPromptTurn: async (input) => {
turnAgent = input.agent
turnModel = input.model
turnStarted.resolve()
api.close()
},
queuePromptTurn: async () => {},
@@ -147,8 +133,8 @@ describe("run interactive runtime", () => {
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
data: model,
})
await defaultModelReloaded.promise
await modelShown.promise
while (defaultModel.mock.calls.length < 2) await Bun.sleep(0)
while (!events.some((event) => event.type === "model")) await Bun.sleep(0)
expect(events).toContainEqual({
type: "model",
model: "Resolved Model · Test Provider",
@@ -156,9 +142,8 @@ describe("run interactive runtime", () => {
})
expect(lifecycle.onCycleVariant?.()).toMatchObject({ status: "variant low", variant: "low" })
lifecycle.onAgentSelect?.("review")
await ui.promptReady
expect(ui.submit("hello")).toBe(true)
await turnStarted.promise
ui.submit("hello")
while (!turnModel) await Bun.sleep(0)
expect(turnAgent).toBe("review")
expect(turnModel).toEqual({ providerID: "test", modelID: "resolved" })
await task
@@ -53,6 +53,7 @@ async function bootApp(directory: string) {
)
return {
task,
renderer: setup,
async [Symbol.asyncDispose]() {
process.chdir(cwd)
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
@@ -81,6 +82,32 @@ test("editing a discovered TUI plugin hot-reloads its fresh module", async () =>
await app.task
})
test("creating the TUI plugin directory after startup discovers its first plugin", async () => {
await using tmp = await tmpdir()
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
await mkdir(path.dirname(directory), { recursive: true })
const marker = path.join(tmp.path, "marker.txt")
const placeholders = ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"]
await using app = await bootApp(tmp.path)
const frame = await until(
async () => {
await app.renderer.renderOnce()
return app.renderer.captureCharFrame()
},
(value) => placeholders.some((text) => value?.includes(text)),
)
expect(placeholders.some((text) => frame?.includes(text))).toBe(true)
await mkdir(directory)
await writeFile(path.join(directory, "hot.ts"), lifecycleSource(marker, "test.hot", "v1"))
const read = () => readFile(marker, "utf8")
expect(await until(read, (value) => value === "v1:setup\n")).toBe("v1: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")
@@ -1,72 +0,0 @@
import { expect, test } from "bun:test"
import { createComponent, createSignal, type JSX } from "solid-js"
import { testRender } from "@opentui/solid"
import { mkdir, writeFile } from "node:fs/promises"
import path from "node:path"
import { preparePlugin } from "../src/plugin/loader.bun"
import "../src/plugin/runtime-plugin-support.bun"
import { tmpdir } from "./fixture/fixture"
test("an external TSX plugin uses the host Solid runtime", async () => {
await using tmp = await tmpdir()
const root = path.join(tmp.path, ".opencode")
const source = path.join(root, "plugins", "tui", "reactive.tsx")
const helper = path.join(root, "plugins", "tui", "signal.ts")
const localSolid = path.join(root, "node_modules", "solid-js")
const localOpenTui = path.join(root, "node_modules", "@opentui", "solid")
await Promise.all([
mkdir(path.dirname(source), { recursive: true }),
mkdir(localSolid, { recursive: true }),
mkdir(localOpenTui, { recursive: true }),
])
await Promise.all([
writeFile(
path.join(localSolid, "package.json"),
JSON.stringify({ name: "solid-js", type: "module", main: "index.js" }),
),
writeFile(
path.join(localSolid, "index.js"),
"export const createSignal = () => { throw new Error('local Solid used') }\n",
),
writeFile(
path.join(localOpenTui, "package.json"),
JSON.stringify({ name: "@opentui/solid", type: "module", main: "index.js" }),
),
writeFile(path.join(localOpenTui, "index.js"), "throw new Error('local OpenTUI used')\n"),
writeFile(helper, 'export { createSignal, onCleanup } from "solid-js"\n'),
writeFile(
source,
`
import { createSignal, onCleanup } from "./signal"
export const signal = createSignal
export default {
id: "test.reactive",
setup(context: any) {
context.ui.slot("home.footer", () => {
const [count, setCount] = createSignal(0)
const timer = setTimeout(() => setCount(1), 10)
onCleanup(() => clearTimeout(timer))
return <box><text>count:{count()}</text></box>
})
},
}
`,
),
])
const plugin = await import(await preparePlugin(new URL(`file://${source}`).href, `${source}?mtime=1`, tmp.path))
expect(plugin.signal).toBe(createSignal)
let slot: ((input: object) => JSX.Element) | undefined
await plugin.default.setup({ ui: { slot: (_name: string, render: typeof slot) => (slot = render) } })
if (!slot) throw new Error("Plugin did not register its slot")
const setup = await testRender(() => createComponent(slot!, {}), { width: 20, height: 2 })
try {
expect(await setup.waitForFrame((frame) => frame.includes("count:0"))).toContain("count:0")
expect(await setup.waitForFrame((frame) => frame.includes("count:1"))).toContain("count:1")
} finally {
setup.renderer.destroy()
}
})