diff --git a/.opencode/plugins/tui-smoke.tsx b/.opencode/plugins/tui-smoke.tsx index 68091cde62..2cfb84fcda 100644 --- a/.opencode/plugins/tui-smoke.tsx +++ b/.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) => { ]) } -const themes = { - "workspace-plugin-smoke": mytheme, -} - const tui = async (input: TuiPluginInput, options?: Record) => { 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) => } export default { - themes, tui, } diff --git a/.opencode/tui.json b/.opencode/tui.json index e519f683ba..ada23dc578 100644 --- a/.opencode/tui.json +++ b/.opencode/tui.json @@ -1,6 +1,6 @@ { "$schema": "https://opencode.ai/tui.json", - "theme": "workspace-plugin-smoke", + "theme": "mytheme", "plugin": [ [ "./plugins/tui-smoke.tsx", diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index a52f870127..ef505efe16 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/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() }, diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 7f1b4c5828..c0be2bae66 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/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) { - 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 diff --git a/packages/opencode/src/cli/cmd/tui/plugin.ts b/packages/opencode/src/cli/cmd/tui/plugin.ts index c4da7dc3ad..8bc9f296d5 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin.ts +++ b/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 = { 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 { 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, + 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 | 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 diff --git a/packages/opencode/test/cli/tui/plugin-loader.test.ts b/packages/opencode/test/cli/tui/plugin-loader.test.ts index 5cac98505f..ab6e046abe 100644 --- a/packages/opencode/test/cli/tui/plugin-loader.test.ts +++ b/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(pluginPath).href, { fn_marker: fnMarker, obj_marker: objMarker }]], + plugin: [ + [ + pathToFileURL(globalPluginPath).href, + { marker: globalMarker, theme_path: `./${globalThemeFile}`, theme_name: globalThemeName }, + ], + ], + }, + null, + 2, + ), + ) + + await Bun.write( + localConfigPath, + JSON.stringify( + { + 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(() => {}) } }) diff --git a/packages/opencode/test/cli/tui/theme-store.test.ts b/packages/opencode/test/cli/tui/theme-store.test.ts index abcbe738ad..baa64e16c6 100644 --- a/packages/opencode/test/cli/tui/theme-store.test.ts +++ b/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).primary = "#101010" ;(two.theme as Record).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) +}) diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 0a9e51ffb7..e4aa75055e 100644 --- a/packages/plugin/src/tui.ts +++ b/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 - theme: Record & { - selectedListItemText?: ThemeColorValue - backgroundMenu?: ThemeColorValue - thinkingOpacity?: number - } -} - export type TuiRouteCurrent = | { name: "home" @@ -128,6 +110,9 @@ export type TuiToast = { export type TuiTheme = { readonly current: Record readonly selected: string + has: (name: string) => boolean + set: (name: string) => boolean + install: (jsonPath: string) => Promise mode: () => "dark" | "light" readonly ready: boolean } @@ -200,5 +185,4 @@ export type TuiPluginModule = { server?: ServerPlugin tui?: TuiPlugin slots?: TuiSlotPlugin - themes?: Record }