diff --git a/bun.lock b/bun.lock index c08a107af9..0898a51c83 100644 --- a/bun.lock +++ b/bun.lock @@ -600,6 +600,7 @@ "zod": "catalog:", }, "devDependencies": { + "@opencode-ai/theme": "workspace:*", "@opentui/core": "catalog:", "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", @@ -611,12 +612,14 @@ "typescript": "catalog:", }, "peerDependencies": { + "@opencode-ai/theme": "workspace:*", "@opentui/core": ">=0.4.5", "@opentui/keymap": ">=0.4.5", "@opentui/solid": ">=0.4.5", "solid-js": ">=1.9.0", }, "optionalPeers": [ + "@opencode-ai/theme", "@opentui/core", "@opentui/keymap", "@opentui/solid", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 8f1ec0da3e..21b61cd418 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -30,12 +30,16 @@ "zod": "catalog:" }, "peerDependencies": { + "@opencode-ai/theme": "workspace:*", "@opentui/core": ">=0.4.5", "@opentui/keymap": ">=0.4.5", "@opentui/solid": ">=0.4.5", "solid-js": ">=1.9.0" }, "peerDependenciesMeta": { + "@opencode-ai/theme": { + "optional": true + }, "@opentui/core": { "optional": true }, @@ -50,6 +54,7 @@ } }, "devDependencies": { + "@opencode-ai/theme": "workspace:*", "@opentui/core": "catalog:", "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", diff --git a/packages/plugin/src/tui/context.ts b/packages/plugin/src/tui/context.ts index 05e66ba163..2bc5d393ca 100644 --- a/packages/plugin/src/tui/context.ts +++ b/packages/plugin/src/tui/context.ts @@ -19,6 +19,7 @@ import type { ShellInfo, SkillInfo, } from "@opencode-ai/client" +import type { ResolvedTheme } from "@opencode-ai/theme/tui" import type { CliRenderer, KeyEvent, Renderable } from "@opentui/core" import type { JSX } from "@opentui/solid" import type { Store } from "solid-js/store" @@ -356,7 +357,7 @@ export interface Context { readonly client: OpenCodeClient readonly data: Data readonly attention: Attention - readonly theme: any + readonly theme: ResolvedTheme readonly keymap: Keymap readonly storage: Storage readonly ui: UI diff --git a/packages/theme/src/tui/index.ts b/packages/theme/src/tui/index.ts index 0ad7eab5ce..4bd7a99147 100644 --- a/packages/theme/src/tui/index.ts +++ b/packages/theme/src/tui/index.ts @@ -33,6 +33,7 @@ export { export type { Categorical, + ContextName, FormfieldColor, Hue, HueSource, @@ -40,7 +41,7 @@ export type { ResolvedActionState, ResolvedFormfieldState, ResolvedTheme, - ResolvedThemeView, + ResolvedThemeTokens, StatefulColor, } from "./types.js" export { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js" diff --git a/packages/theme/src/tui/resolve.ts b/packages/theme/src/tui/resolve.ts index 4443e88e5d..67246a962e 100644 --- a/packages/theme/src/tui/resolve.ts +++ b/packages/theme/src/tui/resolve.ts @@ -15,11 +15,12 @@ import { } from "./schema.js" import type { ActionStateKey, + ContextName, HueDefinition, HueScale, ResolvedActionState, ResolvedTheme, - ResolvedThemeView, + ResolvedThemeTokens, StatefulColorDefinition, ThemeTokensDefinition, } from "./index.js" @@ -64,16 +65,17 @@ function resolveExpandedTheme(definition: ThemeDefinition): ResolvedTheme { const hueSteps = compileHueSteps(hue) const base = tokens(definition) const resolved = resolveView(base, hue, categorical, hueSteps) - const contexts = Object.fromEntries( - Object.entries(definition) - .filter(([key]) => key.startsWith("@context:")) - .map(([key, override]) => { - const contextual = contextualize(base, override as ThemeTokensDefinition) - return [key, resolveView(contextual, hue, categorical, hueSteps)] - }), - ) + const context = (name: ContextName) => { + const override = definition[`@context:${name}`] + if (!override) return resolved + return resolveView(contextualize(base, override), hue, categorical, hueSteps) + } + const contextual = { + elevated: context("elevated"), + overlay: context("overlay"), + } - return { ...resolved, contexts } as ResolvedTheme + return { ...resolved, contextual } as ResolvedTheme } function tokens(definition: ThemeDefinition): ThemeTokensDefinition { @@ -132,15 +134,15 @@ function contextualActions( function resolveView( definition: ThemeTokensDefinition, - hue: ResolvedThemeView["hue"], - categorical: ResolvedThemeView["categorical"], - hueSteps: Pick, -): ResolvedThemeView { + hue: ResolvedThemeTokens["hue"], + categorical: ResolvedThemeTokens["categorical"], + hueSteps: Pick, +): ResolvedThemeTokens { const source: Record = { hue, ...definition } - return { ...(createResolver(source)(source, "theme") as ResolvedThemeView), hue, categorical, ...hueSteps } + return { ...(createResolver(source)(source, "theme") as ResolvedThemeTokens), hue, categorical, ...hueSteps } } -function compileHueSteps(hue: ResolvedThemeView["hue"]): Pick { +function compileHueSteps(hue: ResolvedThemeTokens["hue"]): Pick { const index = new WeakMap() for (const [name, scale] of Object.entries(hue) as [keyof typeof hue, HueScale][]) { HueStep.literals.forEach((step, position) => index.set(scale[step], { hue: name, step, position })) @@ -201,7 +203,7 @@ function resolveHue(definition: HueDefinition) { return Object.fromEntries( [...BaseHue.literals, ...HueAlias.literals].map((name) => [name, resolve(name, [])]), - ) as ResolvedThemeView["hue"] + ) as ResolvedThemeTokens["hue"] } function createResolver(source: Record) { diff --git a/packages/theme/src/tui/syntax.ts b/packages/theme/src/tui/syntax.ts index 6330c9913a..d85840e132 100644 --- a/packages/theme/src/tui/syntax.ts +++ b/packages/theme/src/tui/syntax.ts @@ -1,7 +1,7 @@ import { SyntaxStyle, type RGBA, type ThemeTokenStyle } from "@opentui/core" -import type { Mode, ResolvedThemeView } from "./index.js" +import type { Mode, ResolvedThemeTokens } from "./index.js" -export function generateSyntax(theme: ResolvedThemeView, mode: Mode) { +export function generateSyntax(theme: ResolvedThemeTokens, mode: Mode) { const step = mode === "light" ? 800 : 200 const syntax = theme.syntax const markdown = theme.markdown diff --git a/packages/theme/src/tui/types.ts b/packages/theme/src/tui/types.ts index 66e9333a9a..ceb3c151c4 100644 --- a/packages/theme/src/tui/types.ts +++ b/packages/theme/src/tui/types.ts @@ -7,7 +7,6 @@ import type { HueAlias, HueStep, MarkdownToken, - ContextKey, SyntaxToken, } from "./schema.js" @@ -20,7 +19,7 @@ export type Categorical = readonly HueScale[] export type StatefulColor = Readonly> export type FormfieldColor = StatefulColor -export type ResolvedThemeView = { +export type ResolvedThemeTokens = { readonly hue: Hue readonly categorical: Categorical readonly source: (color: RGBA) => HueSource | undefined @@ -63,6 +62,8 @@ export type ResolvedThemeView = { readonly markdown: Readonly> } -export type ResolvedTheme = ResolvedThemeView & { - readonly contexts: Readonly>> +export type ContextName = "elevated" | "overlay" + +export type ResolvedTheme = ResolvedThemeTokens & { + readonly contextual: Readonly> } diff --git a/packages/tui/src/component/bg-pulse.tsx b/packages/tui/src/component/bg-pulse.tsx index b4635a2790..01afa4c0e0 100644 --- a/packages/tui/src/component/bg-pulse.tsx +++ b/packages/tui/src/component/bg-pulse.tsx @@ -7,7 +7,7 @@ import { } from "@opentui/core" import { extend, useRenderer } from "@opentui/solid" import { onCleanup, onMount } from "solid-js" -import { useThemes } from "../context/theme" +import { useTheme, useThemes } from "../context/theme" import { tint } from "../theme/color" import { GoUpsellArtPainter } from "./bg-pulse-render" @@ -71,7 +71,7 @@ extend({ go_upsell_art: GoUpsellArtRenderable }) export function BgPulse() { const themes = useThemes() - const theme = themes.contextual("elevated") + const theme = useTheme("elevated") const mode = themes.mode const renderer = useRenderer() let targetFps = renderer.targetFps diff --git a/packages/tui/src/component/devtools-bar.tsx b/packages/tui/src/component/devtools-bar.tsx index abd9525a77..f6c387971e 100644 --- a/packages/tui/src/component/devtools-bar.tsx +++ b/packages/tui/src/component/devtools-bar.tsx @@ -36,7 +36,7 @@ export function DevToolsBar() { const renderer = useRenderer() const dimensions = useTerminalDimensions() const { current: theme, mode, supports, setMode } = themes - const elevatedTheme = themes.contextual("elevated") + const elevatedTheme = useTheme("elevated") const [panel, setPanel] = createSignal() const [dumping, setDumping] = createSignal(false) const [dumpPath, setDumpPath] = createSignal() @@ -435,7 +435,7 @@ function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) { } function PanelBox(props: ParentProps) { - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const renderer = useRenderer() return ( {props.children} @@ -470,7 +470,7 @@ function PanelTitle(props: ParentProps) { } function Row(props: { label: string; value: string }) { - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") return ( {props.label} @@ -481,7 +481,7 @@ function Row(props: { label: string; value: string }) { } function Action(props: ParentProps<{ onClick: () => void; disabled?: boolean; hoverBackground?: boolean }>) { - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const [hovered, setHovered] = createSignal(false) return ( { const value = props.values.at(-1) if (value === undefined) return "--" diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index 5a32abe286..f07d6f27cb 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -11,7 +11,7 @@ import { useClipboard } from "../context/clipboard" import { useData } from "../context/data" import { useClient } from "../context/client" import { Keymap } from "../context/keymap" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { useDialog } from "../ui/dialog" import { DialogPrompt } from "../ui/dialog-prompt" import { DialogSelect } from "../ui/dialog-select" @@ -64,7 +64,7 @@ export function DialogIntegration( ) { const data = useData() const dialog = useDialog() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const options = createMemo(() => { const providers = data.location.websearch.list() ?? [] const providersByID = new Map(providers.map((provider) => [provider.id, provider])) @@ -303,8 +303,8 @@ function CommandPending(props: { function CommandView(props: { title: string; output: string; message: string }) { const dialog = useDialog() - const theme = useThemes().contextual("elevated") - const overlayTheme = useThemes().contextual("overlay") + const theme = useTheme("elevated") + const overlayTheme = useTheme("overlay") onMount(() => dialog.setSize("large")) return ( @@ -341,7 +341,7 @@ function KeyMethod(props: { const dialog = useDialog() const client = useClient() const toast = useToast() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const [error, setError] = createSignal() return ( @@ -516,7 +516,7 @@ function OAuthCode(props: { const dialog = useDialog() const client = useClient() const toast = useToast() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const [error, setError] = createSignal() let settled = false @@ -561,7 +561,7 @@ function OAuthCode(props: { function OAuthView(props: { title: string; url?: string; instructions?: string; message: string; copy?: boolean }) { const dialog = useDialog() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") return ( diff --git a/packages/tui/src/component/dialog-mcp.tsx b/packages/tui/src/component/dialog-mcp.tsx index 31fafd7ef8..3d3a0ab22e 100644 --- a/packages/tui/src/component/dialog-mcp.tsx +++ b/packages/tui/src/component/dialog-mcp.tsx @@ -5,7 +5,7 @@ import { Keymap } from "../context/keymap" import { pipe, sortBy } from "remeda" import { DialogSelect } from "../ui/dialog-select" import { useDialog } from "../ui/dialog" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core" import type { McpServer } from "@opencode-ai/client" import { useClipboard } from "../context/clipboard" @@ -20,7 +20,7 @@ function statusError(status: McpServer["status"]) { } function Status(props: { enabled: boolean; loading: boolean }) { - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") if (props.loading) return ⋯ Loading if (props.enabled) { return ✓ Enabled @@ -33,7 +33,7 @@ export function DialogMcp() { const dialog = useDialog() const client = useClient() const toast = useToast() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const [focused, setFocused] = createSignal() const [detail, setDetail] = createSignal() const [loading, setLoading] = createSignal(null) @@ -134,8 +134,8 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) { const dialog = useDialog() const clipboard = useClipboard() const toast = useToast() - const theme = useThemes().contextual("elevated") - const overlayTheme = useThemes().contextual("overlay") + const theme = useTheme("elevated") + const overlayTheme = useTheme("overlay") const dimensions = useTerminalDimensions() const config = useConfig().data const [copied, setCopied] = createSignal(false) diff --git a/packages/tui/src/component/dialog-move-session.tsx b/packages/tui/src/component/dialog-move-session.tsx index 3a1171acf8..0ca6a1168c 100644 --- a/packages/tui/src/component/dialog-move-session.tsx +++ b/packages/tui/src/component/dialog-move-session.tsx @@ -6,7 +6,7 @@ import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select" import { useDialog } from "../ui/dialog" import { useClient } from "../context/client" import { Keymap } from "../context/keymap" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { useData } from "../context/data" import { abbreviateHome } from "../runtime" import { useTuiPaths } from "../context/runtime" @@ -38,7 +38,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { const dialog = useDialog() const client = useClient() const dimensions = useTerminalDimensions() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const sessionData = useData() const route = useRoute() const toast = useToast() diff --git a/packages/tui/src/component/dialog-pair.tsx b/packages/tui/src/component/dialog-pair.tsx index 05565dd0df..70032b2e51 100644 --- a/packages/tui/src/component/dialog-pair.tsx +++ b/packages/tui/src/component/dialog-pair.tsx @@ -3,7 +3,7 @@ import { useTerminalDimensions } from "@opentui/solid" import { createMemo, createResource, createSignal, For, Show } from "solid-js" import { renderUnicodeCompact } from "uqr" import { useClient } from "../context/client" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { useDialog } from "../ui/dialog" import { errorMessage } from "../util/error" @@ -16,7 +16,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) { const client = useClient() const dialog = useDialog() const dimensions = useTerminalDimensions() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const [loadError, setLoadError] = createSignal() const [showPassword, setShowPassword] = createSignal(false) const [passwordHover, setPasswordHover] = createSignal(false) diff --git a/packages/tui/src/component/dialog-project-copy-name.tsx b/packages/tui/src/component/dialog-project-copy-name.tsx index d172b22456..1be66ab5bb 100644 --- a/packages/tui/src/component/dialog-project-copy-name.tsx +++ b/packages/tui/src/component/dialog-project-copy-name.tsx @@ -2,12 +2,12 @@ import { InputRenderable, TextAttributes } from "@opentui/core" import { Slug } from "@opencode-ai/core/util/slug" import { createSignal, onMount } from "solid-js" import { Keymap } from "../context/keymap" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "../ui/dialog" export function DialogProjectCopyName(props: { onConfirm: (name: string) => void }) { const dialog = useDialog() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const shortcuts = Keymap.useShortcuts() const [inputTarget, setInputTarget] = createSignal() let input: InputRenderable diff --git a/packages/tui/src/component/dialog-retry-action.tsx b/packages/tui/src/component/dialog-retry-action.tsx index b98ba9580c..65d7730ac4 100644 --- a/packages/tui/src/component/dialog-retry-action.tsx +++ b/packages/tui/src/component/dialog-retry-action.tsx @@ -2,7 +2,7 @@ import { RGBA, TextAttributes } from "@opentui/core" import open from "open" import { createSignal } from "solid-js" import { Keymap } from "../context/keymap" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "../ui/dialog" import { Link } from "../ui/link" import { BgPulse } from "./bg-pulse" @@ -38,7 +38,7 @@ function panelOverlay(color: RGBA) { export function DialogRetryAction(props: DialogRetryActionProps) { const dialog = useDialog() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const showGoTreatment = () => props.link === GO_URL const textBg = () => (showGoTreatment() ? panelOverlay(theme.background.default) : undefined) const [selected, setSelected] = createSignal<"dismiss" | "action">("action") diff --git a/packages/tui/src/component/dialog-session-delete-failed.tsx b/packages/tui/src/component/dialog-session-delete-failed.tsx index d11b113a7f..2d44512d9c 100644 --- a/packages/tui/src/component/dialog-session-delete-failed.tsx +++ b/packages/tui/src/component/dialog-session-delete-failed.tsx @@ -1,6 +1,6 @@ import { TextAttributes } from "@opentui/core" import { Keymap } from "../context/keymap" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { useDialog } from "../ui/dialog" import { createStore } from "solid-js/store" import { For } from "solid-js" @@ -13,7 +13,7 @@ export function DialogSessionDeleteFailed(props: { onDone?: () => void }) { const dialog = useDialog() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const [store, setStore] = createStore({ active: "delete" as "delete" | "restore", }) diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index 3f572ff39a..3b489607b1 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -7,7 +7,7 @@ import { useRoute } from "../context/route" import { useData } from "../context/data" import { Keymap } from "../context/keymap" import { Locale } from "../util/locale" -import { useThemes } from "../context/theme" +import { useTheme, useThemes } from "../context/theme" import { useClient } from "../context/client" import { useLocal } from "../context/local" import { createDebouncedSignal } from "../util/signal" @@ -22,7 +22,7 @@ export function DialogSessionList() { const route = useRoute() const data = useData() const themes = useThemes() - const theme = themes.contextual("elevated") + const theme = useTheme("elevated") const mode = themes.mode const client = useClient() const local = useLocal() diff --git a/packages/tui/src/component/dialog-stash.tsx b/packages/tui/src/component/dialog-stash.tsx index 90c2dc8e53..176bb71928 100644 --- a/packages/tui/src/component/dialog-stash.tsx +++ b/packages/tui/src/component/dialog-stash.tsx @@ -3,7 +3,7 @@ import { DialogSelect } from "../ui/dialog-select" import { createMemo, createSignal } from "solid-js" import { Locale } from "../util/locale" import { Keymap } from "../context/keymap" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { usePromptStash, type StashEntry } from "./prompt/stash" function getRelativeTime(timestamp: number): string { @@ -29,7 +29,7 @@ function getStashPreview(input: string, maxLength: number = 50): string { export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { const dialog = useDialog() const stash = usePromptStash() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const shortcuts = Keymap.useShortcuts() const [toDelete, setToDelete] = createSignal() diff --git a/packages/tui/src/component/dialog-status.tsx b/packages/tui/src/component/dialog-status.tsx index 2e9675c807..b848176958 100644 --- a/packages/tui/src/component/dialog-status.tsx +++ b/packages/tui/src/component/dialog-status.tsx @@ -1,5 +1,5 @@ import { TextAttributes } from "@opentui/core" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { useDialog } from "../ui/dialog" import { useData } from "../context/data" import { For, Match, Switch, Show, createMemo } from "solid-js" @@ -8,7 +8,7 @@ export type DialogStatusProps = {} export function DialogStatus() { const data = useData() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const dialog = useDialog() const mcp = createMemo(() => data.location.mcp.server.list() ?? []) diff --git a/packages/tui/src/component/dialog-workspace-file-changes.tsx b/packages/tui/src/component/dialog-workspace-file-changes.tsx index d349923e05..89eb3c75d0 100644 --- a/packages/tui/src/component/dialog-workspace-file-changes.tsx +++ b/packages/tui/src/component/dialog-workspace-file-changes.tsx @@ -4,7 +4,7 @@ import type { VcsFileStatus } from "@opencode-ai/client" import { createMemo, For } from "solid-js" import { createStore } from "solid-js/store" import { FilePath } from "../ui/file-path" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { useConfig } from "../config" import { useDialog, type DialogContext } from "../ui/dialog" import { getScrollAcceleration } from "../util/scroll" @@ -31,8 +31,8 @@ export function DialogWorkspaceFileChanges(props: { message?: string }) { const dialog = useDialog() - const theme = useThemes().contextual("elevated") - const overlayTheme = useThemes().contextual("overlay") + const theme = useTheme("elevated") + const overlayTheme = useTheme("overlay") const config = useConfig().data const dimensions = useTerminalDimensions() const scrollAcceleration = createMemo(() => getScrollAcceleration(config)) diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 10c1f037dd..8912a052b3 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -12,7 +12,7 @@ import { getScrollAcceleration } from "../../util/scroll" import { useTuiPaths } from "../../context/runtime" import { useConfig } from "../../config" import { useLocation } from "../../context/location" -import { useThemes } from "../../context/theme" +import { useTheme } from "../../context/theme" import { SplitBorder } from "../../ui/border" import { useTerminalDimensions } from "@opentui/solid" import { Locale } from "../../util/locale" @@ -57,7 +57,7 @@ export function Autocomplete(props: { const data = useData() const keymap = Keymap.use() const keymapCommands = Keymap.useCommands() - const theme = useThemes().contextual("overlay") + const theme = useTheme("overlay") const dimensions = useTerminalDimensions() const frecency = useFrecency() const config = useConfig().data diff --git a/packages/tui/src/component/reconnecting.tsx b/packages/tui/src/component/reconnecting.tsx index a61e319bd2..dca869569d 100644 --- a/packages/tui/src/component/reconnecting.tsx +++ b/packages/tui/src/component/reconnecting.tsx @@ -1,9 +1,9 @@ import { RGBA } from "@opentui/core" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { Spinner } from "./spinner" export function Reconnecting() { - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") return ( boolean }) { - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const [show, setShow] = createSignal(false) const text = createMemo(() => (props.ready() ? "Finishing startup..." : "Loading plugins...")) let wait: NodeJS.Timeout | undefined diff --git a/packages/tui/src/context/theme.tsx b/packages/tui/src/context/theme.tsx index a84826f36a..4a13c9eda2 100644 --- a/packages/tui/src/context/theme.tsx +++ b/packages/tui/src/context/theme.tsx @@ -1,6 +1,12 @@ import { CliRenderEvents, SyntaxStyle, type TerminalColors } from "@opentui/core" import { useRenderer } from "@opentui/solid" -import { generateSyntax, resolveThemeDocument, themeModes } from "@opencode-ai/theme/tui" +import { + generateSyntax, + resolveThemeDocument, + themeModes, + type ResolvedTheme, + type ContextName, +} from "@opencode-ai/theme/tui" import { DEFAULT_THEMES, addTheme, @@ -95,10 +101,9 @@ type State = { ready: boolean } -type ContextName = "elevated" | "overlay" type Themes = { current: ComponentTheme - contextual(context: ContextName): ComponentTheme + currentTokens: Accessor readonly selected: string all: typeof allThemes has: typeof hasTheme @@ -115,6 +120,12 @@ type Themes = { readonly ready: boolean } +type ThemeContextValue = { + current: ComponentTheme["contextual"][ContextName] + themes: Themes + readonly ready: boolean +} + const [store, setStore] = createStore({ themes: allThemes(), mode: "dark", @@ -127,7 +138,7 @@ subscribeThemes((themes) => setStore("themes", themes)) const themeContext = createSimpleContext({ name: "Theme", - init: (props: { mode: "dark" | "light"; source?: ThemeSource }) => { + init: (props: { mode: "dark" | "light"; source?: ThemeSource }): ThemeContextValue => { const renderer = useRenderer() const configState = useConfig() const config = configState.data @@ -309,21 +320,14 @@ const themeContext = createSimpleContext({ valuesV2() themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`) const current = createComponentTheme(valuesV2, mode) - const contextsV2 = { - elevated: createComponentTheme(() => valuesV2().contexts["@context:elevated"] ?? valuesV2(), mode), - overlay: createComponentTheme(() => valuesV2().contexts["@context:overlay"] ?? valuesV2(), mode), - } createEffect(() => renderer.setBackgroundColor(valuesV2().background.default)) const currentSyntax = createSyntaxStyleMemo(() => generateSyntax(valuesV2(), mode())) - function contextual(context: ContextName) { - return contextsV2[context] - } const service: Themes = { current, + currentTokens: valuesV2, currentSyntax, - contextual, get selected() { return store.active }, @@ -368,15 +372,20 @@ const themeContext = createSimpleContext({ export function useThemes() { return themeContext.use().themes } -export function useTheme() { - return themeContext.use().current +export function useTheme(): ComponentTheme +export function useTheme(context: ContextName): ComponentTheme["contextual"][ContextName] +export function useTheme(context?: ContextName) { + const value = themeContext.use() + return context ? value.themes.current.contextual[context] : value.current } export const ThemeProvider = themeContext.provider export function ThemeContextProvider(props: ParentProps<{ context: ContextName }>) { - const themes = useThemes() + const value = themeContext.use() return ( - + {props.children} ) diff --git a/packages/tui/src/feature-plugins/system/diff-viewer.tsx b/packages/tui/src/feature-plugins/system/diff-viewer.tsx index 51055b6c10..279808d184 100644 --- a/packages/tui/src/feature-plugins/system/diff-viewer.tsx +++ b/packages/tui/src/feature-plugins/system/diff-viewer.tsx @@ -18,6 +18,7 @@ import { Panel, PanelGroup, Separator } from "./diff-viewer-ui" import { DialogSelect } from "../../ui/dialog-select" import { getScrollAcceleration } from "../../util/scroll" import { useConfig } from "../../config" +import { useThemes } from "../../context/theme" import { allExpandedFileTreeDirectories, buildFileTree, @@ -83,6 +84,7 @@ function DiffViewer(props: { context: Plugin.Context }) { const config = useConfig() const dialog = props.context.ui.dialog const theme = props.context.theme + const currentSyntax = useThemes().currentSyntax const params = () => { const route = props.context.ui.router.current() return (route.type === "plugin" ? route.data : undefined) as @@ -834,7 +836,7 @@ function DiffViewer(props: { context: Plugin.Context }) { diff={patch()} view={view()} filetype={reviewed() ? PLAIN_TEXT_FILETYPE : filetype(entry.file.file)} - syntaxStyle={theme.syntaxStyle()} + syntaxStyle={currentSyntax()} showLineNumbers={true} width="100%" wrapMode="char" @@ -941,7 +943,7 @@ function DiffViewer(props: { context: Plugin.Context }) { } function DiffViewerHelpDialog(props: { context: Plugin.Context }) { - const theme = props.context.theme.contextual("elevated") + const theme = props.context.theme.contextual.elevated const shortcut = (id: string) => () => props.context.keymap.shortcuts(id)[0] const rows = [ { diff --git a/packages/tui/src/feature-plugins/system/scrap.tsx b/packages/tui/src/feature-plugins/system/scrap.tsx index 9305845c49..78a483d04c 100644 --- a/packages/tui/src/feature-plugins/system/scrap.tsx +++ b/packages/tui/src/feature-plugins/system/scrap.tsx @@ -44,7 +44,7 @@ function Commands(props: { context: Plugin.Context }) { function Scrap(props: { context: Plugin.Context }) { const dimensions = useTerminalDimensions() const theme = props.context.theme - const elevatedTheme = props.context.theme.contextual("elevated") + const elevatedTheme = theme.contextual.elevated const [tabs, setTabs] = createSignal(FIXTURE_TABS.slice(0, 6)) const [active, setActive] = createSignal("fixture-2") const [animations, setAnimations] = createSignal(true) diff --git a/packages/tui/src/plugin/context.tsx b/packages/tui/src/plugin/context.tsx index a6d8f1f4cc..6dc8df9729 100644 --- a/packages/tui/src/plugin/context.tsx +++ b/packages/tui/src/plugin/context.tsx @@ -24,7 +24,7 @@ import { Keymap } from "../context/keymap" import { useRoute } from "../context/route" import { useTuiApp, useTuiLifecycle, useTuiPaths } from "../context/runtime" import { useLocation } from "../context/location" -import { useTheme, useThemes } from "../context/theme" +import { useThemes } from "../context/theme" import { DialogAlert } from "../ui/dialog-alert" import { DialogConfirm } from "../ui/dialog-confirm" import { DialogPrompt } from "../ui/dialog-prompt" @@ -91,9 +91,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }> const app = useTuiApp() const paths = useTuiPaths() const location = useLocation() - const theme = useTheme() const themes = useThemes() - const pluginTheme = createPluginTheme(theme, themes) const dialog = useDialog() const toast = useToast() const attention = useAttention() @@ -226,7 +224,9 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }> client: client.api, data, attention, - theme: pluginTheme, + get theme() { + return themes.currentTokens() + }, keymap: { layer: Keymap.createLayer, dispatch: keymap.dispatch, @@ -530,24 +530,6 @@ function isPlugin(value: unknown): value is Plugin.Definition { ) } -type PluginTheme = ReturnType & { - contextual(context: "elevated" | "overlay"): PluginTheme - syntaxStyle(): ReturnType["currentSyntax"]> -} - -export function createPluginTheme(theme: ReturnType, themes: ReturnType): PluginTheme { - return new Proxy(theme as PluginTheme, { - get(target, property, receiver) { - if (property === "contextual") { - return (context: "elevated" | "overlay") => createPluginTheme(themes.contextual(context), themes) - } - if (property === "syntaxStyle") return themes.currentSyntax - if (Reflect.has(target, property)) return Reflect.get(target, property, receiver) - return Reflect.get(themes, property, themes) - }, - }) -} - export function usePlugin() { const value = useContext(PluginContext) if (!value) throw new Error("PluginProvider is missing") diff --git a/packages/tui/src/routes/session/composer/index.tsx b/packages/tui/src/routes/session/composer/index.tsx index d29e206be4..08d3d22473 100644 --- a/packages/tui/src/routes/session/composer/index.tsx +++ b/packages/tui/src/routes/session/composer/index.tsx @@ -1,7 +1,7 @@ import { createEffect, createMemo, For, onCleanup, Show, useContext, createContext } from "solid-js" import { createStore } from "solid-js/store" import { TextAttributes } from "@opentui/core" -import { useThemes } from "../../../context/theme" +import { useTheme } from "../../../context/theme" import { SplitBorder } from "../../../ui/border" import { Keymap } from "../../../context/keymap" import { SubagentsTab } from "./subagents-tab" @@ -39,7 +39,7 @@ export type ComposerProps = { } export function Composer(props: ComposerProps) { - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const [store, setStore] = createStore({ tabs: {} as Record, diff --git a/packages/tui/src/routes/session/form.tsx b/packages/tui/src/routes/session/form.tsx index 976fca4824..8ea3c6f8c9 100644 --- a/packages/tui/src/routes/session/form.tsx +++ b/packages/tui/src/routes/session/form.tsx @@ -3,7 +3,7 @@ import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-j import { useRenderer, useTerminalDimensions } from "@opentui/solid" import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core" import open from "open" -import { useThemes } from "../../context/theme" +import { useTheme, useThemes } from "../../context/theme" import type { FormField, FormValue } from "@opencode-ai/client" import type { FormWithLocation } from "../../context/data" import { useClient } from "../../context/client" @@ -45,7 +45,7 @@ function requestOptions(form: FormWithLocation) { export function FormPrompt(props: { form: FormWithLocation }) { const client = useClient() const themes = useThemes() - const theme = themes.contextual("elevated") + const theme = useTheme("elevated") const themeMode = themes.mode const renderer = useRenderer() const dimensions = useTerminalDimensions() diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index fedda0d98c..0f3ade0541 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1494,7 +1494,7 @@ function SessionGroupView(props: { function AssistantFooter(props: { message: SessionMessageAssistant }) { const ctx = use() const local = useLocal() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const model = createMemo( () => ctx @@ -1691,7 +1691,7 @@ function RevertMessage(props: { }> }) { const ctx = use() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const route = useRouteData("session") const client = useClient() const toast = useToast() @@ -1764,7 +1764,7 @@ function RevertMessage(props: { } function ShellMessage(props: { message: Extract }) { - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const output = createMemo(() => stripAnsi(props.message.output?.output.trim() ?? "")) return ( @@ -1792,7 +1792,7 @@ function UserMessage(props: { message: SessionMessageUser }) { const local = useLocal() const files = createMemo(() => props.message.files ?? []) const themes = useThemes() - const theme = themes.contextual("elevated") + const theme = useTheme("elevated") const mode = themes.mode const [hover, setHover] = createSignal(false) const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build")) @@ -1869,7 +1869,7 @@ function UserMessage(props: { message: SessionMessageUser }) { function AssistantMessage(props: { message: SessionMessageAssistant; last: boolean }) { const ctx = use() const local = useLocal() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const model = createMemo( () => ctx diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index 663c4edf01..5cd7966f8a 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -297,7 +297,7 @@ function RejectPrompt(props: { onCancel: () => void }) { let input: TextareaRenderable - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const dimensions = useTerminalDimensions() const narrow = createMemo(() => dimensions().width < 80) Keymap.createLayer(() => ({ @@ -429,7 +429,7 @@ function Prompt>(props: { fullscreen?: boolean onSelect: (option: keyof T) => void }) { - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const dimensions = useTerminalDimensions() const keys = Object.keys(props.options) as (keyof T)[] const [store, setStore] = createStore({ diff --git a/packages/tui/src/routes/session/sidebar.tsx b/packages/tui/src/routes/session/sidebar.tsx index 44c0130f6c..3de0335463 100644 --- a/packages/tui/src/routes/session/sidebar.tsx +++ b/packages/tui/src/routes/session/sidebar.tsx @@ -1,6 +1,6 @@ import { useData } from "../../context/data" import { createMemo, Show } from "solid-js" -import { useThemes } from "../../context/theme" +import { useTheme } from "../../context/theme" import { useConfig } from "../../config" import { PluginSlot } from "../../plugin/context" @@ -8,7 +8,7 @@ import { getScrollAcceleration } from "../../util/scroll" export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const data = useData() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const config = useConfig().data const session = createMemo(() => data.session.get(props.sessionID)) const scrollAcceleration = createMemo(() => getScrollAcceleration(config)) diff --git a/packages/tui/src/routes/session/subagent-footer.tsx b/packages/tui/src/routes/session/subagent-footer.tsx index a0ba4971f4..9a9722db7f 100644 --- a/packages/tui/src/routes/session/subagent-footer.tsx +++ b/packages/tui/src/routes/session/subagent-footer.tsx @@ -1,7 +1,7 @@ import { createMemo, createSignal, Show } from "solid-js" import { useRouteData } from "../../context/route" import { useData } from "../../context/data" -import { useThemes } from "../../context/theme" +import { useTheme } from "../../context/theme" import { SplitBorder } from "../../ui/border" import { Locale } from "../../util/locale" import { useTerminalDimensions } from "@opentui/solid" @@ -42,7 +42,7 @@ export function SubagentFooter() { } }) - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const keymap = Keymap.use() const shortcuts = Keymap.useShortcuts() const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null) diff --git a/packages/tui/src/theme/component.ts b/packages/tui/src/theme/component.ts index 28e52658d7..8e9cdffcb3 100644 --- a/packages/tui/src/theme/component.ts +++ b/packages/tui/src/theme/component.ts @@ -1,41 +1,48 @@ import type { RGBA } from "@opentui/core" import type { Accessor } from "solid-js" -import type { Mode, ResolvedThemeView } from "@opencode-ai/theme/tui" +import type { Mode, ResolvedTheme, ResolvedThemeTokens } from "@opencode-ai/theme/tui" -export function createComponentTheme(current: Accessor, mode: Accessor) { - return { +export function createComponentTheme(current: Accessor, mode: Accessor) { + const create = (view: Accessor) => ({ get hue() { - return current().hue + return view().hue }, get categorical() { - return current().categorical + return view().categorical }, get text() { - return current().text + return view().text }, get background() { - return current().background + return view().background }, get border() { - return current().border + return view().border }, get scrollbar() { - return current().scrollbar + return view().scrollbar }, get diff() { - return current().diff + return view().diff }, get syntax() { - return current().syntax + return view().syntax }, get markdown() { - return current().markdown + return view().markdown }, - source: (color: RGBA) => current().source(color), - increase: (color: RGBA, amount = 1) => current().increase(color, amount), - decrease: (color: RGBA, amount = 1) => current().decrease(color, amount), - raise: (color: RGBA) => (mode() === "light" ? current().increase(color) : current().decrease(color)), - } + source: (color: RGBA) => view().source(color), + increase: (color: RGBA, amount = 1) => view().increase(color, amount), + decrease: (color: RGBA, amount = 1) => view().decrease(color, amount), + raise: (color: RGBA) => (mode() === "light" ? view().increase(color) : view().decrease(color)), + }) + + return Object.assign(create(current), { + contextual: { + elevated: create(() => current().contextual.elevated), + overlay: create(() => current().contextual.overlay), + }, + }) } export type ComponentTheme = ReturnType diff --git a/packages/tui/src/ui/dialog-alert.tsx b/packages/tui/src/ui/dialog-alert.tsx index f7c231a07f..718ec0c4d5 100644 --- a/packages/tui/src/ui/dialog-alert.tsx +++ b/packages/tui/src/ui/dialog-alert.tsx @@ -1,6 +1,6 @@ import { TextAttributes } from "@opentui/core" import { Keymap } from "../context/keymap" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" export type DialogAlertProps = { @@ -11,7 +11,7 @@ export type DialogAlertProps = { export function DialogAlert(props: DialogAlertProps) { const dialog = useDialog() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") Keymap.createLayer(() => ({ mode: "modal", diff --git a/packages/tui/src/ui/dialog-confirm.tsx b/packages/tui/src/ui/dialog-confirm.tsx index 5adbb0333e..35cb757d23 100644 --- a/packages/tui/src/ui/dialog-confirm.tsx +++ b/packages/tui/src/ui/dialog-confirm.tsx @@ -1,6 +1,6 @@ import { TextAttributes } from "@opentui/core" import { Keymap } from "../context/keymap" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { createStore } from "solid-js/store" import { For } from "solid-js" @@ -21,7 +21,7 @@ export type DialogConfirmResult = boolean | undefined export function DialogConfirm(props: DialogConfirmProps) { const dialog = useDialog() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const [store, setStore] = createStore({ active: "confirm" as "confirm" | "cancel", }) diff --git a/packages/tui/src/ui/dialog-export-options.tsx b/packages/tui/src/ui/dialog-export-options.tsx index dc9049a525..9953d06fad 100644 --- a/packages/tui/src/ui/dialog-export-options.tsx +++ b/packages/tui/src/ui/dialog-export-options.tsx @@ -1,6 +1,6 @@ import { TextAttributes } from "@opentui/core" import { Keymap } from "../context/keymap" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { createStore } from "solid-js/store" import { For, Show } from "solid-js" @@ -17,8 +17,8 @@ type Active = ExportFormat | "thinking" | "copy" | "export" export function DialogExportOptions(props: DialogExportOptionsProps) { const dialog = useDialog() - const theme = useThemes().contextual("elevated") - const overlayTheme = useThemes().contextual("overlay") + const theme = useTheme("elevated") + const overlayTheme = useTheme("overlay") const [store, setStore] = createStore({ format: "markdown" as ExportFormat, thinking: props.defaultThinking, diff --git a/packages/tui/src/ui/dialog-export-result.tsx b/packages/tui/src/ui/dialog-export-result.tsx index c43b7ef2e1..9ae5d5cc1c 100644 --- a/packages/tui/src/ui/dialog-export-result.tsx +++ b/packages/tui/src/ui/dialog-export-result.tsx @@ -1,11 +1,11 @@ import { TextAttributes } from "@opentui/core" import { Keymap } from "../context/keymap" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" export function DialogExportResult(props: { path: string; onClose?: () => void }) { const dialog = useDialog() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const close = () => { props.onClose?.() diff --git a/packages/tui/src/ui/dialog-help.tsx b/packages/tui/src/ui/dialog-help.tsx index 9217d71f6a..ebf05b5e5a 100644 --- a/packages/tui/src/ui/dialog-help.tsx +++ b/packages/tui/src/ui/dialog-help.tsx @@ -1,11 +1,11 @@ import { TextAttributes } from "@opentui/core" import { Keymap } from "../context/keymap" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { useDialog } from "./dialog" export function DialogHelp() { const dialog = useDialog() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const shortcuts = Keymap.useShortcuts() Keymap.createLayer(() => ({ diff --git a/packages/tui/src/ui/dialog-prompt.tsx b/packages/tui/src/ui/dialog-prompt.tsx index acb1700d26..9eb0ec0f4f 100644 --- a/packages/tui/src/ui/dialog-prompt.tsx +++ b/packages/tui/src/ui/dialog-prompt.tsx @@ -1,6 +1,6 @@ import { TextareaRenderable, TextAttributes } from "@opentui/core" import { Keymap } from "../context/keymap" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { Show, createEffect, createSignal, onMount, type JSX } from "solid-js" import { Spinner } from "../component/spinner" @@ -18,7 +18,7 @@ export type DialogPromptProps = { export function DialogPrompt(props: DialogPromptProps) { const dialog = useDialog() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const shortcuts = Keymap.useShortcuts() const [textareaTarget, setTextareaTarget] = createSignal() let textarea: TextareaRenderable diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index 7b2d27ffe0..c10e44ad2f 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -1,6 +1,6 @@ import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core" import { Keymap, type KeymapCommand } from "../context/keymap" -import { useThemes } from "../context/theme" +import { useTheme, useThemes } from "../context/theme" import { entries, filter, flatMap, groupBy, pipe } from "remeda" import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on, onCleanup } from "solid-js" import { createStore } from "solid-js/store" @@ -96,7 +96,7 @@ export function DialogSelect(props: DialogSelectProps) { const dialog = useDialog() const themes = useThemes() - const theme = themes.contextual("elevated") + const theme = useTheme("elevated") const mode = themes.mode const config = useConfig().data const scrollAcceleration = createMemo(() => getScrollAcceleration(config)) @@ -773,7 +773,7 @@ function Option(props: { activeColor?: RGBA onMouseOver?: () => void }) { - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const text = createMemo(() => { if (props.active && !props.muted) return props.activeColor ?? theme.text.action.primary.focused if (props.muted && (props.active || props.current)) return theme.text.subdued diff --git a/packages/tui/src/ui/dialog.tsx b/packages/tui/src/ui/dialog.tsx index b0d885d50c..0c55bbae5b 100644 --- a/packages/tui/src/ui/dialog.tsx +++ b/packages/tui/src/ui/dialog.tsx @@ -1,7 +1,7 @@ import { useRenderer, useTerminalDimensions } from "@opentui/solid" import { batch, createContext, createEffect, onCleanup, Show, useContext, type JSX, type ParentProps } from "solid-js" import { Keymap } from "../context/keymap" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { MouseButton, Renderable, RGBA } from "@opentui/core" import { createStore } from "solid-js/store" import { useToast } from "./toast" @@ -16,7 +16,7 @@ export function Dialog( }>, ) { const dimensions = useTerminalDimensions() - const theme = useThemes().contextual("elevated") + const theme = useTheme("elevated") const renderer = useRenderer() let dismiss = false diff --git a/packages/tui/src/ui/toast.tsx b/packages/tui/src/ui/toast.tsx index 821e6ae12b..9c54ad8cfa 100644 --- a/packages/tui/src/ui/toast.tsx +++ b/packages/tui/src/ui/toast.tsx @@ -1,6 +1,6 @@ import { createContext, useContext, type ParentProps, Show } from "solid-js" import { createStore } from "solid-js/store" -import { useThemes } from "../context/theme" +import { useTheme } from "../context/theme" import { useTerminalDimensions } from "@opentui/solid" import { SplitBorder } from "./border" import { TextAttributes } from "@opentui/core" @@ -14,7 +14,7 @@ type ToastInput = Omit & { duration?: number } export function Toast() { const toast = useToast() - const theme = useThemes().contextual("overlay") + const theme = useTheme("overlay") const dimensions = useTerminalDimensions() return ( diff --git a/packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx b/packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx index aa930029f5..bc2a51cb6b 100644 --- a/packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx +++ b/packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx @@ -4,7 +4,7 @@ import { testRender } from "@opentui/solid" import type { JSX } from "solid-js" import { onMount, type ParentProps } from "solid-js" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" -import { ThemeProvider, useTheme, useThemes } from "../../../src/context/theme" +import { ThemeProvider, useThemes } from "../../../src/context/theme" import type { Plugin } from "@opencode-ai/plugin/tui" import { ConfigProvider } from "../../../src/config" import { @@ -12,7 +12,6 @@ import { type DiffViewerFileTreeProps, } from "../../../src/feature-plugins/system/diff-viewer-file-tree" import { TestTuiContexts } from "../../fixture/tui-environment" -import { createPluginTheme } from "../../../src/plugin/context" import { allExpandedFileTreeDirectories, buildFileTree, @@ -130,7 +129,7 @@ describe("DiffViewerFileTree", () => { }) function ThemedDiffViewerFileTree(props: Omit) { - return + return } async function renderFrame(component: () => JSX.Element) { diff --git a/packages/tui/test/cli/tui/diff-viewer.test.tsx b/packages/tui/test/cli/tui/diff-viewer.test.tsx index 04758facb9..89937c032f 100644 --- a/packages/tui/test/cli/tui/diff-viewer.test.tsx +++ b/packages/tui/test/cli/tui/diff-viewer.test.tsx @@ -11,7 +11,7 @@ import type { Route, Slot, } from "@opencode-ai/plugin/tui/context" -import { ThemeProvider, useTheme, useThemes } from "../../../src/context/theme" +import { ThemeProvider, useThemes } from "../../../src/context/theme" import { ConfigProvider } from "../../../src/config" import { TuiKeybind } from "../../../src/config/keybind" import { Keymap } from "../../../src/context/keymap" @@ -21,7 +21,6 @@ import { TestTuiContexts } from "../../fixture/tui-environment" import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client" import { DialogProvider } from "../../../src/ui/dialog" import { ToastProvider } from "../../../src/ui/toast" -import { createPluginTheme } from "../../../src/plugin/context" test("closing the diff viewer returns to the route it opened from", async () => { const viewer = await renderDiffViewer([]) @@ -158,7 +157,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: }) }, createEventStream()) function Harness() { - let theme: ReturnType + let theme: ReturnType["currentTokens"]> const context = { options: {}, client: createApi(transport.fetch), @@ -207,7 +206,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: void diffViewerPlugin.setup(context) function Content() { - theme = createPluginTheme(useTheme(), useThemes()) + theme = useThemes().currentTokens() const commandView = renderCommands?.({}) if (current.type !== "plugin") commands.get("diff.open")?.run() return ( diff --git a/packages/tui/test/cli/tui/theme-mode.test.tsx b/packages/tui/test/cli/tui/theme-mode.test.tsx index 7e59846bfb..4ab2232213 100644 --- a/packages/tui/test/cli/tui/theme-mode.test.tsx +++ b/packages/tui/test/cli/tui/theme-mode.test.tsx @@ -6,7 +6,7 @@ import { DEFAULT_THEME, selectTheme } from "@opencode-ai/theme/tui" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { DEFAULT_THEMES } from "../../../src/theme" import { ConfigProvider } from "../../../src/config" -import { ThemeContextProvider, ThemeProvider, useTheme, useThemes, type ThemeError } from "../../../src/context/theme" +import { ThemeContextProvider, ThemeProvider, type ThemeError, useTheme, useThemes } from "../../../src/context/theme" async function wait(fn: () => boolean) { const started = Date.now() @@ -129,9 +129,11 @@ test("contextual hooks resolve overrides and fall back to a standalone theme's b } as const let themes: ReturnType | undefined let theme: ReturnType | undefined + let explicit: ReturnType | undefined function ContextProbe() { theme = useTheme() + explicit = useTheme("elevated") return {theme.text.default.toString()} } @@ -160,9 +162,11 @@ test("contextual hooks resolve overrides and fall back to a standalone theme's b await wait(() => themes?.ready === true) if (!themes) throw new Error("Theme provider is not mounted") if (!theme) throw new Error("Contextual theme is not mounted") + if (!explicit) throw new Error("Explicit contextual theme is not mounted") expect(theme.text.default.equals(RGBA.fromHex("#abcdef"))).toBeTrue() - expect(theme.text.default).toBe(themes.contextual("elevated").text.default) - expect(themes.contextual("overlay").background.default).toBe(themes.current.background.default) + expect(theme).toBe(explicit) + expect(theme.text.default).toBe(themes.current.contextual.elevated.text.default) + expect(themes.current.contextual.overlay.background.default).toBe(themes.current.background.default) } finally { app.renderer.destroy() } diff --git a/packages/tui/test/theme/v2/component.test.ts b/packages/tui/test/theme/v2/component.test.ts index 87c3265777..4d3eeb3d00 100644 --- a/packages/tui/test/theme/v2/component.test.ts +++ b/packages/tui/test/theme/v2/component.test.ts @@ -1,17 +1,18 @@ import { expect, test } from "bun:test" import { createSignal } from "solid-js" import { RGBA } from "@opentui/core" -import { DEFAULT_THEME, resolveTheme, selectTheme, type ContextKey } from "@opencode-ai/theme/tui" +import { DEFAULT_THEME, resolveTheme, selectTheme, type ContextName } from "@opencode-ai/theme/tui" import { createComponentTheme } from "../../../src/theme/component" test("provides reactive properties, states, contexts, and color operations", () => { const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "light"))) const [mode, setMode] = createSignal<"light" | "dark">("light") - const [context, setContext] = createSignal() - const theme = createComponentTheme(() => { - const key = context() - return key ? (resolved().contexts[key] ?? resolved()) : resolved() - }, mode) + const theme = createComponentTheme(resolved, mode) + const [context, setContext] = createSignal() + const current = () => { + const name = context() + return name ? theme.contextual[name] : theme + } expect(theme.text.default).toBe(resolved().text.default) expect(theme.hue.accent[500]).toBe(resolved().hue.accent[500]) @@ -50,20 +51,21 @@ test("provides reactive properties, states, contexts, and color operations", () expect(theme.scrollbar.default).toBe(resolved().scrollbar.default) expect(theme.diff.text.added).toBe(resolved().diff.text.added) - setContext("@context:elevated") - expect(theme.categorical.map((scale) => scale[500])).toEqual(resolved().categorical.map((scale) => scale[500])) - expect(theme.text.default).toBe(resolved().contexts["@context:elevated"]!.text.default) - expect(theme.background.action.primary.focused).toBe( - resolved().contexts["@context:elevated"]!.background.action.primary.focused, + setContext("elevated") + expect("contexts" in current()).toBeFalse() + expect(current().categorical.map((scale) => scale[500])).toEqual(resolved().categorical.map((scale) => scale[500])) + expect(current().text.default).toBe(resolved().contextual.elevated.text.default) + expect(current().background.action.primary.focused).toBe( + resolved().contextual.elevated.background.action.primary.focused, ) - expect(theme.background.action.primary.hovered).toBe(resolved().background.surface.overlay) - expect(theme.background.formfield.selected).toBe( - resolved().contexts["@context:elevated"]!.background.formfield.selected, + expect(current().background.action.primary.hovered).toBe(resolved().background.surface.overlay) + expect(current().background.formfield.selected).toBe( + resolved().contextual.elevated.background.formfield.selected, ) setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "dark"))) setMode("dark") - expect(theme.text.default).toBe(resolved().contexts["@context:elevated"]!.text.default) - expect(theme.decrease(theme.background.surface.offset, 1)).toBe(resolved().hue.neutral[600]) - expect(theme.raise(theme.background.surface.offset)).toBe(resolved().hue.neutral[600]) + expect(current().text.default).toBe(resolved().contextual.elevated.text.default) + expect(current().decrease(current().background.surface.offset, 1)).toBe(resolved().hue.neutral[600]) + expect(current().raise(current().background.surface.offset)).toBe(resolved().hue.neutral[600]) }) diff --git a/packages/tui/test/theme/v2/resolve.test.ts b/packages/tui/test/theme/v2/resolve.test.ts index f3e3837de6..ce2fe09d2f 100644 --- a/packages/tui/test/theme/v2/resolve.test.ts +++ b/packages/tui/test/theme/v2/resolve.test.ts @@ -37,7 +37,7 @@ test("validates and resolves categorical hues in configured order", () => { expect(theme.categorical[0]).toBe(theme.hue.accent) expect(theme.categorical[1]).toBe(theme.hue.red) expect(theme.categorical[2]).toBe(theme.hue.interactive) - expect(theme.contexts["@context:elevated"]?.categorical).toBe(theme.categorical) + expect(theme.contextual.elevated.categorical).toBe(theme.categorical) expect(() => resolveSource({ version: 2, light: { categorical: [] } }, "light")).toThrow("Invalid theme") expect(() => resolveSource({ version: 2, light: { categorical: ["magenta"] } }, "light")).toThrow("Invalid theme") }) @@ -65,29 +65,29 @@ test("resolves independent definitions and hue aliases", () => { expect(lightTheme.source(lightTheme.background.surface.offset)).toEqual({ hue: "neutral", step: 300 }) expect(lightTheme.increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200]) expect(lightTheme.decrease(lightTheme.hue.red[200])).toBe(lightTheme.hue.red[100]) - expect(lightTheme.contexts["@context:elevated"]?.increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200]) + expect(lightTheme.contextual.elevated.increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200]) expect(lightTheme.text.default).toBeInstanceOf(RGBA) expect(darkTheme.background.default).toBeInstanceOf(RGBA) expect(lightTheme.background.surface.offset).toBe(lightTheme.hue.neutral[300]) expect(lightTheme.background.surface.overlay).toBe(lightTheme.hue.neutral[400]) expect(lightTheme.syntax.keyword).toBeInstanceOf(RGBA) expect(lightTheme.text.action.primary.default).toBe(lightTheme.hue.neutral[200]) - expect(lightTheme.contexts["@context:elevated"]?.background.action.primary.default).toBe( + expect(lightTheme.contextual.elevated.background.action.primary.default).toBe( lightTheme.hue.interactive[500], ) - expect(lightTheme.contexts["@context:elevated"]?.background.default).toBe(lightTheme.background.surface.offset) - expect(lightTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe(lightTheme.hue.neutral[100]) - expect(lightTheme.contexts["@context:overlay"]?.background.action.primary.default).toBe( + expect(lightTheme.contextual.elevated.background.default).toBe(lightTheme.background.surface.offset) + expect(lightTheme.contextual.elevated.text.action.primary.default).toBe(lightTheme.hue.neutral[100]) + expect(lightTheme.contextual.overlay.background.action.primary.default).toBe( lightTheme.hue.interactive[500], ) - expect(lightTheme.contexts["@context:overlay"]?.background.default).toBe(lightTheme.background.surface.overlay) - expect(lightTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe(lightTheme.hue.neutral[100]) - expect(darkTheme.contexts["@context:elevated"]?.background.action.primary.default).toBe( + expect(lightTheme.contextual.overlay.background.default).toBe(lightTheme.background.surface.overlay) + expect(lightTheme.contextual.overlay.text.action.primary.default).toBe(lightTheme.hue.neutral[100]) + expect(darkTheme.contextual.elevated.background.action.primary.default).toBe( darkTheme.hue.interactive[400], ) - expect(darkTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe(darkTheme.hue.neutral[200]) - expect(darkTheme.contexts["@context:overlay"]?.background.action.primary.default).toBe(darkTheme.hue.interactive[400]) - expect(darkTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe(darkTheme.hue.neutral[200]) + expect(darkTheme.contextual.elevated.text.action.primary.default).toBe(darkTheme.hue.neutral[200]) + expect(darkTheme.contextual.overlay.background.action.primary.default).toBe(darkTheme.hue.interactive[400]) + expect(darkTheme.contextual.overlay.text.action.primary.default).toBe(darkTheme.hue.neutral[200]) }) test("resolves base hue aliases and rejects circular hue aliases", () => { @@ -228,8 +228,8 @@ test("resolves elevated hover surfaces from direct colors", () => { "light", ) - expect(theme.contexts["@context:elevated"]?.background.default.toInts()).toEqual([18, 52, 86, 255]) - expect(theme.contexts["@context:elevated"]?.background.action.primary.hovered.toInts()).toEqual([35, 69, 103, 255]) + expect(theme.contextual.elevated.background.default.toInts()).toEqual([18, 52, 86, 255]) + expect(theme.contextual.elevated.background.action.primary.hovered.toInts()).toEqual([35, 69, 103, 255]) }) test("resolves transparent colors", () => { @@ -271,7 +271,7 @@ test("context overrides rewire semantic references and apply state precedence", }, }) const theme = resolveTheme(definition) - const overlay = theme.contexts["@context:elevated"]! + const overlay = theme.contextual.elevated expect(overlay.text.default.toInts()).toEqual([51, 51, 51, 255]) expect(overlay.text.action.primary.pressed.toInts()).toEqual([68, 68, 68, 255]) diff --git a/packages/tui/test/theme/v2/v1-migrate.test.ts b/packages/tui/test/theme/v2/v1-migrate.test.ts index 31b5b25e5d..677943414f 100644 --- a/packages/tui/test/theme/v2/v1-migrate.test.ts +++ b/packages/tui/test/theme/v2/v1-migrate.test.ts @@ -43,11 +43,11 @@ test("migrates resolved V1 modes into V2 tokens", () => { expect(resolved.background.action.primary.selected.toInts()).toEqual([0, 0, 0, 0]) expect(resolved.text.action.primary.selected.toInts()).toEqual(legacy.primary.toInts()) expect(resolved.background.feedback.error.default.toInts()).toEqual(legacy.background.toInts()) - expect(resolved.contexts["@context:elevated"]?.background.default.toInts()).toEqual(legacy.backgroundPanel.toInts()) - expect(resolved.contexts["@context:elevated"]?.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0]) - expect(resolved.contexts["@context:elevated"]?.text.action.primary.default.toInts()).toEqual(legacy.text.toInts()) - expect(resolved.contexts["@context:overlay"]?.background.default.toInts()).toEqual(legacy.backgroundMenu.toInts()) - expect(resolved.contexts["@context:overlay"]?.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0]) + expect(resolved.contextual.elevated.background.default.toInts()).toEqual(legacy.backgroundPanel.toInts()) + expect(resolved.contextual.elevated.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0]) + expect(resolved.contextual.elevated.text.action.primary.default.toInts()).toEqual(legacy.text.toInts()) + expect(resolved.contextual.overlay.background.default.toInts()).toEqual(legacy.backgroundMenu.toInts()) + expect(resolved.contextual.overlay.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0]) }) test("references generated hues from matching token colors", () => {