tui plugins (#19347)

This commit is contained in:
Sebastian
2026-03-27 15:00:26 +01:00
committed by GitHub
parent d8ad8338f5
commit 6274b0677c
91 changed files with 10544 additions and 898 deletions
+121 -27
View File
@@ -8,23 +8,101 @@ import { TuiInfo } from "./tui-schema"
import { Instance } from "@/project/instance"
import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
import { isRecord } from "@/util/record"
import { Global } from "@/global"
import { parsePluginSpecifier } from "@/plugin/shared"
export namespace TuiConfig {
const log = Log.create({ service: "tui.config" })
export const Info = TuiInfo
export type Info = z.output<typeof Info>
export type PluginMeta = {
scope: "global" | "local"
source: string
}
type PluginEntry = {
item: Config.PluginSpec
meta: PluginMeta
}
type Acc = {
result: Info
entries: PluginEntry[]
}
export type Info = z.output<typeof Info> & {
plugin_meta?: Record<string, PluginMeta>
}
function pluginScope(file: string): PluginMeta["scope"] {
if (Instance.containsPath(file)) return "local"
return "global"
}
function dedupePlugins(list: PluginEntry[]) {
const seen = new Set<string>()
const result: PluginEntry[] = []
for (const item of list.toReversed()) {
const spec = Config.pluginSpecifier(item.item)
const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg
if (seen.has(name)) continue
seen.add(name)
result.push(item)
}
return result.toReversed()
}
function mergeInfo(target: Info, source: Info): Info {
return mergeDeep(target, source)
const merged = mergeDeep(target, source)
if (target.plugin && source.plugin) {
merged.plugin = [...target.plugin, ...source.plugin]
}
return merged
}
function customPath() {
return Flag.OPENCODE_TUI_CONFIG
}
function normalize(raw: Record<string, unknown>) {
const data = { ...raw }
if (!("tui" in data)) return data
if (!isRecord(data.tui)) {
delete data.tui
return data
}
const tui = data.tui
delete data.tui
return {
...tui,
...data,
}
}
function installDeps(dir: string): Promise<void> {
return Config.installDependencies(dir)
}
async function mergeFile(acc: Acc, file: string) {
const data = await loadFile(file)
acc.result = mergeInfo(acc.result, data)
if (!data.plugin?.length) return
const scope = pluginScope(file)
for (const item of data.plugin) {
acc.entries.push({
item,
meta: {
scope,
source: file,
},
})
}
}
const state = Instance.state(async () => {
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? []
@@ -38,38 +116,55 @@ export namespace TuiConfig {
? []
: await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
let result: Info = {}
const acc: Acc = {
result: {},
entries: [],
}
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
result = mergeInfo(result, await loadFile(file))
await mergeFile(acc, file)
}
if (custom) {
result = mergeInfo(result, await loadFile(custom))
await mergeFile(acc, custom)
log.debug("loaded custom tui config", { path: custom })
}
for (const file of projectFiles) {
result = mergeInfo(result, await loadFile(file))
await mergeFile(acc, file)
}
for (const dir of unique(directories)) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
result = mergeInfo(result, await loadFile(file))
await mergeFile(acc, file)
}
}
if (existsSync(managed)) {
for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
result = mergeInfo(result, await loadFile(file))
await mergeFile(acc, file)
}
}
result.keybinds = Config.Keybinds.parse(result.keybinds ?? {})
const merged = dedupePlugins(acc.entries)
acc.result.keybinds = Config.Keybinds.parse(acc.result.keybinds ?? {})
acc.result.plugin = merged.map((item) => item.item)
acc.result.plugin_meta = merged.length
? Object.fromEntries(merged.map((item) => [Config.pluginSpecifier(item.item), item.meta]))
: undefined
const deps: Promise<void>[] = []
if (acc.result.plugin?.length) {
for (const dir of unique(directories)) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
deps.push(installDeps(dir))
}
}
return {
config: result,
config: acc.result,
deps,
}
})
@@ -77,6 +172,11 @@ export namespace TuiConfig {
return state().then((x) => x.config)
}
export async function waitForDependencies() {
const deps = await state().then((x) => x.deps)
await Promise.all(deps)
}
async function loadFile(filepath: string): Promise<Info> {
const text = await ConfigPaths.readFile(filepath)
if (!text) return {}
@@ -87,25 +187,12 @@ export namespace TuiConfig {
}
async function load(text: string, configFilepath: string): Promise<Info> {
const data = await ConfigPaths.parseText(text, configFilepath, "empty")
if (!data || typeof data !== "object" || Array.isArray(data)) return {}
const raw = await ConfigPaths.parseText(text, configFilepath, "empty")
if (!isRecord(raw)) return {}
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
// (mirroring the old opencode.json shape) still get their settings applied.
const normalized = (() => {
const copy = { ...(data as Record<string, unknown>) }
if (!("tui" in copy)) return copy
if (!copy.tui || typeof copy.tui !== "object" || Array.isArray(copy.tui)) {
delete copy.tui
return copy
}
const tui = copy.tui as Record<string, unknown>
delete copy.tui
return {
...tui,
...copy,
}
})()
const normalized = normalize(raw)
const parsed = Info.safeParse(normalized)
if (!parsed.success) {
@@ -113,6 +200,13 @@ export namespace TuiConfig {
return {}
}
return parsed.data
const data = parsed.data
if (data.plugin) {
for (let i = 0; i < data.plugin.length; i++) {
data.plugin[i] = await Config.resolvePluginSpec(data.plugin[i], configFilepath)
}
}
return data
}
}