fix(tui): discover plugins across config roots (#39988)

This commit is contained in:
Kit Langton 2026-08-01 14:06:58 -04:00 committed by GitHub
parent f158abd694
commit c391983ae0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 254 additions and 64 deletions

View file

@ -84,6 +84,7 @@ import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { Config, ConfigProvider, useConfig } from "./config"
import { PluginProvider, usePlugin, type PackageResolver } from "./plugin/context"
import { tuiPluginDirectories } from "./plugin/discovery"
import { PluginRoute, PluginSlot } from "./plugin/render"
import { CommandPaletteDialog } from "./component/command-palette"
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
@ -209,9 +210,13 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
})
const options = { baseUrl: input.server.endpoint.url, headers: Service.headers(input.server.endpoint) }
const api = OpenCode.make(options)
const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
Effect.map((response) => response.location.directory),
Effect.catch(() => Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory))),
const location = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
Effect.map((response) => response.location),
Effect.catch(() => Effect.tryPromise(() => api.location.get())),
)
const directory = location.directory
const pluginDirectories = yield* Effect.promise(() =>
tuiPluginDirectories(process.cwd(), global.config),
)
const handoff = input.terminalHandoff ? yield* Effect.promise(input.terminalHandoff) : undefined
const managed = input.server.service
@ -379,7 +384,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<PromptRefProvider>
<EditorContextProvider>
<AttentionProvider>
<PluginProvider packages={input.packages}>
<PluginProvider
packages={input.packages}
directories={pluginDirectories}
>
<App
pair={
input.server.endpoint.auth

View file

@ -22,7 +22,7 @@ import {
type ThemeDocumentSource,
} from "../theme"
import { generateSystem, terminalMode } from "../theme/system"
import { discoverThemes, themeDirectories } from "../theme/discovery"
import { discoverThemes } from "../theme/discovery"
import { createComponentTheme, type ComponentTheme } from "../theme/component"
import { createEffect, createMemo, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
import { createStore, produce } from "solid-js/store"
@ -30,6 +30,7 @@ import { createSimpleContext } from "./helper"
import { useConfig } from "../config"
import { Global } from "@opencode-ai/util/global"
import { DevTools } from "../devtools"
import { configDirectories } from "../util/config-directories"
const themePerformance = DevTools.register({ id: "theme-performance", title: "Theme performance" })
export type ThemeError = { name: string; error: Error }
@ -70,7 +71,7 @@ export type ThemeSource = Readonly<{
const themeSource: ThemeSource = {
async discover() {
return discoverThemes(themeDirectories(Global.Path.config, process.cwd()))
return discoverThemes(configDirectories(Global.Path.config, process.cwd()))
},
subscribeRefresh(refresh) {
process.on("SIGUSR2", refresh)

View file

@ -13,7 +13,7 @@ import { errorMessage } from "../util/error"
import { builtins } from "./builtins"
import { createPluginContext, usePluginHost, type Dispose } from "./api"
import { createSourceWatcher } from "./watch"
import { discoverTuiPlugins, freshSpecifier, localSource, tuiPluginDirectory } from "./discovery"
import { discoverTuiPlugins, freshSpecifier, localSource } from "./discovery"
export interface PackageResolver {
readonly resolve: (spec: string) => Promise<string | undefined>
@ -57,7 +57,7 @@ type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "
const PluginContext = createContext<Value>()
export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>) {
export function PluginProvider(props: ParentProps<{ packages: PackageResolver; directories: string[] }>) {
const host = usePluginHost()
const config = useConfig()
const lifecycle = useTuiLifecycle()
@ -171,10 +171,11 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
void enqueue(reconcile).catch(() => undefined)
}, 100)
})
onCleanup(() => {
const stopWatching = () => {
clearTimeout(pending)
watcher.dispose()
})
}
onCleanup(stopWatching)
// Rebuild the plugin generation as resolve → compare → swap, mirroring the
// core plugin registry: fold the ordered entries into a desired end state
@ -186,8 +187,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
// 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))
await Promise.all(props.directories.map(watcher.wait))
const entries = [...(await discoverTuiPlugins(props.directories)), ...(config.data.plugins ?? [])]
// Resolve: fold entries into one desired generation. A source that fails
// to import keeps its running previous version and only reports failure.
@ -210,7 +211,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
const options = typeof entry === "string" ? undefined : entry.options
// Watch even when the resolve below fails so fixing a broken plugin reloads it.
const local = localSource(target, directory)
if (local) watcher.add(fileURLToPath(local))
if (local) await watcher.add(fileURLToPath(local))
const previous = Object.values(store.registrations).find((registration) => registration.target === target)
const memo = local ? undefined : npmFailures.get(target)
const resolved = memo
@ -363,6 +364,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
let disposing: Promise<void> | undefined
const dispose = () => {
if (disposing) return disposing
stopWatching()
disposing = loading
.catch(() => undefined)
.then(() =>

View file

@ -1,23 +1,47 @@
import { readdir } from "node:fs/promises"
import { readdir, stat } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
import {
isMissingPath,
localProjectDirectory,
projectConfigDirectories,
} from "../util/config-directories"
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 tuiPluginDirectories(cwd: string, configDirectory: string) {
const projectDirectory = await localProjectDirectory(cwd)
const projectConfig = path.join(projectDirectory, ".opencode")
const directories = [configDirectory, ...projectConfigDirectories(projectDirectory, cwd)]
const exists = await Promise.all(
directories.map((directory) => {
if (directory === configDirectory || directory === projectConfig) return true
return stat(directory).then(
(info) => info.isDirectory(),
(error) => (isMissingPath(error) ? false : Promise.reject(error)),
)
}),
)
return directories
.filter((_, index) => exists[index])
.map((directory) => path.join(directory, "plugins", "tui"))
}
export async function discoverTuiPlugins(cwd: string) {
const directory = tuiPluginDirectory(cwd)
const entries = await readdir(directory, { withFileTypes: true }).catch((error: unknown) => {
if (error && typeof error === "object" && Reflect.get(error, "code") === "ENOENT") return []
return Promise.reject(error)
})
return entries
.filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && extensions.has(path.extname(entry.name)))
.map((entry) => path.join(directory, entry.name))
.sort()
export async function discoverTuiPlugins(directories: string[]) {
return (
await Promise.all(
directories.map(async (directory) => {
const entries = await readdir(directory, { withFileTypes: true }).catch((error: unknown) => {
if (isMissingPath(error)) return []
return Promise.reject(error)
})
return entries
.filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && extensions.has(path.extname(entry.name)))
.map((entry) => path.join(directory, entry.name))
.sort()
}),
)
).flat()
}
export function localSource(spec: string, directory: string) {

View file

@ -10,21 +10,30 @@ import { lstat, realpath, stat } from "fs/promises"
// 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.
// spurious onChange); all die with dispose(). Missing retryable targets are
// polled until they can be armed without relying on a racy chain of ancestor
// watches.
export function createSourceWatcher(onChange: () => void) {
const watchers = new Map<string, ReturnType<typeof watch>>()
const watched = new Map<string, Set<string> | null>()
const missing = new Set<string>()
const arming = new Map<string, Promise<void>>()
let disposed = false
const notify = () => {
if (!disposed) onChange()
}
const forget = (dir: string) => {
watchers.get(dir)?.close()
watchers.delete(dir)
watched.delete(dir)
}
const arm = (target: string) => {
stat(target)
const arm = (target: string, retry: boolean) => {
const active = arming.get(target)
if (active) return active
const result = stat(target)
.then((info) => {
if (disposed) return
const appeared = missing.delete(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)
@ -32,44 +41,69 @@ export function createSourceWatcher(onChange: () => void) {
if (existing !== undefined) {
if (name === null) watched.set(dir, null)
else existing?.add(name)
if (appeared) notify()
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()
notify()
return
}
// A null filename (platform-dependent) always schedules.
const accept = watched.get(dir)
if (filename && accept && !accept.has(filename.toString())) return
onChange()
notify()
})
watched.set(dir, name === null ? null : new Set([name]))
// Reconcile after watcher errors so every source is re-added and any
// temporarily unavailable target moves into the polling set.
watcher.on("error", () => {
forget(dir)
notify()
})
// 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)
if (appeared) notify()
})
.catch(() => undefined)
.catch((error: unknown) => {
if (!disposed && retry && isMissing(error)) missing.add(target)
})
.finally(() => arming.delete(target))
arming.set(target, result)
return result
}
const add = (target: string) => {
arm(target)
const add = async (target: string, retry: boolean) => {
await arm(target, retry)
// A symlinked source receives edits at its resolved target.
lstat(target)
await lstat(target)
.then((info) => {
if (!info.isSymbolicLink()) return
return realpath(target).then(arm)
return realpath(target).then((target) => arm(target, retry))
})
.catch(() => undefined)
}
const dispose = () => {
disposed = true
clearInterval(poll)
for (const watcher of watchers.values()) watcher.close()
watchers.clear()
watched.clear()
missing.clear()
}
const poll = setInterval(() => missing.forEach((target) => arm(target, true)), 500)
poll.unref()
return {
add: (target: string) => add(target, false),
wait: (target: string) => add(target, true),
dispose,
}
return { add, dispose }
}
function isMissing(error: unknown) {
if (!error || typeof error !== "object") return false
const code = Reflect.get(error, "code")
return code === "ENOENT" || code === "ENOTDIR"
}

View file

@ -1,15 +1,6 @@
import { readdir, readFile } from "node:fs/promises"
import path from "node:path"
export function themeDirectories(config: string, cwd: string) {
const directories: string[] = []
for (let current = cwd; ; current = path.dirname(current)) {
directories.push(path.join(current, ".opencode"))
if (path.dirname(current) === current) break
}
return [config, ...directories.reverse()]
}
export async function discoverThemes(directories: string[]) {
const result: Record<string, unknown> = {}
for (const directory of directories) {

View file

@ -0,0 +1,45 @@
import path from "node:path"
import { stat } from "node:fs/promises"
export function configDirectories(config: string, cwd: string) {
return [...new Set([config, ...ancestors(cwd).map((directory) => path.join(directory, ".opencode"))])]
}
export function projectConfigDirectories(project: string, cwd: string) {
const directories = ancestors(cwd)
return directories
.slice(directories.indexOf(path.resolve(project)))
.map((directory) => path.join(directory, ".opencode"))
}
export async function localProjectDirectory(cwd: string) {
const directories = ancestors(cwd)
const repositories = await Promise.all(
directories.map((directory) =>
Promise.all(
[".git", ".hg"].map((name) =>
stat(path.join(directory, name)).then(
() => true,
(error) => (isMissingPath(error) ? false : Promise.reject(error)),
),
),
).then((matches) => matches.some(Boolean)),
),
)
return directories.findLast((_, index) => repositories[index]) ?? path.resolve(cwd)
}
export function isMissingPath(error: unknown) {
if (!error || typeof error !== "object") return false
const code = Reflect.get(error, "code")
return code === "ENOENT" || code === "ENOTDIR"
}
function ancestors(cwd: string) {
const directories: string[] = []
for (let current = path.resolve(cwd); ; current = path.dirname(current)) {
directories.push(current)
if (path.dirname(current) === current) break
}
return directories.reverse()
}

View file

@ -1,7 +1,8 @@
import { mkdir, writeFile } from "node:fs/promises"
import path from "node:path"
import { expect, test } from "bun:test"
import { discoverTuiPlugins } from "../src/plugin/discovery"
import { discoverTuiPlugins, tuiPluginDirectories } from "../src/plugin/discovery"
import { localProjectDirectory } from "../src/util/config-directories"
import { tmpdir } from "./fixture/fixture"
test("discovers project TUI plugin files in stable order", async () => {
@ -15,13 +16,57 @@ test("discovers project TUI plugin files in stable order", async () => {
writeFile(path.join(directory, "nested", "ignored.ts"), "export default {}"),
])
expect(await discoverTuiPlugins(tmp.path)).toEqual([
path.join(directory, "first.js"),
path.join(directory, "second.tsx"),
])
expect(
await discoverTuiPlugins(await tuiPluginDirectories(tmp.path, path.join(tmp.path, "config"))),
).toEqual([path.join(directory, "first.js"), path.join(directory, "second.tsx")])
})
test("returns no project TUI plugins when the directory is absent", async () => {
await using tmp = await tmpdir()
expect(await discoverTuiPlugins(tmp.path)).toEqual([])
const roots = await tuiPluginDirectories(tmp.path, path.join(tmp.path, "config"))
expect(await discoverTuiPlugins(roots)).toEqual([])
expect(roots).toContain(path.join(tmp.path, ".opencode", "plugins", "tui"))
})
test("discovers global and ancestor plugin roots in precedence order", async () => {
await using tmp = await tmpdir()
const cwd = path.join(tmp.path, "repo", "packages", "app")
const project = path.join(tmp.path, "repo")
const config = path.join(tmp.path, "config")
const directories = [
path.join(config, "plugins", "tui"),
path.join(tmp.path, "repo", ".opencode", "plugins", "tui"),
path.join(tmp.path, "repo", "packages", ".opencode", "plugins", "tui"),
]
const outside = path.join(tmp.path, ".opencode", "plugins", "tui")
await mkdir(path.join(project, ".git"), { recursive: true })
await Promise.all([...directories, outside].map((directory) => mkdir(directory, { recursive: true })))
await Promise.all(
directories.map((directory, index) => writeFile(path.join(directory, `${index}.ts`), "export default {}")),
)
await writeFile(path.join(outside, "outside.ts"), "export default {}")
const roots = await tuiPluginDirectories(cwd, config)
expect(await discoverTuiPlugins(roots)).toEqual(
directories.map((directory, index) => path.join(directory, `${index}.ts`)),
)
expect(roots).not.toContain(path.join(cwd, ".opencode", "plugins", "tui"))
expect(roots).not.toContain(outside)
})
test("uses an Hg root for a missing project plugin directory", async () => {
await using tmp = await tmpdir()
const project = path.join(tmp.path, "repo")
const cwd = path.join(project, "package")
await mkdir(path.join(project, ".hg"), { recursive: true })
await mkdir(cwd, { recursive: true })
expect(await tuiPluginDirectories(cwd, path.join(tmp.path, "config"))).toContain(
path.join(project, ".opencode", "plugins", "tui"),
)
})
test("propagates non-missing filesystem errors", async () => {
await expect(localProjectDirectory("\0")).rejects.toBeInstanceOf(Error)
await expect(discoverTuiPlugins(["\0"])).rejects.toBeInstanceOf(Error)
})

View file

@ -1,11 +1,10 @@
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 { createEventStream, createFetch, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
function lifecycleSource(marker: string, id: string, version: string) {
@ -36,7 +35,16 @@ async function bootApp(directory: string) {
const core = await import("@opentui/core")
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
const events = createEventStream()
const calls = createFetch(undefined, events)
const calls = createFetch((url) => {
if (url.pathname !== "/api/fs/list") return
return json({
location: {
directory,
project: { id: "proj_test", directory, canonical: directory },
},
data: [],
})
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
const cwd = process.cwd()
process.chdir(directory)
@ -49,7 +57,10 @@ async function bootApp(directory: string) {
packages: { resolve: async () => undefined },
args: {},
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
}).pipe(
Effect.provide(Global.layerWith({ config: path.join(directory, ".global") })),
Effect.provide(FileSystem.layerNoop({})),
),
)
return {
task,
@ -62,6 +73,34 @@ async function bootApp(directory: string) {
}
}
test("discovers an ancestor TUI plugin directory created after startup", async () => {
await using tmp = await tmpdir()
const cwd = path.join(tmp.path, "repo", "packages", "app")
await mkdir(cwd, { recursive: true })
await mkdir(path.join(tmp.path, "repo", ".git"))
const ready = path.join(tmp.path, "ready.txt")
const marker = path.join(tmp.path, "marker.txt")
const initial = path.join(cwd, ".opencode", "plugins", "tui")
await mkdir(initial, { recursive: true })
await writeFile(path.join(initial, "ready.ts"), lifecycleSource(ready, "test.ready", "ready"))
await using app = await bootApp(cwd)
expect(await until(() => readFile(ready, "utf8"), (value) => value === "ready:setup\n")).toBe("ready:setup\n")
const directory = path.join(tmp.path, "repo", ".opencode", "plugins", "tui")
await mkdir(directory, { recursive: true })
await writeFile(path.join(directory, "hot.ts"), lifecycleSource(marker, "test.hot", "v1"))
expect(
await until(
() => readFile(marker, "utf8"),
(value) => value === "v1:setup\n",
),
).toBe("v1:setup\n")
process.emit("SIGHUP")
await app.task
})
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")

View file

@ -12,7 +12,8 @@ import {
setCustomThemes,
upsertTheme,
} from "../src/theme"
import { discoverThemes, themeDirectories } from "../src/theme/discovery"
import { discoverThemes } from "../src/theme/discovery"
import { configDirectories } from "../src/util/config-directories"
import { terminalMode } from "../src/theme/system"
import { tmpdir } from "./fixture/fixture"
@ -187,7 +188,7 @@ test("theme directories include global config before project directories", async
await writeFile(path.join(global, "themes", "global.json"), JSON.stringify({ source: "global" }))
await writeFile(path.join(project, ".opencode", "themes", "project.json"), JSON.stringify({ source: "project" }))
await expect(discoverThemes(themeDirectories(global, project))).resolves.toEqual({
await expect(discoverThemes(configDirectories(global, project))).resolves.toEqual({
global: { source: "global" },
project: { source: "project" },
})