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
4 changed files with 68 additions and 33 deletions
+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")