Skip to content

Commit bfffc3c

Browse files
committed
tui: ensure TUI plugins load with proper project context when multiple directories are open
Fixes potential plugin resolution issues when switching between projects by wrapping plugin loading in Instance.provide(). This ensures each plugin resolves dependencies relative to its correct project directory instead of inheriting context from whatever instance happened to be active. Also reorganizes config loading code into focused modules (command.ts, managed.ts, plugin.ts) to make the codebase easier to maintain and test.
1 parent b28956f commit bfffc3c

8 files changed

Lines changed: 265 additions & 270 deletions

File tree

packages/opencode/src/cli/cmd/tui/plugin/runtime.ts

Lines changed: 42 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { TuiConfig } from "@/cli/cmd/tui/config/tui"
1616
import { Log } from "@/util"
1717
import { errorData, errorMessage } from "@/util/error"
1818
import { isRecord } from "@/util/record"
19+
import { Instance } from "@/project/instance"
1920
import {
2021
readPackageThemes,
2122
readPluginId,
@@ -789,7 +790,13 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
789790
state.pending.delete(spec)
790791
return true
791792
}
792-
const ready = await resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies())
793+
const ready = await Instance.provide({
794+
directory: state.directory,
795+
fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()),
796+
}).catch((error) => {
797+
fail("failed to add tui plugin", { path: next, error })
798+
return [] as PluginLoad[]
799+
})
793800
if (!ready.length) {
794801
return false
795802
}
@@ -980,37 +987,42 @@ export namespace TuiPluginRuntime {
980987
}
981988
runtime = next
982989
try {
983-
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
984-
if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
985-
log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
986-
}
990+
await Instance.provide({
991+
directory: cwd,
992+
fn: async () => {
993+
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
994+
if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
995+
log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
996+
}
987997

988-
for (const item of INTERNAL_TUI_PLUGINS) {
989-
log.info("loading internal tui plugin", { id: item.id })
990-
const entry = loadInternalPlugin(item)
991-
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
992-
addPluginEntry(next, {
993-
id: entry.id,
994-
load: entry,
995-
meta,
996-
themes: {},
997-
plugin: entry.module.tui,
998-
enabled: true,
999-
})
1000-
}
998+
for (const item of INTERNAL_TUI_PLUGINS) {
999+
log.info("loading internal tui plugin", { id: item.id })
1000+
const entry = loadInternalPlugin(item)
1001+
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
1002+
addPluginEntry(next, {
1003+
id: entry.id,
1004+
load: entry,
1005+
meta,
1006+
themes: {},
1007+
plugin: entry.module.tui,
1008+
enabled: true,
1009+
})
1010+
}
10011011

1002-
const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
1003-
await addExternalPluginEntries(next, ready)
1004-
1005-
applyInitialPluginEnabledState(next, config)
1006-
for (const plugin of next.plugins) {
1007-
if (!plugin.enabled) continue
1008-
// Keep plugin execution sequential for deterministic side effects:
1009-
// command registration order affects keybind/command precedence,
1010-
// route registration is last-wins when ids collide,
1011-
// and hook chains rely on stable plugin ordering.
1012-
await activatePluginEntry(next, plugin, false)
1013-
}
1012+
const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
1013+
await addExternalPluginEntries(next, ready)
1014+
1015+
applyInitialPluginEnabledState(next, config)
1016+
for (const plugin of next.plugins) {
1017+
if (!plugin.enabled) continue
1018+
// Keep plugin execution sequential for deterministic side effects:
1019+
// command registration order affects keybind/command precedence,
1020+
// route registration is last-wins when ids collide,
1021+
// and hook chains rely on stable plugin ordering.
1022+
await activatePluginEntry(next, plugin, false)
1023+
}
1024+
},
1025+
})
10141026
} catch (error) {
10151027
fail("failed to load tui plugins", { directory: cwd, error })
10161028
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { Log } from "../util"
2+
import path from "path"
3+
import z from "zod"
4+
import { NamedError } from "@opencode-ai/shared/util/error"
5+
import { Glob } from "@opencode-ai/shared/util/glob"
6+
import { Bus } from "@/bus"
7+
import * as ConfigMarkdown from "./markdown"
8+
import { InvalidError } from "./paths"
9+
10+
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
11+
12+
const log = Log.create({ service: "config" })
13+
14+
function rel(item: string, patterns: string[]) {
15+
const normalizedItem = item.replaceAll("\\", "/")
16+
for (const pattern of patterns) {
17+
const index = normalizedItem.indexOf(pattern)
18+
if (index === -1) continue
19+
return normalizedItem.slice(index + pattern.length)
20+
}
21+
}
22+
23+
function trim(file: string) {
24+
const ext = path.extname(file)
25+
return ext.length ? file.slice(0, -ext.length) : file
26+
}
27+
28+
export namespace ConfigCommand {
29+
export const Info = z.object({
30+
template: z.string(),
31+
description: z.string().optional(),
32+
agent: z.string().optional(),
33+
model: ModelId.optional(),
34+
subtask: z.boolean().optional(),
35+
})
36+
37+
export type Info = z.infer<typeof Info>
38+
39+
export async function load(dir: string) {
40+
const result: Record<string, Info> = {}
41+
for (const item of await Glob.scan("{command,commands}/**/*.md", {
42+
cwd: dir,
43+
absolute: true,
44+
dot: true,
45+
symlink: true,
46+
})) {
47+
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
48+
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
49+
? err.data.message
50+
: `Failed to parse command ${item}`
51+
const { Session } = await import("@/session")
52+
void Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
53+
log.error("failed to load command", { command: item, err })
54+
return undefined
55+
})
56+
if (!md) continue
57+
58+
const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"]
59+
const file = rel(item, patterns) ?? path.basename(item)
60+
const name = trim(file)
61+
62+
const config = {
63+
name,
64+
...md.data,
65+
template: md.content.trim(),
66+
}
67+
const parsed = Info.safeParse(config)
68+
if (parsed.success) {
69+
result[config.name] = parsed.data
70+
continue
71+
}
72+
throw new InvalidError({ path: item, issues: parsed.error.issues }, { cause: parsed.error })
73+
}
74+
return result
75+
}
76+
}

0 commit comments

Comments
 (0)