Sebastian Herrlinger 1 mês atrás
pai
commit
f9385bcc63

+ 0 - 0
.opencode/themes/mytheme.json → .opencode/plugins/smoke-theme.json


+ 2 - 2
.opencode/plugins/tui-smoke.tsx

@@ -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 - 0
.opencode/themes/.gitignore

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

+ 1 - 1
.opencode/tui.json

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

+ 57 - 62
packages/opencode/src/cli/cmd/tui/plugin.ts

@@ -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,84 +66,77 @@ 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 {
   const log = Log.create({ service: "tui.plugin" })
   let loaded: Promise<void> | undefined
@@ -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),

+ 34 - 38
packages/opencode/test/cli/tui/plugin-loader.test.ts

@@ -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(