Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/tui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
const client = useClient()
const toast = useToast()
const themeState = useTheme()
const { themeV2, mode, setMode, locked, lock, unlock } = themeState
const { themeV2, mode, supports, setMode, locked, lock, unlock } = themeState
const data = useData()
const location = useLocation()
const exit = useExit()
Expand Down Expand Up @@ -818,6 +818,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
name: "theme.switch_mode",
title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode",
palette: undefined,
enabled: () => supports(mode() === "dark" ? "light" : "dark"),
run: () => {
setMode(mode() === "dark" ? "light" : "dark")
dialog.clear()
Expand Down
12 changes: 7 additions & 5 deletions packages/tui/src/component/devtools-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import { useTheme } from "../context/theme"
import { DevTools } from "../devtools"

export function DevToolsSidebar() {
const { themeV2, mode, setMode } = useTheme().contextual("elevated")
const { themeV2, mode, supports, setMode } = useTheme().contextual("elevated")
const [modeHovered, setModeHovered] = createSignal(false)
const nextMode = () => (mode() === "dark" ? "light" : "dark")
const canSwitchMode = () => supports(nextMode())

return (
<box
Expand All @@ -29,12 +31,12 @@ export function DevToolsSidebar() {
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={modeHovered() ? themeV2.background.action("hovered") : undefined}
onMouseOver={() => setModeHovered(true)}
backgroundColor={modeHovered() && canSwitchMode() ? themeV2.background.action("hovered") : undefined}
onMouseOver={() => setModeHovered(canSwitchMode())}
onMouseOut={() => setModeHovered(false)}
onMouseUp={() => setMode(mode() === "dark" ? "light" : "dark")}
onMouseUp={canSwitchMode() ? () => setMode(nextMode()) : undefined}
>
<text fg={themeV2.text()}>{mode()}</text>
<text fg={canSwitchMode() ? themeV2.text() : themeV2.text.subdued()}>{mode()}</text>
</box>
</box>
</box>
Expand Down
35 changes: 26 additions & 9 deletions packages/tui/src/context/theme.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { discoverThemes, themeDirectories } from "../theme/discovery"
import { createComponentTheme, type ComponentTheme } from "../theme/v2/component"
import { resolveThemeFile } from "../theme/v2/resolve"
import { migrateV1 } from "../theme/v2/v1-migrate"
import { themeModes } from "../theme/v2/select"
import { createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "./helper"
Expand Down Expand Up @@ -78,10 +79,12 @@ type ThemeService = {
has: typeof hasTheme
syntax: Accessor<SyntaxStyle>
mode: Accessor<"dark" | "light">
modes: Accessor<readonly ("dark" | "light")[]>
supports(mode: "dark" | "light"): boolean
locked: Accessor<boolean>
lock(): void
unlock(): void
setMode(mode?: "dark" | "light", persist?: boolean): void
setMode(mode?: "dark" | "light", persist?: boolean): boolean
set(theme: string): boolean
readonly ready: boolean
}
Expand Down Expand Up @@ -271,17 +274,25 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({

const source = createMemo(() => store.themes[store.active] ?? store.themes.opencode)
const sourceName = createMemo(() => (store.themes[store.active] ? store.active : "opencode"))
const values = createMemo(() => resolveTheme(source(), store.mode))
const valuesV2 = createMemo(() => {
const file = createMemo(() => {
const started = performance.now()
const file = migrateV1(source())
const result = migrateV1(source())
themePerformance.set("Convert V1 to V2", duration(performance.now() - started))
return result
})
const modes = createMemo(() => themeModes(file()))
const mode = () => {
const supported = modes()
if (supported.includes(store.mode)) return store.mode
return supported[0] ?? store.mode
}
const values = createMemo(() => resolveTheme(source(), mode()))
const valuesV2 = createMemo(() => {
const resolveStarted = performance.now()
const result = resolveThemeFile(file, store.mode, sourceName())
const result = resolveThemeFile(file(), mode(), sourceName())
themePerformance.set("Resolve final theme", duration(performance.now() - resolveStarted))
return result
})
const mode = () => store.mode
const themeV2 = createComponentTheme(valuesV2, mode)
const contextsV2 = {
elevated: createComponentTheme(() => {
Expand Down Expand Up @@ -319,11 +330,17 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
all: allThemes,
has: hasTheme,
syntax,
mode: () => store.mode,
mode,
modes,
supports: (requested) => modes().includes(requested),
locked: () => store.lock !== undefined,
lock: () => pin(store.mode),
lock: () => pin(mode()),
unlock: free,
setMode: pin,
setMode(requested = mode(), persist = true) {
if (!modes().includes(requested)) return false
pin(requested, persist)
return true
},
set(theme: string) {
if (!hasTheme(theme)) return false
setStore("active", theme)
Expand Down
1 change: 1 addition & 0 deletions packages/tui/src/theme/v2/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,4 @@ export type {
StatefulColor,
} from "./types"
export { migrateV1 } from "./v1-migrate"
export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select"
31 changes: 27 additions & 4 deletions packages/tui/src/theme/v2/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,15 +137,35 @@ const BackgroundDefinition = Schema.Struct({
export type BackgroundDefinition = Schema.Schema.Type<typeof BackgroundDefinition>

export const SyntaxToken = Schema.Literals([
"comment", "keyword", "function", "variable", "string", "number", "type", "operator", "punctuation",
"comment",
"keyword",
"function",
"variable",
"string",
"number",
"type",
"operator",
"punctuation",
])
export type SyntaxToken = Schema.Schema.Type<typeof SyntaxToken>
export const SyntaxDefinition = Schema.Record(SyntaxToken, Schema.optionalKey(HueColorValue))
export type SyntaxDefinition = Schema.Schema.Type<typeof SyntaxDefinition>

export const MarkdownToken = Schema.Literals([
"text", "heading", "link", "linkText", "code", "blockQuote", "emphasis", "strong", "horizontalRule", "listItem",
"listEnumeration", "image", "imageText", "codeBlock",
"text",
"heading",
"link",
"linkText",
"code",
"blockQuote",
"emphasis",
"strong",
"horizontalRule",
"listItem",
"listEnumeration",
"image",
"imageText",
"codeBlock",
])
export type MarkdownToken = Schema.Schema.Type<typeof MarkdownToken>
export const MarkdownDefinition = Schema.Record(MarkdownToken, Schema.optionalKey(HueColorValue))
Expand Down Expand Up @@ -225,5 +245,8 @@ const FileMetadata = {
version: Schema.Literal(2),
standalone: Schema.optional(Schema.Boolean),
}
export const ThemeFile = Schema.Struct({ ...FileMetadata, light: ModeDefinition, dark: ModeDefinition })
export const ThemeFile = Schema.Union([
Schema.Struct({ ...FileMetadata, light: ModeDefinition, dark: Schema.optional(ModeDefinition) }),
Schema.Struct({ ...FileMetadata, light: Schema.optional(ModeDefinition), dark: ModeDefinition }),
])
export type ThemeFile = Schema.Schema.Type<typeof ThemeFile>
30 changes: 22 additions & 8 deletions packages/tui/src/theme/v2/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type {
} from "./index"

export function selectTheme(
file: Omit<ThemeFile, "light" | "dark"> & { light: ThemeDefinition; dark: ThemeDefinition },
file: ThemeFile & { light: ThemeDefinition; dark: ThemeDefinition },
mode?: Mode,
): ThemeDefinition
export function selectTheme(file: ThemeFile, mode?: Mode): FileThemeDefinition
Expand All @@ -21,17 +21,31 @@ export function selectThemeMode(
file: ThemeFile,
mode: Mode = "light",
): { theme: FileThemeDefinition; mode: Mode; expanded: boolean } {
const modes = themeModes(file)
const selectedMode = modes.includes(mode) ? mode : modes[0]
const selected = file[selectedMode]
if (!selected) throw new Error("Theme must provide at least one mode")
if (merges(file.light) && merges(file.dark)) throw new Error("Light and dark themes cannot both merge modes")
const selected = file[mode]
if (!merges(selected)) return { theme: selected, mode, expanded: false }
if (!merges(selected)) return { theme: selected, mode: selectedMode, expanded: false }

const otherMode = mode === "light" ? "dark" : "light"
const otherMode = selectedMode === "light" ? "dark" : "light"
const other = file[otherMode]
if (!other) throw new Error(`The ${selectedMode} theme cannot merge without a ${otherMode} theme`)
const merged = mergeTheme(expandTheme(other), expandTheme(selected))
if (!merged["hue"]) throw new Error(`The ${otherMode} theme must provide hues when ${mode} merges modes`)
return { theme: merged as FileThemeDefinition, mode, expanded: true }
if (!merged["hue"]) throw new Error(`The ${otherMode} theme must provide hues when ${selectedMode} merges modes`)
return { theme: merged as FileThemeDefinition, mode: selectedMode, expanded: true }
}

function merges(definition: ModeDefinition): definition is MergeModeDefinition {
return "mergeMode" in definition && definition.mergeMode === true
export function themeModes(file: ThemeFile): readonly Mode[] {
if (merges(file.light) && !file.dark) throw new Error("The light theme cannot merge without a dark theme")
if (merges(file.dark) && !file.light) throw new Error("The dark theme cannot merge without a light theme")
return (["light", "dark"] as const).filter((mode) => file[mode] !== undefined)
}

export function supportsThemeMode(file: ThemeFile, mode: Mode) {
return themeModes(file).includes(mode)
}

function merges(definition: ModeDefinition | undefined): definition is MergeModeDefinition {
return definition !== undefined && "mergeMode" in definition && definition.mergeMode === true
}
26 changes: 22 additions & 4 deletions packages/tui/src/theme/v2/v1-migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { RGBA } from "@opentui/core"
import { oklchToHex, rgbToOklch } from "@opencode-ai/ui/theme/color"
import type { Theme, ThemeJson } from "../index"
import { DEFAULT_THEME } from "./defaults"
import type { ThemeFile } from "./index"
import type { FileThemeDefinition, Mode, ThemeFile } from "./index"
import { HueStep } from "./schema"

type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
Expand All @@ -13,15 +13,33 @@ const minimumChroma = 0.03
const lightThreshold = 0.6

export function migrateV1(theme: ThemeJson): ThemeFile {
const light = resolveV1(theme, "light")
const dark = resolveV1(theme, "dark")
if (light.background.a > 0 && dark.background.a > 0 && light.background.equals(dark.background)) {
const lightMode = detectMode(light)
const darkMode = detectMode(dark)
if (lightMode === darkMode) {
if (lightMode === "light") return { version: 2, standalone: true, light: migrateMode(light, "light") }
return { version: 2, standalone: true, dark: migrateMode(dark, "dark") }
}
}
return {
version: 2,
standalone: true,
light: migrateMode(resolveV1(theme, "light"), "light"),
dark: migrateMode(resolveV1(theme, "dark"), "dark"),
light: migrateMode(light, "light"),
dark: migrateMode(dark, "dark"),
}
}

function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] {
function detectMode(theme: Theme): Mode {
return luminance(theme.text) > luminance(theme.background) ? "dark" : "light"
}

function luminance(color: RGBA) {
return 0.299 * color.r + 0.587 * color.g + 0.114 * color.b
}

function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
const color = (key: ThemeColor) => hex(theme[key])
const selected = hex(selectedForeground(theme, theme.primary))
const destructive = hex(selectedForeground(theme, theme.error))
Expand Down
72 changes: 72 additions & 0 deletions packages/tui/test/cli/tui/theme-mode.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/** @jsxImportSource @opentui/solid */
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { DEFAULT_THEMES } from "../../../src/theme"
import { ConfigProvider } from "../../../src/config"
import { ThemeProvider, useTheme } from "../../../src/context/theme"

async function wait(fn: () => boolean) {
const started = Date.now()
while (!fn()) {
if (Date.now() - started > 2000) throw new Error("timed out waiting for theme mode")
await Bun.sleep(10)
}
}

test("uses an available mode while retaining the pinned preference", async () => {
const lightOnly = structuredClone(DEFAULT_THEMES.opencode)
lightOnly.theme.background = "#eeeeee"
lightOnly.theme.text = "#111111"
const dual = structuredClone(DEFAULT_THEMES.opencode)
dual.theme.background = { light: "#eeeeee", dark: "#111111" }
dual.theme.text = { light: "#111111", dark: "#eeeeee" }
const darkOnly = structuredClone(DEFAULT_THEMES.opencode)
darkOnly.theme.background = "#111111"
darkOnly.theme.text = "#eeeeee"
let theme: ReturnType<typeof useTheme> | undefined

function Probe() {
const value = useTheme()
theme = value
return <text>{value.mode()}</text>
}

function current() {
if (!theme) throw new Error("Theme provider is not mounted")
return theme
}

const app = await testRender(
() => (
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "light-only", mode: "dark" } })}>
<ThemeProvider
mode="dark"
source={{ discover: () => Promise.resolve({ "light-only": lightOnly, "dark-only": darkOnly, dual }) }}
>
<Probe />
</ThemeProvider>
</ConfigProvider>
),
{ width: 20, height: 2 },
)
app.renderer.start()

try {
await wait(() => theme?.ready === true)
expect(current().mode()).toBe("light")
expect(current().modes()).toEqual(["light"])
expect(current().supports("dark")).toBeFalse()
expect(current().setMode("dark")).toBeFalse()
expect(current().set("dark-only")).toBeTrue()
await wait(() => current().mode() === "dark")
expect(current().modes()).toEqual(["dark"])
expect(current().set("light-only")).toBeTrue()
await wait(() => current().mode() === "light")
expect(current().set("dual")).toBeTrue()
await wait(() => current().mode() === "dark")
expect(current().modes()).toEqual(["light", "dark"])
} finally {
app.renderer.destroy()
}
})
10 changes: 5 additions & 5 deletions packages/tui/test/theme/v2/component.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,12 @@ test("provides reactive property, variant, state, and context accessors", () =>
expect(theme.hue.accent(500)).toBe(resolved().hue.accent[500])
expect(theme.hue.interactive(500)).toBe(resolved().hue.interactive[500])
expect(theme.hue.gray(200)).toBe(resolved().hue.gray[200])
expect(theme.increase(theme.background.surface.offset(), 1)).toBe(resolved().hue.neutral[300])
expect(theme.raise(theme.background.surface.offset())).toBe(resolved().hue.neutral[300])
expect(theme.increase(theme.background.surface.offset(), 1)).toBe(resolved().hue.neutral[400])
expect(theme.raise(theme.background.surface.offset())).toBe(resolved().hue.neutral[400])
expect(theme.decrease(theme.hue.red(300), 2)).toBe(resolved().hue.red[100])
expect(theme.increase(theme.hue.red(900), 3)).toBe(resolved().hue.red[900])
expect(theme.decrease(theme.hue.red(100), 3)).toBe(resolved().hue.red[100])
expect(theme.source(theme.background.surface.offset())).toEqual({ hue: "neutral", step: 200 })
expect(theme.source(theme.background.surface.offset())).toEqual({ hue: "neutral", step: 300 })
const equivalent = RGBA.fromInts(...resolved().hue.green[500].toInts())
expect(theme.source(equivalent)).toBeUndefined()
expect(theme.increase(equivalent, 1)).toBe(equivalent)
Expand Down Expand Up @@ -87,6 +87,6 @@ test("provides reactive property, variant, state, and context accessors", () =>
setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "dark")))
setMode("dark")
expect(theme.text()).toBe(resolved().contexts["@context:elevated"]!.text.default)
expect(theme.decrease(theme.background.surface.offset(), 1)).toBe(resolved().hue.neutral[700])
expect(theme.raise(theme.background.surface.offset())).toBe(resolved().hue.neutral[700])
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])
})
Loading
Loading