theme api

actual-tui-plugins
Sebastian Herrlinger 2026-03-06 22:45:17 +01:00
parent 3f9603e7a6
commit 29aab3223c
8 changed files with 307 additions and 71 deletions

View File

@ -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,
}

View File

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

View File

@ -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()
},

View File

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

View File

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

View File

@ -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(() => {})
}
})

View File

@ -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)
})

View File

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