mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-02 16:26:14 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 55b2bb8a3a |
@@ -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": {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export async function preparePlugin(_entrypoint: string, version: string, _state: string) {
|
||||
return version
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user