diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx index 60411e5629..96c3a6643d 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx @@ -1,14 +1,16 @@ import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select" import { THEMES, useTheme } from "../context/theme" import { useDialog } from "../ui/dialog" -import { onCleanup, onMount } from "solid-js" +import { createMemo, onCleanup, onMount } from "solid-js" export function DialogThemeList() { const theme = useTheme() - const options = Object.keys(THEMES).map((value) => ({ - title: value, - value: value as keyof typeof THEMES, - })) + const options = createMemo(() => { + return Object.keys(theme.all).map((value) => ({ + title: value, + value: value as keyof typeof THEMES, + })) + }) const dialog = useDialog() let confirmed = false let ref: DialogSelectRef @@ -27,7 +29,7 @@ export function DialogThemeList() { return ( { theme.set(opt.value) }} diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index f402b8ffc3..34d70f652e 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -1,5 +1,5 @@ import { SyntaxStyle, RGBA } from "@opentui/core" -import { createMemo, createSignal } from "solid-js" +import { createEffect, createMemo, createSignal } from "solid-js" import { useSync } from "@tui/context/sync" import { createSimpleContext } from "./helper" import aura from "../../../../../../tui/internal/theme/themes/aura.json" with { type: "json" } @@ -25,6 +25,10 @@ import tokyonight from "../../../../../../tui/internal/theme/themes/tokyonight.j import vesper from "../../../../../../tui/internal/theme/themes/vesper.json" with { type: "json" } import zenburn from "../../../../../../tui/internal/theme/themes/zenburn.json" with { type: "json" } import { useKV } from "./kv" +import path from "path" +import { Config } from "@/config/config" +import { useToast } from "../ui/toast" +import { iife } from "@/util/iife" type Theme = { primary: RGBA @@ -135,12 +139,53 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ init: (props: { mode: "dark" | "light" }) => { const sync = useSync() const kv = useKV() + const toast = useToast() const [theme, setTheme] = createSignal(sync.data.config.theme ?? kv.get("theme", "opencode")) const [mode, setMode] = createSignal(props.mode) + const [customThemes, setCustomThemes] = createSignal>() - const values = createMemo(() => { - return resolveTheme(THEMES[theme()] ?? THEMES.opencode, mode()) + // Load custom themes in the background + createEffect(async () => { + const customThemes: Record = {} + for (const configDir of await Config.directories()) { + for await (const themeFile of new Bun.Glob("themes/*.json").scan({ cwd: configDir, absolute: true })) { + const themeName = path.basename(themeFile, ".json") + const theme = await iife( + async () => { + return await Bun.file(themeFile).json() + .catch(e => { + toast.show({ + variant: "error", + message: `Failed to load theme ${themeName}: ${e.message}`, + }) + }) + } + ) + if (!theme) continue + if (THEMES[themeName]) + toast.show({ + variant: "warning", + message: `Custom theme '${themeName}' is overriding built-in theme of same name`, + }) + else if (customThemes[themeName]) + toast.show({ + variant: "warning", + message: `Multiple custom themes named '${themeName}' are defined`, + }) + customThemes[themeName] = resolveTheme(theme as ThemeJson, mode()) + } + } + setCustomThemes(customThemes) + }) + + const allThemes = createMemo(() => { + return { ...THEMES, ...customThemes() } + }) + + const selectedThemeDef = createMemo(() => { + const selected = theme() + return customThemes()?.[selected] ?? resolveTheme(THEMES[theme()] ?? THEMES.opencode, mode()) }) const syntax = createMemo(() => { @@ -148,74 +193,74 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ { scope: ["prompt"], style: { - foreground: values().accent, + foreground: selectedThemeDef().accent, }, }, { scope: ["extmark.file"], style: { - foreground: values().warning, + foreground: selectedThemeDef().warning, bold: true, }, }, { scope: ["extmark.agent"], style: { - foreground: values().secondary, + foreground: selectedThemeDef().secondary, bold: true, }, }, { scope: ["extmark.paste"], style: { - foreground: values().background, - background: values().warning, + foreground: selectedThemeDef().background, + background: selectedThemeDef().warning, bold: true, }, }, { scope: ["comment"], style: { - foreground: values().syntaxComment, + foreground: selectedThemeDef().syntaxComment, italic: true, }, }, { scope: ["comment.documentation"], style: { - foreground: values().syntaxComment, + foreground: selectedThemeDef().syntaxComment, italic: true, }, }, { scope: ["string", "symbol"], style: { - foreground: values().syntaxString, + foreground: selectedThemeDef().syntaxString, }, }, { scope: ["number", "boolean"], style: { - foreground: values().syntaxNumber, + foreground: selectedThemeDef().syntaxNumber, }, }, { scope: ["character.special"], style: { - foreground: values().syntaxString, + foreground: selectedThemeDef().syntaxString, }, }, { scope: ["keyword.return", "keyword.conditional", "keyword.repeat", "keyword.coroutine"], style: { - foreground: values().syntaxKeyword, + foreground: selectedThemeDef().syntaxKeyword, italic: true, }, }, { scope: ["keyword.type"], style: { - foreground: values().syntaxType, + foreground: selectedThemeDef().syntaxType, bold: true, italic: true, }, @@ -223,80 +268,80 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ { scope: ["keyword.function", "function.method"], style: { - foreground: values().syntaxFunction, + foreground: selectedThemeDef().syntaxFunction, }, }, { scope: ["keyword"], style: { - foreground: values().syntaxKeyword, + foreground: selectedThemeDef().syntaxKeyword, italic: true, }, }, { scope: ["keyword.import"], style: { - foreground: values().syntaxKeyword, + foreground: selectedThemeDef().syntaxKeyword, }, }, { scope: ["operator", "keyword.operator", "punctuation.delimiter"], style: { - foreground: values().syntaxOperator, + foreground: selectedThemeDef().syntaxOperator, }, }, { scope: ["keyword.conditional.ternary"], style: { - foreground: values().syntaxOperator, + foreground: selectedThemeDef().syntaxOperator, }, }, { scope: ["variable", "variable.parameter", "function.method.call", "function.call"], style: { - foreground: values().syntaxVariable, + foreground: selectedThemeDef().syntaxVariable, }, }, { scope: ["variable.member", "function", "constructor"], style: { - foreground: values().syntaxFunction, + foreground: selectedThemeDef().syntaxFunction, }, }, { scope: ["type", "module"], style: { - foreground: values().syntaxType, + foreground: selectedThemeDef().syntaxType, }, }, { scope: ["constant"], style: { - foreground: values().syntaxNumber, + foreground: selectedThemeDef().syntaxNumber, }, }, { scope: ["property"], style: { - foreground: values().syntaxVariable, + foreground: selectedThemeDef().syntaxVariable, }, }, { scope: ["class"], style: { - foreground: values().syntaxType, + foreground: selectedThemeDef().syntaxType, }, }, { scope: ["parameter"], style: { - foreground: values().syntaxVariable, + foreground: selectedThemeDef().syntaxVariable, }, }, { scope: ["punctuation", "punctuation.bracket"], style: { - foreground: values().syntaxPunctuation, + foreground: selectedThemeDef().syntaxPunctuation, }, }, { @@ -308,45 +353,45 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ "constant.builtin", ], style: { - foreground: values().error, + foreground: selectedThemeDef().error, }, }, { scope: ["variable.super"], style: { - foreground: values().error, + foreground: selectedThemeDef().error, }, }, { scope: ["string.escape", "string.regexp"], style: { - foreground: values().syntaxKeyword, + foreground: selectedThemeDef().syntaxKeyword, }, }, { scope: ["keyword.directive"], style: { - foreground: values().syntaxKeyword, + foreground: selectedThemeDef().syntaxKeyword, italic: true, }, }, { scope: ["punctuation.special"], style: { - foreground: values().syntaxOperator, + foreground: selectedThemeDef().syntaxOperator, }, }, { scope: ["keyword.modifier"], style: { - foreground: values().syntaxKeyword, + foreground: selectedThemeDef().syntaxKeyword, italic: true, }, }, { scope: ["keyword.exception"], style: { - foreground: values().syntaxKeyword, + foreground: selectedThemeDef().syntaxKeyword, italic: true, }, }, @@ -354,155 +399,155 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ { scope: ["markup.heading"], style: { - foreground: values().markdownHeading, + foreground: selectedThemeDef().markdownHeading, bold: true, }, }, { scope: ["markup.heading.1"], style: { - foreground: values().markdownHeading, + foreground: selectedThemeDef().markdownHeading, bold: true, }, }, { scope: ["markup.heading.2"], style: { - foreground: values().markdownHeading, + foreground: selectedThemeDef().markdownHeading, bold: true, }, }, { scope: ["markup.heading.3"], style: { - foreground: values().markdownHeading, + foreground: selectedThemeDef().markdownHeading, bold: true, }, }, { scope: ["markup.heading.4"], style: { - foreground: values().markdownHeading, + foreground: selectedThemeDef().markdownHeading, bold: true, }, }, { scope: ["markup.heading.5"], style: { - foreground: values().markdownHeading, + foreground: selectedThemeDef().markdownHeading, bold: true, }, }, { scope: ["markup.heading.6"], style: { - foreground: values().markdownHeading, + foreground: selectedThemeDef().markdownHeading, bold: true, }, }, { scope: ["markup.bold", "markup.strong"], style: { - foreground: values().markdownStrong, + foreground: selectedThemeDef().markdownStrong, bold: true, }, }, { scope: ["markup.italic"], style: { - foreground: values().markdownEmph, + foreground: selectedThemeDef().markdownEmph, italic: true, }, }, { scope: ["markup.list"], style: { - foreground: values().markdownListItem, + foreground: selectedThemeDef().markdownListItem, }, }, { scope: ["markup.quote"], style: { - foreground: values().markdownBlockQuote, + foreground: selectedThemeDef().markdownBlockQuote, italic: true, }, }, { scope: ["markup.raw", "markup.raw.block"], style: { - foreground: values().markdownCode, + foreground: selectedThemeDef().markdownCode, }, }, { scope: ["markup.raw.inline"], style: { - foreground: values().markdownCode, - background: values().background, + foreground: selectedThemeDef().markdownCode, + background: selectedThemeDef().background, }, }, { scope: ["markup.link"], style: { - foreground: values().markdownLink, + foreground: selectedThemeDef().markdownLink, underline: true, }, }, { scope: ["markup.link.label"], style: { - foreground: values().markdownLinkText, + foreground: selectedThemeDef().markdownLinkText, underline: true, }, }, { scope: ["markup.link.url"], style: { - foreground: values().markdownLink, + foreground: selectedThemeDef().markdownLink, underline: true, }, }, { scope: ["label"], style: { - foreground: values().markdownLinkText, + foreground: selectedThemeDef().markdownLinkText, }, }, { scope: ["spell", "nospell"], style: { - foreground: values().text, + foreground: selectedThemeDef().text, }, }, { scope: ["conceal"], style: { - foreground: values().textMuted, + foreground: selectedThemeDef().textMuted, }, }, // Additional common highlight groups { scope: ["string.special", "string.special.url"], style: { - foreground: values().markdownLink, + foreground: selectedThemeDef().markdownLink, underline: true, }, }, { scope: ["character"], style: { - foreground: values().syntaxString, + foreground: selectedThemeDef().syntaxString, }, }, { scope: ["float"], style: { - foreground: values().syntaxNumber, + foreground: selectedThemeDef().syntaxNumber, }, }, { scope: ["comment.error"], style: { - foreground: values().error, + foreground: selectedThemeDef().error, italic: true, bold: true, }, @@ -510,7 +555,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ { scope: ["comment.warning"], style: { - foreground: values().warning, + foreground: selectedThemeDef().warning, italic: true, bold: true, }, @@ -518,7 +563,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ { scope: ["comment.todo", "comment.note"], style: { - foreground: values().info, + foreground: selectedThemeDef().info, italic: true, bold: true, }, @@ -526,129 +571,128 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ { scope: ["namespace"], style: { - foreground: values().syntaxType, + foreground: selectedThemeDef().syntaxType, }, }, { scope: ["field"], style: { - foreground: values().syntaxVariable, + foreground: selectedThemeDef().syntaxVariable, }, }, { scope: ["type.definition"], style: { - foreground: values().syntaxType, + foreground: selectedThemeDef().syntaxType, bold: true, }, }, { scope: ["keyword.export"], style: { - foreground: values().syntaxKeyword, + foreground: selectedThemeDef().syntaxKeyword, }, }, { scope: ["attribute", "annotation"], style: { - foreground: values().warning, + foreground: selectedThemeDef().warning, }, }, { scope: ["tag"], style: { - foreground: values().error, + foreground: selectedThemeDef().error, }, }, { scope: ["tag.attribute"], style: { - foreground: values().syntaxKeyword, + foreground: selectedThemeDef().syntaxKeyword, }, }, { scope: ["tag.delimiter"], style: { - foreground: values().syntaxOperator, + foreground: selectedThemeDef().syntaxOperator, }, }, { scope: ["markup.strikethrough"], style: { - foreground: values().textMuted, + foreground: selectedThemeDef().textMuted, }, }, { scope: ["markup.underline"], style: { - foreground: values().text, + foreground: selectedThemeDef().text, underline: true, }, }, { scope: ["markup.list.checked"], style: { - foreground: values().success, + foreground: selectedThemeDef().success, }, }, { scope: ["markup.list.unchecked"], style: { - foreground: values().textMuted, + foreground: selectedThemeDef().textMuted, }, }, { scope: ["diff.plus"], style: { - foreground: values().diffAdded, + foreground: selectedThemeDef().diffAdded, }, }, { scope: ["diff.minus"], style: { - foreground: values().diffRemoved, + foreground: selectedThemeDef().diffRemoved, }, }, { scope: ["diff.delta"], style: { - foreground: values().diffContext, + foreground: selectedThemeDef().diffContext, }, }, { scope: ["error"], style: { - foreground: values().error, + foreground: selectedThemeDef().error, bold: true, }, }, { scope: ["warning"], style: { - foreground: values().warning, + foreground: selectedThemeDef().warning, bold: true, }, }, { scope: ["info"], style: { - foreground: values().info, + foreground: selectedThemeDef().info, }, }, { scope: ["debug"], style: { - foreground: values().textMuted, + foreground: selectedThemeDef().textMuted, }, }, ]) }) return { - theme: new Proxy(values(), { + theme: new Proxy(selectedThemeDef(), { get(_target, prop) { - // @ts-expect-error - return values()[prop] + return selectedThemeDef()[prop as keyof Theme] }, }), get selected() { @@ -660,13 +704,16 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ setMode(mode) }, set(theme: string) { - if (!THEMES[theme]) return + if (!this.all[theme]) return setTheme(theme) kv.set("theme", theme) }, get ready() { return sync.ready }, + get all() { + return allThemes() + } } }, })