Browse Source

theme api

Sebastian Herrlinger 1 month ago
parent
commit
29aab3223c

+ 3 - 6
.opencode/plugins/tui-smoke.tsx

@@ -1,5 +1,4 @@
 /** @jsxImportSource @opentui/solid */
-import mytheme from "../themes/mytheme.json" with { type: "json" }
 import { extend, useKeyboard, useTerminalDimensions, type RenderableConstructor } from "@opentui/solid"
 import { RGBA, VignetteEffect, type OptimizedBuffer, type RenderContext } from "@opentui/core"
 import { ThreeRenderable, THREE } from "@opentui/core/3d"
@@ -726,13 +725,12 @@ const reg = (api: TuiApi, input: ReturnType<typeof cfg>) => {
   ])
 }
 
-const themes = {
-  "workspace-plugin-smoke": mytheme,
-}
-
 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")
+
   const value = cfg(options)
   const route = names(value)
   const fx = new VignetteEffect(value.vignette)
@@ -770,6 +768,5 @@ const tui = async (input: TuiPluginInput, options?: Record<string, unknown>) =>
 }
 
 export default {
-  themes,
   tui,
 }

+ 1 - 1
.opencode/tui.json

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

+ 9 - 0
packages/opencode/src/cli/cmd/tui/app.tsx

@@ -395,6 +395,15 @@ function App() {
       get selected() {
         return t.selected
       },
+      has(name) {
+        return t.has(name)
+      },
+      set(name) {
+        return t.set(name)
+      },
+      async install(_jsonPath) {
+        throw new Error("theme.install is only available in plugin context")
+      },
       mode() {
         return t.mode()
       },

+ 24 - 8
packages/opencode/src/cli/cmd/tui/context/theme.tsx

@@ -42,6 +42,7 @@ import { createStore, produce } from "solid-js/store"
 import { Global } from "@/global"
 import { Filesystem } from "@/util/filesystem"
 import { useTuiConfig } from "./tui-config"
+import { isRecord } from "@/util/record"
 
 type ThemeColors = {
   primary: RGBA
@@ -203,15 +204,25 @@ export function allThemes() {
   return store.themes
 }
 
-export function registerThemes(themes: Record<string, unknown>) {
-  const list = Object.entries(themes).filter((entry): entry is [string, ThemeJson] => {
-    const theme = entry[1]
-    if (!theme || typeof theme !== "object") return false
-    if (!("theme" in theme)) return false
-    return true
+function isTheme(theme: unknown): theme is ThemeJson {
+  if (!isRecord(theme)) return false
+  if (!isRecord(theme.theme)) return false
+  return true
+}
+
+export function hasTheme(name: string) {
+  if (!name) return false
+  return allThemes()[name] !== undefined
+}
+
+export function addTheme(name: string, theme: unknown) {
+  if (!name) return false
+  if (!isTheme(theme)) return false
+  if (hasTheme(name)) return false
+  mergeThemes({
+    [name]: theme,
   })
-  if (!list.length) return
-  mergeThemes(Object.fromEntries(list))
+  return true
 }
 
 function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
@@ -414,6 +425,9 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
       all() {
         return allThemes()
       },
+      has(name: string) {
+        return hasTheme(name)
+      },
       syntax,
       subtleSyntax,
       mode() {
@@ -424,8 +438,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
         kv.set("theme_mode", mode)
       },
       set(theme: string) {
+        if (!hasTheme(theme)) return false
         setStore("active", theme)
         kv.set("theme", theme)
+        return true
       },
       get ready() {
         return store.ready

+ 105 - 11
packages/opencode/src/cli/cmd/tui/plugin.ts

@@ -5,10 +5,13 @@ import {
   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"
 import "@opentui/solid/preload"
+import path from "path"
+import { fileURLToPath } from "url"
 
 import { Config } from "@/config/config"
 import { TuiConfig } from "@/config/tui"
@@ -16,7 +19,9 @@ import { Log } from "@/util/log"
 import { isRecord } from "@/util/record"
 import { Instance } from "@/project/instance"
 import { resolvePluginTarget, uniqueModuleEntries } from "@/plugin/shared"
-import { registerThemes } from "./context/theme"
+import { addTheme, hasTheme } from "./context/theme"
+import { Global } from "@/global"
+import { Filesystem } from "@/util/filesystem"
 
 type SlotProps<K extends keyof TuiSlotMap> = {
   name: K
@@ -45,12 +50,6 @@ function getTuiSlotPlugin(value: unknown) {
   return value.slots
 }
 
-function getThemes(value: unknown) {
-  if (!isRecord(value) || !("themes" in value)) return
-  if (!isRecord(value.themes)) return
-  return value.themes
-}
-
 function isTuiPlugin(value: unknown): value is TuiPluginFn<CliRenderer> {
   return typeof value === "function"
 }
@@ -61,6 +60,90 @@ function getTuiPlugin(value: unknown) {
   return value.tui
 }
 
+function isTheme(value: unknown) {
+  if (!isRecord(value)) return false
+  if (!isRecord(value.theme)) return false
+  return true
+}
+
+function localThemeDir(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)
+  return path.join(Global.Path.config, "themes")
+}
+
+function pluginDir(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 themeName(filepath: string) {
+  return path.basename(filepath, path.extname(filepath))
+}
+
+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)
+    },
+  }
+}
+
 export namespace TuiPlugin {
   const log = Log.create({ service: "tui.plugin" })
   let loaded: Promise<void> | undefined
@@ -128,6 +211,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)]
           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 })
@@ -151,15 +235,25 @@ export namespace TuiPlugin {
               continue
             }
 
-            const theme = getThemes(entry)
-            if (theme) registerThemes(theme)
-
             const slotPlugin = getTuiSlotPlugin(entry)
             if (slotPlugin) input.slots.register(slotPlugin)
 
             const tuiPlugin = getTuiPlugin(entry)
             if (!tuiPlugin) continue
-            await tuiPlugin(input, Config.pluginOptions(item))
+            const root = pluginDir(spec, target)
+            await tuiPlugin(
+              {
+                ...input,
+                api: {
+                  command: input.api.command,
+                  route: input.api.route,
+                  ui: input.api.ui,
+                  keybind: input.api.keybind,
+                  theme: themeApi(input.api, { root, meta }),
+                },
+              },
+              Config.pluginOptions(item),
+            )
           }
 
           return true

+ 147 - 16
packages/opencode/test/cli/tui/plugin-loader.test.ts

@@ -6,6 +6,7 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2"
 import type { CliRenderer } from "@opentui/core"
 import { tmpdir } from "../../fixture/fixture"
 import { Log } from "../../../src/util/log"
+import { Global } from "../../../src/global"
 
 mock.module("@opentui/solid/preload", () => ({}))
 mock.module("@opentui/solid/jsx-runtime", () => ({
@@ -14,6 +15,7 @@ mock.module("@opentui/solid/jsx-runtime", () => ({
   jsxs: () => null,
   jsxDEV: () => null,
 }))
+const { allThemes } = await import("../../../src/cli/cmd/tui/context/theme")
 const { TuiPlugin } = await import("../../../src/cli/cmd/tui/plugin")
 
 async function waitForLog(text: string, timeout = 1000) {
@@ -33,16 +35,35 @@ async function waitForLog(text: string, timeout = 1000) {
     .catch(() => "")
 }
 
-test("ignores function-only tui exports and loads object exports", async () => {
+test("loads plugin theme API with scoped theme installation", async () => {
+  const stamp = Date.now()
+  const globalConfigPath = path.join(Global.Path.config, "tui.json")
+  const backup = await Bun.file(globalConfigPath)
+    .text()
+    .catch(() => undefined)
+
   await using tmp = await tmpdir({
     init: async (dir) => {
-      const pluginPath = path.join(dir, "plugin.ts")
+      const localPluginPath = path.join(dir, "local-plugin.ts")
+      const globalPluginPath = path.join(dir, "global-plugin.ts")
+      const localThemeFile = `local-theme-${stamp}.json`
+      const globalThemeFile = `global-theme-${stamp}.json`
+      const localThemeName = localThemeFile.replace(/\.json$/, "")
+      const globalThemeName = globalThemeFile.replace(/\.json$/, "")
+      const localThemePath = path.join(dir, localThemeFile)
+      const globalThemePath = path.join(dir, globalThemeFile)
+      const localDest = path.join(dir, ".opencode", "themes", localThemeFile)
+      const globalDest = path.join(Global.Path.config, "themes", globalThemeFile)
       const fnMarker = path.join(dir, "function-called.txt")
-      const objMarker = path.join(dir, "object-called.txt")
-      const configPath = path.join(dir, "tui.json")
+      const localMarker = path.join(dir, "local-called.json")
+      const globalMarker = path.join(dir, "global-called.json")
+      const localConfigPath = path.join(dir, "tui.json")
+
+      await Bun.write(localThemePath, JSON.stringify({ theme: { primary: "#101010" } }, null, 2))
+      await Bun.write(globalThemePath, JSON.stringify({ theme: { primary: "#202020" } }, null, 2))
 
       await Bun.write(
-        pluginPath,
+        localPluginPath,
         [
           "export default async (_input, options) => {",
           "  if (!options?.fn_marker) return",
@@ -50,9 +71,21 @@ test("ignores function-only tui exports and loads object exports", async () => {
           "}",
           "",
           "export const object_plugin = {",
-          "  tui: async (_input, options) => {",
-          "    if (!options?.obj_marker) return",
-          "    await Bun.write(options.obj_marker, 'called')",
+          "  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 }),",
+          "    )",
           "  },",
           "}",
           "",
@@ -60,10 +93,54 @@ test("ignores function-only tui exports and loads object exports", async () => {
       )
 
       await Bun.write(
-        configPath,
+        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"),
+      )
+
+      await Bun.write(
+        globalConfigPath,
+        JSON.stringify(
+          {
+            plugin: [
+              [
+                pathToFileURL(globalPluginPath).href,
+                { marker: globalMarker, theme_path: `./${globalThemeFile}`, theme_name: globalThemeName },
+              ],
+            ],
+          },
+          null,
+          2,
+        ),
+      )
+
+      await Bun.write(
+        localConfigPath,
         JSON.stringify(
           {
-            plugin: [[pathToFileURL(pluginPath).href, { fn_marker: fnMarker, obj_marker: objMarker }]],
+            plugin: [
+              [
+                pathToFileURL(localPluginPath).href,
+                {
+                  fn_marker: fnMarker,
+                  marker: localMarker,
+                  source: localThemePath,
+                  dest: localDest,
+                  theme_path: `./${localThemeFile}`,
+                  theme_name: localThemeName,
+                },
+              ],
+            ],
           },
           null,
           2,
@@ -71,15 +148,21 @@ test("ignores function-only tui exports and loads object exports", async () => {
       )
 
       return {
-        configPath,
+        localThemeFile,
+        globalThemeFile,
+        localThemeName,
+        globalThemeName,
+        localDest,
+        globalDest,
         fnMarker,
-        objMarker,
+        localMarker,
+        globalMarker,
       }
     },
   })
 
-  process.env.OPENCODE_TUI_CONFIG = tmp.extra.configPath
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
+  let selected = "opencode"
 
   const renderer = {
     ...Object.create(null),
@@ -133,7 +216,18 @@ test("ignores function-only tui exports and loads object exports", async () => {
             return {}
           },
           get selected() {
-            return "opencode"
+            return selected
+          },
+          has(name) {
+            return allThemes()[name] !== undefined
+          },
+          set(name) {
+            if (!allThemes()[name]) return false
+            selected = name
+            return true
+          },
+          async install() {
+            throw new Error("base theme.install should not run")
           },
           mode() {
             return "dark" as const
@@ -145,15 +239,52 @@ test("ignores function-only tui exports and loads object exports", async () => {
       },
     })
 
-    expect(await fs.readFile(tmp.extra.objMarker, "utf8")).toBe("called")
+    const local = JSON.parse(await fs.readFile(tmp.extra.localMarker, "utf8"))
+    expect(local.before).toBe(false)
+    expect(local.set_missing).toBe(false)
+    expect(local.after).toBe(true)
+    expect(local.set_installed).toBe(true)
+    expect(local.selected).toBe(tmp.extra.localThemeName)
+    expect(local.same).toBe(true)
+
+    const global = JSON.parse(await fs.readFile(tmp.extra.globalMarker, "utf8"))
+    expect(global.has).toBe(true)
+    expect(global.set_installed).toBe(true)
+    expect(global.selected).toBe(tmp.extra.globalThemeName)
+
     await expect(fs.readFile(tmp.extra.fnMarker, "utf8")).rejects.toThrow()
 
+    const localInstalled = await fs.readFile(tmp.extra.localDest, "utf8")
+    expect(localInstalled).toContain("#101010")
+    expect(localInstalled).not.toContain("#fefefe")
+
+    const globalInstalled = await fs.readFile(tmp.extra.globalDest, "utf8")
+    expect(globalInstalled).toContain("#202020")
+
+    expect(
+      await fs
+        .stat(path.join(Global.Path.config, "themes", tmp.extra.localThemeFile))
+        .then(() => true)
+        .catch(() => false),
+    ).toBe(false)
+    expect(
+      await fs
+        .stat(path.join(tmp.path, ".opencode", "themes", tmp.extra.globalThemeFile))
+        .then(() => true)
+        .catch(() => false),
+    ).toBe(false)
+
     const log = await waitForLog("ignoring non-object tui plugin export")
     expect(log).toContain("ignoring non-object tui plugin export")
     expect(log).toContain("name=default")
     expect(log).toContain("type=function")
   } finally {
     cwd.mockRestore()
-    delete process.env.OPENCODE_TUI_CONFIG
+    if (backup === undefined) {
+      await fs.rm(globalConfigPath, { force: true })
+    } else {
+      await Bun.write(globalConfigPath, backup)
+    }
+    await fs.rm(tmp.extra.globalDest, { force: true }).catch(() => {})
   }
 })

+ 15 - 10
packages/opencode/test/cli/tui/theme-store.test.ts

@@ -7,33 +7,38 @@ mock.module("@opentui/solid/jsx-runtime", () => ({
   jsxDEV: () => null,
 }))
 
-const { DEFAULT_THEMES, allThemes, registerThemes } = await import("../../../src/cli/cmd/tui/context/theme")
+const { DEFAULT_THEMES, allThemes, addTheme, hasTheme } = await import("../../../src/cli/cmd/tui/context/theme")
 
-test("registerThemes writes into module theme store", () => {
+test("addTheme writes into module theme store", () => {
   const name = `plugin-theme-${Date.now()}`
-  registerThemes({
-    [name]: DEFAULT_THEMES.opencode,
-  })
+  expect(addTheme(name, DEFAULT_THEMES.opencode)).toBe(true)
 
   expect(allThemes()[name]).toBeDefined()
 })
 
-test("registerThemes keeps first theme for duplicate names", () => {
+test("addTheme keeps first theme for duplicate names", () => {
   const name = `plugin-theme-keep-${Date.now()}`
   const one = structuredClone(DEFAULT_THEMES.opencode)
   const two = structuredClone(DEFAULT_THEMES.opencode)
   ;(one.theme as Record<string, unknown>).primary = "#101010"
   ;(two.theme as Record<string, unknown>).primary = "#fefefe"
 
-  registerThemes({ [name]: one })
-  registerThemes({ [name]: two })
+  expect(addTheme(name, one)).toBe(true)
+  expect(addTheme(name, two)).toBe(false)
 
   expect(allThemes()[name]).toBeDefined()
   expect(allThemes()[name]!.theme.primary).toBe("#101010")
 })
 
-test("registerThemes ignores entries without a theme object", () => {
+test("addTheme ignores entries without a theme object", () => {
   const name = `plugin-theme-invalid-${Date.now()}`
-  registerThemes({ [name]: { defs: { a: "#ffffff" } } })
+  expect(addTheme(name, { defs: { a: "#ffffff" } })).toBe(false)
   expect(allThemes()[name]).toBeUndefined()
 })
+
+test("hasTheme checks theme presence", () => {
+  const name = `plugin-theme-has-${Date.now()}`
+  expect(hasTheme(name)).toBe(false)
+  expect(addTheme(name, DEFAULT_THEMES.opencode)).toBe(true)
+  expect(hasTheme(name)).toBe(true)
+})

+ 3 - 19
packages/plugin/src/tui.ts

@@ -4,24 +4,6 @@ import type { Plugin as ServerPlugin, PluginOptions } from "./index"
 
 export type { CliRenderer, SlotMode } from "@opentui/core"
 
-type HexColor = `#${string}`
-type RefName = string
-type Variant = {
-  dark: HexColor | RefName | number
-  light: HexColor | RefName | number
-}
-type ThemeColorValue = HexColor | RefName | number | Variant
-
-export type ThemeJson = {
-  $schema?: string
-  defs?: Record<string, HexColor | RefName>
-  theme: Record<string, ThemeColorValue> & {
-    selectedListItemText?: ThemeColorValue
-    backgroundMenu?: ThemeColorValue
-    thinkingOpacity?: number
-  }
-}
-
 export type TuiRouteCurrent =
   | {
       name: "home"
@@ -128,6 +110,9 @@ export type TuiToast = {
 export type TuiTheme = {
   readonly current: Record<string, unknown>
   readonly selected: string
+  has: (name: string) => boolean
+  set: (name: string) => boolean
+  install: (jsonPath: string) => Promise<void>
   mode: () => "dark" | "light"
   readonly ready: boolean
 }
@@ -200,5 +185,4 @@ export type TuiPluginModule<Renderer = CliRenderer, Node = unknown> = {
   server?: ServerPlugin
   tui?: TuiPlugin<Renderer, Node>
   slots?: TuiSlotPlugin
-  themes?: Record<string, ThemeJson>
 }