feat(tui): support single-mode themes (#37930)

This commit is contained in:
James Long 2026-07-20 09:37:48 -04:00 committed by GitHub
commit 7be95bcd6a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 283 additions and 77 deletions

View file

@ -41,3 +41,4 @@ export type {
StatefulColor,
} from "./types"
export { migrateV1 } from "./v1-migrate"
export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select"

View file

@ -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))
@ -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>

View file

@ -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
@ -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
}

View file

@ -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">
@ -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))