Browse Source

refactor(plugin): expose resolved TUI theme (#39536)

James Long 2 weeks ago
parent
commit
c2e975c4e6
49 changed files with 227 additions and 210 deletions
  1. 3 0
      bun.lock
  2. 5 0
      packages/plugin/package.json
  3. 2 1
      packages/plugin/src/tui/context.ts
  4. 2 1
      packages/theme/src/tui/index.ts
  5. 19 17
      packages/theme/src/tui/resolve.ts
  6. 2 2
      packages/theme/src/tui/syntax.ts
  7. 5 4
      packages/theme/src/tui/types.ts
  8. 2 2
      packages/tui/src/component/bg-pulse.tsx
  9. 6 6
      packages/tui/src/component/devtools-bar.tsx
  10. 7 7
      packages/tui/src/component/dialog-integration.tsx
  11. 5 5
      packages/tui/src/component/dialog-mcp.tsx
  12. 2 2
      packages/tui/src/component/dialog-move-session.tsx
  13. 2 2
      packages/tui/src/component/dialog-pair.tsx
  14. 2 2
      packages/tui/src/component/dialog-project-copy-name.tsx
  15. 2 2
      packages/tui/src/component/dialog-retry-action.tsx
  16. 2 2
      packages/tui/src/component/dialog-session-delete-failed.tsx
  17. 2 2
      packages/tui/src/component/dialog-session-list.tsx
  18. 2 2
      packages/tui/src/component/dialog-stash.tsx
  19. 2 2
      packages/tui/src/component/dialog-status.tsx
  20. 3 3
      packages/tui/src/component/dialog-workspace-file-changes.tsx
  21. 2 2
      packages/tui/src/component/prompt/autocomplete.tsx
  22. 2 2
      packages/tui/src/component/reconnecting.tsx
  23. 2 2
      packages/tui/src/component/startup-loading.tsx
  24. 25 16
      packages/tui/src/context/theme.tsx
  25. 4 2
      packages/tui/src/feature-plugins/system/diff-viewer.tsx
  26. 1 1
      packages/tui/src/feature-plugins/system/scrap.tsx
  27. 4 22
      packages/tui/src/plugin/context.tsx
  28. 2 2
      packages/tui/src/routes/session/composer/index.tsx
  29. 2 2
      packages/tui/src/routes/session/form.tsx
  30. 5 5
      packages/tui/src/routes/session/index.tsx
  31. 2 2
      packages/tui/src/routes/session/permission.tsx
  32. 2 2
      packages/tui/src/routes/session/sidebar.tsx
  33. 2 2
      packages/tui/src/routes/session/subagent-footer.tsx
  34. 24 17
      packages/tui/src/theme/component.ts
  35. 2 2
      packages/tui/src/ui/dialog-alert.tsx
  36. 2 2
      packages/tui/src/ui/dialog-confirm.tsx
  37. 3 3
      packages/tui/src/ui/dialog-export-options.tsx
  38. 2 2
      packages/tui/src/ui/dialog-export-result.tsx
  39. 2 2
      packages/tui/src/ui/dialog-help.tsx
  40. 2 2
      packages/tui/src/ui/dialog-prompt.tsx
  41. 3 3
      packages/tui/src/ui/dialog-select.tsx
  42. 2 2
      packages/tui/src/ui/dialog.tsx
  43. 2 2
      packages/tui/src/ui/toast.tsx
  44. 2 3
      packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx
  45. 3 4
      packages/tui/test/cli/tui/diff-viewer.test.tsx
  46. 7 3
      packages/tui/test/cli/tui/theme-mode.test.tsx
  47. 19 17
      packages/tui/test/theme/v2/component.test.ts
  48. 15 15
      packages/tui/test/theme/v2/resolve.test.ts
  49. 5 5
      packages/tui/test/theme/v2/v1-migrate.test.ts

+ 3 - 0
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",

+ 5 - 0
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:",

+ 2 - 1
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

+ 2 - 1
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"

+ 19 - 17
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, "source" | "increase" | "decrease">,
-): ResolvedThemeView {
+  hue: ResolvedThemeTokens["hue"],
+  categorical: ResolvedThemeTokens["categorical"],
+  hueSteps: Pick<ResolvedThemeTokens, "source" | "increase" | "decrease">,
+): ResolvedThemeTokens {
   const source: Record<string, unknown> = { 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<ResolvedThemeView, "source" | "increase" | "decrease"> {
+function compileHueSteps(hue: ResolvedThemeTokens["hue"]): Pick<ResolvedThemeTokens, "source" | "increase" | "decrease"> {
   const index = new WeakMap<RGBA, { hue: keyof typeof hue; step: HueStep; position: number }>()
   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<string, unknown>) {

+ 2 - 2
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

+ 5 - 4
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<Record<ResolvedActionState, RGBA>>
 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<Record<MarkdownToken, RGBA>>
 }
 
-export type ResolvedTheme = ResolvedThemeView & {
-  readonly contexts: Readonly<Partial<Record<ContextKey, ResolvedThemeView>>>
+export type ContextName = "elevated" | "overlay"
+
+export type ResolvedTheme = ResolvedThemeTokens & {
+  readonly contextual: Readonly<Record<ContextName, ResolvedThemeTokens>>
 }

+ 2 - 2
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

+ 6 - 6
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<Panel>()
   const [dumping, setDumping] = createSignal(false)
   const [dumpPath, setDumpPath] = createSignal<string>()
@@ -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 (
     <box
@@ -461,7 +461,7 @@ function PanelBox(props: ParentProps) {
 }
 
 function PanelTitle(props: ParentProps) {
-  const theme = useThemes().contextual("elevated")
+  const theme = useTheme("elevated")
   return (
     <text fg={theme.text.default} attributes={TextAttributes.BOLD} marginBottom={1}>
       {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 (
     <box flexDirection="row">
       <text fg={theme.text.subdued}>{props.label}</text>
@@ -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 (
     <box
@@ -506,7 +506,7 @@ function cpuPercent(microseconds: number, milliseconds: number) {
 }
 
 function ProcessStat(props: { label: string; values: readonly number[]; unit: string; decimals?: number }) {
-  const theme = useThemes().contextual("elevated")
+  const theme = useTheme("elevated")
   const value = () => {
     const value = props.values.at(-1)
     if (value === undefined) return "--"

+ 7 - 7
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 (
     <box gap={1} paddingBottom={1}>
@@ -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<string>()
 
   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<string>()
   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 (
     <box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
       <box flexDirection="row" justifyContent="space-between">

+ 5 - 5
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 <span style={{ fg: theme.text.subdued }}>⋯ Loading</span>
   if (props.enabled) {
     return <span style={{ fg: theme.text.feedback.success.default, attributes: TextAttributes.BOLD }}>✓ Enabled</span>
@@ -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<string>()
   const [detail, setDetail] = createSignal<McpServer>()
   const [loading, setLoading] = createSignal<string | null>(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)

+ 2 - 2
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()

+ 2 - 2
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<unknown>()
   const [showPassword, setShowPassword] = createSignal(false)
   const [passwordHover, setPasswordHover] = createSignal(false)

+ 2 - 2
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<InputRenderable>()
   let input: InputRenderable

+ 2 - 2
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")

+ 2 - 2
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",
   })

+ 2 - 2
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()

+ 2 - 2
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<number>()

+ 2 - 2
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() ?? [])

+ 3 - 3
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))

+ 2 - 2
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

+ 2 - 2
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 (
     <box

+ 2 - 2
packages/tui/src/component/startup-loading.tsx

@@ -1,9 +1,9 @@
 import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
-import { useThemes } from "../context/theme"
+import { useTheme } from "../context/theme"
 import { Spinner } from "./spinner"
 
 export function StartupLoading(props: { ready: () => 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

+ 25 - 16
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<ResolvedTheme>
   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<State>({
   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 (
-    <themeContext.context.Provider value={{ current: themes.contextual(props.context), themes, ready: themes.ready }}>
+    <themeContext.context.Provider
+      value={{ current: value.themes.current.contextual[props.context], themes: value.themes, ready: value.ready }}
+    >
       {props.children}
     </themeContext.context.Provider>
   )

+ 4 - 2
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 = [
     {

+ 1 - 1
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<string | undefined>("fixture-2")
   const [animations, setAnimations] = createSignal(true)

+ 4 - 22
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<typeof useTheme> & {
-  contextual(context: "elevated" | "overlay"): PluginTheme
-  syntaxStyle(): ReturnType<ReturnType<typeof useThemes>["currentSyntax"]>
-}
-
-export function createPluginTheme(theme: ReturnType<typeof useTheme>, themes: ReturnType<typeof useThemes>): 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")

+ 2 - 2
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<string, Tab>,

+ 2 - 2
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()

+ 5 - 5
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<SessionMessageInfo, { type: "shell" }> }) {
-  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

+ 2 - 2
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<const T extends Record<string, string>>(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({

+ 2 - 2
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))

+ 2 - 2
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)

+ 24 - 17
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<ResolvedThemeView>, mode: Accessor<Mode>) {
-  return {
+export function createComponentTheme(current: Accessor<ResolvedTheme>, mode: Accessor<Mode>) {
+  const create = (view: Accessor<ResolvedThemeTokens>) => ({
     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<typeof createComponentTheme>

+ 2 - 2
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",

+ 2 - 2
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",
   })

+ 3 - 3
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,

+ 2 - 2
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?.()

+ 2 - 2
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(() => ({

+ 2 - 2
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<TextareaRenderable>()
   let textarea: TextareaRenderable

+ 3 - 3
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<T>(props: DialogSelectProps<T>) {
 
   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

+ 2 - 2
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

+ 2 - 2
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<ToastOptions, "duration"> & { duration?: number }
 
 export function Toast() {
   const toast = useToast()
-  const theme = useThemes().contextual("overlay")
+  const theme = useTheme("overlay")
   const dimensions = useTerminalDimensions()
 
   return (

+ 2 - 3
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<DiffViewerFileTreeProps, "context">) {
-  return <DiffViewerFileTree {...props} context={{ theme: createPluginTheme(useTheme(), useThemes()) } as Plugin.Context} />
+  return <DiffViewerFileTree {...props} context={{ theme: useThemes().currentTokens() } as Plugin.Context} />
 }
 
 async function renderFrame(component: () => JSX.Element) {

+ 3 - 4
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<typeof createPluginTheme>
+    let theme: ReturnType<ReturnType<typeof useThemes>["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 (

+ 7 - 3
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<typeof useThemes> | undefined
   let theme: ReturnType<typeof useTheme> | undefined
+  let explicit: ReturnType<typeof useTheme> | undefined
 
   function ContextProbe() {
     theme = useTheme()
+    explicit = useTheme("elevated")
     return <text>{theme.text.default.toString()}</text>
   }
 
@@ -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()
   }

+ 19 - 17
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<ContextKey>()
-  const theme = createComponentTheme(() => {
-    const key = context()
-    return key ? (resolved().contexts[key] ?? resolved()) : resolved()
-  }, mode)
+  const theme = createComponentTheme(resolved, mode)
+  const [context, setContext] = createSignal<ContextName>()
+  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])
 })

+ 15 - 15
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])

+ 5 - 5
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", () => {