actual-tui-plugins
Sebastian Herrlinger 2026-03-06 22:55:28 +01:00
parent 29aab3223c
commit f9385bcc63
6 changed files with 95 additions and 103 deletions

View File

@ -728,8 +728,8 @@ const reg = (api: TuiApi, input: ReturnType<typeof cfg>) => {
const tui = async (input: TuiPluginInput, options?: Record<string, unknown>) => {
if (options?.enabled === false) return
await input.api.theme.install("../themes/mytheme.json")
input.api.theme.set("mytheme")
await input.api.theme.install("./smoke-theme.json")
input.api.theme.set("smoke-theme")
const value = cfg(options)
const route = names(value)

1
.opencode/themes/.gitignore vendored 100644
View File

@ -0,0 +1 @@
smoke-theme.json

View File

@ -1,6 +1,6 @@
{
"$schema": "https://opencode.ai/tui.json",
"theme": "mytheme",
"theme": "smoke-theme",
"plugin": [
[
"./plugins/tui-smoke.tsx",

View File

@ -1,11 +1,11 @@
import {
type TuiPlugin as TuiPluginFn,
type TuiPluginInput,
type TuiTheme,
type TuiSlotContext,
type TuiSlotMap,
type TuiSlots,
type SlotMode,
type TuiApi,
} from "@opencode-ai/plugin/tui"
import { createSlot, createSolidSlotRegistry, type JSX, type SolidPlugin } from "@opentui/solid"
import type { CliRenderer } from "@opentui/core"
@ -66,82 +66,75 @@ function isTheme(value: unknown) {
return true
}
function localThemeDir(file: string) {
function localDir(file: string) {
const dir = path.dirname(file)
if (path.basename(dir) === ".opencode") return path.join(dir, "themes")
return path.join(dir, ".opencode", "themes")
}
function themeDir(meta?: TuiConfig.PluginMeta) {
if (meta?.scope === "local") return localThemeDir(meta.source)
function scopeDir(meta: TuiConfig.PluginMeta) {
if (meta.scope === "local") return localDir(meta.source)
return path.join(Global.Path.config, "themes")
}
function pluginDir(spec: string, target: string) {
function pluginRoot(spec: string, target: string) {
if (spec.startsWith("file://")) return path.dirname(fileURLToPath(spec))
if (target.startsWith("file://")) return path.dirname(fileURLToPath(target))
return target
}
function themePath(root: string, filepath: string) {
if (filepath.startsWith("file://")) return fileURLToPath(filepath)
if (path.isAbsolute(filepath)) return filepath
return path.resolve(root, filepath)
function source(root: string, file: string) {
if (file.startsWith("file://")) return fileURLToPath(file)
if (path.isAbsolute(file)) return file
return path.resolve(root, file)
}
function themeName(filepath: string) {
return path.basename(filepath, path.extname(filepath))
function name(file: string) {
return path.basename(file, path.extname(file))
}
function themeApi(
api: TuiApi<JSX.Element>,
options: {
root: string
meta?: TuiConfig.PluginMeta
},
) {
return {
get current() {
return api.theme.current
},
get selected() {
return api.theme.selected
},
mode() {
return api.theme.mode()
},
get ready() {
return api.theme.ready
},
has(name: string) {
return api.theme.has(name)
},
set(name: string) {
return api.theme.set(name)
},
async install(filepath: string) {
const source = themePath(options.root, filepath)
const name = themeName(source)
if (hasTheme(name)) return
const text = await Bun.file(source)
.text()
.catch((error) => {
throw new Error(`failed to read theme at ${source}: ${error}`)
})
const data = JSON.parse(text)
if (!isTheme(data)) {
throw new Error(`invalid theme at ${source}`)
}
const dest = path.join(themeDir(options.meta), `${name}.json`)
if (!(await Filesystem.exists(dest))) {
await Filesystem.write(dest, text)
}
addTheme(name, data)
},
function meta(config: TuiConfig.Info, item: Config.PluginSpec) {
const key = Config.getPluginName(item)
const value = config.plugin_meta?.[key]
if (!value) {
throw new Error(`missing plugin metadata for ${key}`)
}
return value
}
function install(meta: TuiConfig.PluginMeta, root: string): TuiTheme["install"] {
return async (file) => {
const src = source(root, file)
const theme = name(src)
if (hasTheme(theme)) return
const text = await Bun.file(src)
.text()
.catch((error) => {
throw new Error(`failed to read theme at ${src}: ${error}`)
})
const data = JSON.parse(text)
if (!isTheme(data)) {
throw new Error(`invalid theme at ${src}`)
}
const dest = path.join(scopeDir(meta), `${theme}.json`)
if (!(await Filesystem.exists(dest))) {
await Filesystem.write(dest, text)
}
addTheme(theme, data)
}
}
function themeApi(theme: TuiTheme, add: TuiTheme["install"]): TuiTheme {
return Object.create(theme, {
install: {
value: add,
configurable: true,
enumerable: true,
},
})
}
export namespace TuiPlugin {
@ -211,7 +204,7 @@ export namespace TuiPlugin {
const loadOne = async (item: (typeof plugins)[number], retry = false) => {
const spec = Config.pluginSpecifier(item)
const meta = config.plugin_meta?.[Config.getPluginName(item)]
const level = meta(config, item)
log.info("loading tui plugin", { path: spec, retry })
const target = await resolvePluginTarget(spec).catch((error) => {
log.error("failed to resolve tui plugin", { path: spec, retry, error })
@ -219,6 +212,9 @@ export namespace TuiPlugin {
})
if (!target) return false
const root = pluginRoot(spec, target)
const add = install(level, root)
const mod = await import(target).catch((error) => {
log.error("failed to load tui plugin", { path: spec, retry, error })
return
@ -240,7 +236,6 @@ export namespace TuiPlugin {
const tuiPlugin = getTuiPlugin(entry)
if (!tuiPlugin) continue
const root = pluginDir(spec, target)
await tuiPlugin(
{
...input,
@ -249,7 +244,7 @@ export namespace TuiPlugin {
route: input.api.route,
ui: input.api.ui,
keybind: input.api.keybind,
theme: themeApi(input.api, { root, meta }),
theme: themeApi(input.api.theme, add),
},
},
Config.pluginOptions(item),

View File

@ -64,48 +64,44 @@ test("loads plugin theme API with scoped theme installation", async () => {
await Bun.write(
localPluginPath,
[
"export default async (_input, options) => {",
" if (!options?.fn_marker) return",
" await Bun.write(options.fn_marker, 'called')",
"}",
"",
"export const object_plugin = {",
" tui: async (input, options) => {",
" if (!options?.marker) return",
" const before = input.api.theme.has(options.theme_name)",
" const set_missing = input.api.theme.set(options.theme_name)",
" await input.api.theme.install(options.theme_path)",
" const after = input.api.theme.has(options.theme_name)",
" const set_installed = input.api.theme.set(options.theme_name)",
" const first = await Bun.file(options.dest).text()",
" await Bun.write(options.source, JSON.stringify({ theme: { primary: '#fefefe' } }, null, 2))",
" await input.api.theme.install(options.theme_path)",
" const second = await Bun.file(options.dest).text()",
" await Bun.write(",
" options.marker,",
" JSON.stringify({ before, set_missing, after, set_installed, selected: input.api.theme.selected, same: first === second }),",
" )",
" },",
"}",
"",
].join("\n"),
`export default async (_input, options) => {
if (!options?.fn_marker) return
await Bun.write(options.fn_marker, "called")
}
export const object_plugin = {
tui: async (input, options) => {
if (!options?.marker) return
const before = input.api.theme.has(options.theme_name)
const set_missing = input.api.theme.set(options.theme_name)
await input.api.theme.install(options.theme_path)
const after = input.api.theme.has(options.theme_name)
const set_installed = input.api.theme.set(options.theme_name)
const first = await Bun.file(options.dest).text()
await Bun.write(options.source, JSON.stringify({ theme: { primary: "#fefefe" } }, null, 2))
await input.api.theme.install(options.theme_path)
const second = await Bun.file(options.dest).text()
await Bun.write(
options.marker,
JSON.stringify({ before, set_missing, after, set_installed, selected: input.api.theme.selected, same: first === second }),
)
},
}
`,
)
await Bun.write(
globalPluginPath,
[
"export default {",
" tui: async (input, options) => {",
" if (!options?.marker) return",
" await input.api.theme.install(options.theme_path)",
" const has = input.api.theme.has(options.theme_name)",
" const set_installed = input.api.theme.set(options.theme_name)",
" await Bun.write(options.marker, JSON.stringify({ has, set_installed, selected: input.api.theme.selected }))",
" },",
"}",
"",
].join("\n"),
`export default {
tui: async (input, options) => {
if (!options?.marker) return
await input.api.theme.install(options.theme_path)
const has = input.api.theme.has(options.theme_name)
const set_installed = input.api.theme.set(options.theme_name)
await Bun.write(options.marker, JSON.stringify({ has, set_installed, selected: input.api.theme.selected }))
},
}
`,
)
await Bun.write(