refactor(tui): split theme hooks (#39395)

This commit is contained in:
James Long 2026-07-28 16:25:32 -04:00 committed by GitHub
commit 08b80da931
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 833 additions and 814 deletions

View file

@ -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 } from "../../../src/context/theme"
import { ThemeProvider, useTheme, useThemes } from "../../../src/context/theme"
import type { Plugin } from "@opencode-ai/plugin/tui"
import { ConfigProvider } from "../../../src/config"
import {
@ -130,7 +130,7 @@ describe("DiffViewerFileTree", () => {
})
function ThemedDiffViewerFileTree(props: Omit<DiffViewerFileTreeProps, "context">) {
return <DiffViewerFileTree {...props} context={{ theme: createPluginTheme(useTheme()) } as Plugin.Context} />
return <DiffViewerFileTree {...props} context={{ theme: createPluginTheme(useTheme(), useThemes()) } as Plugin.Context} />
}
async function renderFrame(component: () => JSX.Element) {

View file

@ -11,7 +11,7 @@ import type {
Route,
Slot,
} from "@opencode-ai/plugin/tui/context"
import { ThemeProvider, useTheme } from "../../../src/context/theme"
import { ThemeProvider, useTheme, useThemes } from "../../../src/context/theme"
import { ConfigProvider } from "../../../src/config"
import { TuiKeybind } from "../../../src/config/keybind"
import { Keymap } from "../../../src/context/keymap"
@ -207,7 +207,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
void diffViewerPlugin.setup(context)
function Content() {
theme = createPluginTheme(useTheme())
theme = createPluginTheme(useTheme(), useThemes())
const commandView = renderCommands?.({})
if (current.type !== "plugin") commands.get("diff.open")?.run()
return (

View file

@ -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 { ThemeProvider, useTheme, type ThemeError } from "../../../src/context/theme"
import { ThemeContextProvider, ThemeProvider, useTheme, useThemes, type ThemeError } from "../../../src/context/theme"
async function wait(fn: () => boolean) {
const started = Date.now()
@ -27,17 +27,17 @@ test("uses an available mode while retaining the pinned preference", async () =>
darkOnly.theme.background = "#111111"
darkOnly.theme.text = "#eeeeee"
const native = { version: 2, dark: { text: { default: "#abcdef" } } } as const
let theme: ReturnType<typeof useTheme> | undefined
let themes: ReturnType<typeof useThemes> | undefined
function Probe() {
const value = useTheme()
theme = value
const value = useThemes()
themes = value
return <text>{value.mode()}</text>
}
function current() {
if (!theme) throw new Error("Theme provider is not mounted")
return theme
if (!themes) throw new Error("Theme provider is not mounted")
return themes
}
const app = await testRender(
@ -56,7 +56,7 @@ test("uses an available mode while retaining the pinned preference", async () =>
app.renderer.start()
try {
await wait(() => theme?.ready === true)
await wait(() => themes?.ready === true)
expect(current().mode()).toBe("light")
expect(current().modes()).toEqual(["light"])
expect(current().supports("dark")).toBeFalse()
@ -72,7 +72,7 @@ test("uses an available mode while retaining the pinned preference", async () =>
expect(current().set("native")).toBeTrue()
await wait(() => current().selected === "native")
expect(current().modes()).toEqual(["dark"])
expect(current().themeV2.text.default.equals(RGBA.fromHex("#abcdef"))).toBeTrue()
expect(current().current.text.default.equals(RGBA.fromHex("#abcdef"))).toBeTrue()
} finally {
app.renderer.destroy()
}
@ -83,13 +83,13 @@ test.each([
["mode merging", { version: 2, light: { mergeMode: true } }],
["token reference", { version: 2, light: { text: { default: "$missing" } } }],
] as const)("falls back to OpenCode when configured V2 theme %s is invalid", async (_label, source) => {
let theme: ReturnType<typeof useTheme> | undefined
let themes: ReturnType<typeof useThemes> | undefined
let failure: ThemeError | undefined
let unsubscribe: (() => void) | undefined
function Probe() {
const value = useTheme()
theme = value
const value = useThemes()
themes = value
unsubscribe = value.onError((error) => (failure = error))
return <text>{value.selected}</text>
}
@ -107,8 +107,8 @@ test.each([
app.renderer.start()
try {
await wait(() => theme?.ready === true)
expect(theme?.selected).toBe("opencode")
await wait(() => themes?.ready === true)
expect(themes?.selected).toBe("opencode")
expect(failure?.name).toBe("invalid")
expect(failure?.error).toBeInstanceOf(Error)
expect(failure?.error.message.length).toBeGreaterThan(0)
@ -118,17 +118,30 @@ test.each([
}
})
test("contextual themes fall back to a standalone theme's base view", async () => {
test("contextual hooks resolve overrides and fall back to a standalone theme's base view", async () => {
const standalone = {
version: 2,
standalone: true,
dark: { hue: selectTheme(DEFAULT_THEME, "dark").hue },
dark: {
hue: selectTheme(DEFAULT_THEME, "dark").hue,
"@context:elevated": { text: { default: "#abcdef" } },
},
} as const
let themes: ReturnType<typeof useThemes> | undefined
let theme: ReturnType<typeof useTheme> | undefined
function Probe() {
function ContextProbe() {
theme = useTheme()
return <text>{theme.selected}</text>
return <text>{theme.text.default.toString()}</text>
}
function Probe() {
themes = useThemes()
return (
<ThemeContextProvider context="elevated">
<ContextProbe />
</ThemeContextProvider>
)
}
const app = await testRender(
@ -144,10 +157,12 @@ test("contextual themes fall back to a standalone theme's base view", async () =
app.renderer.start()
try {
await wait(() => theme?.ready === true)
if (!theme) throw new Error("Theme provider is not mounted")
expect(theme.contextual("elevated").themeV2.text.default).toBe(theme.themeV2.text.default)
expect(theme.contextual("overlay").themeV2.background.default).toBe(theme.themeV2.background.default)
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")
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)
} finally {
app.renderer.destroy()
}