fix(tui): preserve system palette colors

This commit is contained in:
Kit Langton
2026-07-17 19:50:51 +00:00
committed by 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴
parent 5d5b33f195
commit 212d6797bc
3 changed files with 191 additions and 61 deletions
+8 -2
View File
@@ -16,7 +16,8 @@ import {
type Theme,
type ThemeJson,
} from "../theme"
import { generateSystem, terminalMode } from "../theme/system"
import { generateSystem, generateSystemV2, terminalMode } from "../theme/system"
import type { ThemeFile } from "../theme/v2"
import { createComponentTheme, type ComponentTheme } from "../theme/v2/component"
import { resolveThemeFile } from "../theme/v2/resolve"
import { migrateV1 } from "../theme/v2/v1-migrate"
@@ -78,6 +79,7 @@ const THEME_REFRESH_DELAYS = [250, 1000] as const
type State = {
themes: Record<string, ThemeJson>
systemV2: ThemeFile | undefined
mode: "dark" | "light"
lock: "dark" | "light" | undefined
active: string
@@ -104,6 +106,7 @@ type ThemeService = {
const [store, setStore] = createStore<State>({
themes: allThemes(),
systemV2: undefined,
mode: "dark",
lock: undefined,
active: "opencode",
@@ -181,6 +184,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
.then((colors: TerminalColors) => {
if (!colors.palette[0]) {
if (hasResolvedSystemTheme) return
setStore("systemV2", undefined)
setSystemTheme(undefined)
if (store.active === "system") setStore("active", "opencode")
return
@@ -192,10 +196,12 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
if (store.themes.system && systemThemeSignature === signature && systemThemeMode === next) return
systemThemeSignature = signature
systemThemeMode = next
setStore("systemV2", generateSystemV2(colors))
setSystemTheme(generateSystem(colors, next))
})
.catch(() => {
if (hasResolvedSystemTheme) return
setStore("systemV2", undefined)
setSystemTheme(undefined)
if (store.active === "system") setStore("active", "opencode")
})
@@ -290,7 +296,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
const values = createMemo(() => resolveTheme(source(), store.mode))
const valuesV2 = createMemo(() => {
const started = performance.now()
const file = migrateV1(source())
const file = sourceName() === "system" && store.systemV2 ? store.systemV2 : migrateV1(source())
themePerformance.set("Convert V1 to V2", duration(performance.now() - started))
const resolveStarted = performance.now()
const result = resolveThemeFile(file, store.mode, sourceName())
+146 -59
View File
@@ -1,5 +1,6 @@
import { RGBA, type TerminalColors } from "@opentui/core"
import { ansiToRgba, tint } from "./color"
import { HueStep, type Mode, type ThemeFile } from "./v2"
export function terminalMode(colors: TerminalColors): "dark" | "light" | undefined {
const bg = colors.defaultBackground
@@ -9,6 +10,97 @@ export function terminalMode(colors: TerminalColors): "dark" | "light" | undefin
}
export function generateSystem(colors: TerminalColors, mode: "dark" | "light") {
const value = resolveSystem(colors, mode)
return {
theme: {
primary: value.ansi.cyan,
secondary: value.ansi.magenta,
accent: value.ansi.cyan,
error: value.ansi.red,
warning: value.ansi.yellow,
success: value.ansi.green,
info: value.ansi.cyan,
text: value.fg,
textMuted: value.textMuted,
selectedListItemText: value.bg,
background: value.transparent,
backgroundPanel: value.grays[2],
backgroundElement: value.grays[3],
backgroundMenu: value.grays[3],
borderSubtle: value.grays[6],
border: value.grays[7],
borderActive: value.grays[8],
diffAdded: value.ansi.green,
diffRemoved: value.ansi.red,
diffContext: value.grays[7],
diffHunkHeader: value.grays[7],
diffHighlightAdded: value.ansi.greenBright,
diffHighlightRemoved: value.ansi.redBright,
diffAddedBg: value.diffAddedBg,
diffRemovedBg: value.diffRemovedBg,
diffContextBg: value.diffContextBg,
diffLineNumber: value.textMuted,
diffAddedLineNumberBg: value.diffAddedLineNumberBg,
diffRemovedLineNumberBg: value.diffRemovedLineNumberBg,
markdownText: value.fg,
markdownHeading: value.fg,
markdownLink: value.ansi.blue,
markdownLinkText: value.ansi.cyan,
markdownCode: value.ansi.green,
markdownBlockQuote: value.ansi.yellow,
markdownEmph: value.ansi.yellow,
markdownStrong: value.fg,
markdownHorizontalRule: value.grays[7],
markdownListItem: value.ansi.blue,
markdownListEnumeration: value.ansi.cyan,
markdownImage: value.ansi.blue,
markdownImageText: value.ansi.cyan,
markdownCodeBlock: value.fg,
syntaxComment: value.textMuted,
syntaxKeyword: value.ansi.magenta,
syntaxFunction: value.ansi.blue,
syntaxVariable: value.fg,
syntaxString: value.ansi.green,
syntaxNumber: value.ansi.yellow,
syntaxType: value.ansi.cyan,
syntaxOperator: value.ansi.cyan,
syntaxPunctuation: value.fg,
},
}
}
export function generateSystemV2(colors: TerminalColors): ThemeFile {
return {
version: 2,
light: generateSystemMode(colors, "light"),
dark: generateSystemMode(colors, "dark"),
}
}
function generateSystemMode(colors: TerminalColors, mode: Mode): ThemeFile["light"] {
const value = resolveSystem(colors, mode)
const scale = (color: RGBA) =>
Object.fromEntries(HueStep.literals.map((step) => [step, hex(color)])) as Record<HueStep, string>
return {
hue: {
gray: neutralScale(value, mode),
red: scale(value.ansi.red),
orange: scale(value.ansi.yellow),
yellow: scale(value.ansi.yellow),
green: scale(value.ansi.green),
cyan: scale(value.ansi.cyan),
blue: scale(value.ansi.blue),
purple: scale(value.ansi.magenta),
accent: scale(value.ansi.cyan),
interactive: scale(value.ansi.cyan),
neutral: "$hue.gray",
},
}
}
function resolveSystem(colors: TerminalColors, mode: Mode) {
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
const transparent = RGBA.fromValues(bg.r, bg.g, bg.b, 0)
@@ -22,7 +114,7 @@ export function generateSystem(colors: TerminalColors, mode: "dark" | "light") {
const grays = generateGrayScale(bg, isDark)
const textMuted = generateMutedTextColor(bg, isDark)
const ansiColors = {
const ansi = {
red: col(1),
green: col(2),
yellow: col(3),
@@ -34,67 +126,24 @@ export function generateSystem(colors: TerminalColors, mode: "dark" | "light") {
}
const diffAlpha = isDark ? 0.22 : 0.14
const diffAddedBg = tint(bg, ansiColors.green, diffAlpha)
const diffRemovedBg = tint(bg, ansiColors.red, diffAlpha)
const diffAddedBg = tint(bg, ansi.green, diffAlpha)
const diffRemovedBg = tint(bg, ansi.red, diffAlpha)
const diffContextBg = grays[2]
const diffAddedLineNumberBg = tint(diffContextBg, ansiColors.green, diffAlpha)
const diffRemovedLineNumberBg = tint(diffContextBg, ansiColors.red, diffAlpha)
const diffAddedLineNumberBg = tint(diffContextBg, ansi.green, diffAlpha)
const diffRemovedLineNumberBg = tint(diffContextBg, ansi.red, diffAlpha)
return {
theme: {
primary: ansiColors.cyan,
secondary: ansiColors.magenta,
accent: ansiColors.cyan,
error: ansiColors.red,
warning: ansiColors.yellow,
success: ansiColors.green,
info: ansiColors.cyan,
text: fg,
textMuted,
selectedListItemText: bg,
background: transparent,
backgroundPanel: grays[2],
backgroundElement: grays[3],
backgroundMenu: grays[3],
borderSubtle: grays[6],
border: grays[7],
borderActive: grays[8],
diffAdded: ansiColors.green,
diffRemoved: ansiColors.red,
diffContext: grays[7],
diffHunkHeader: grays[7],
diffHighlightAdded: ansiColors.greenBright,
diffHighlightRemoved: ansiColors.redBright,
diffAddedBg,
diffRemovedBg,
diffContextBg,
diffLineNumber: textMuted,
diffAddedLineNumberBg,
diffRemovedLineNumberBg,
markdownText: fg,
markdownHeading: fg,
markdownLink: ansiColors.blue,
markdownLinkText: ansiColors.cyan,
markdownCode: ansiColors.green,
markdownBlockQuote: ansiColors.yellow,
markdownEmph: ansiColors.yellow,
markdownStrong: fg,
markdownHorizontalRule: grays[7],
markdownListItem: ansiColors.blue,
markdownListEnumeration: ansiColors.cyan,
markdownImage: ansiColors.blue,
markdownImageText: ansiColors.cyan,
markdownCodeBlock: fg,
syntaxComment: textMuted,
syntaxKeyword: ansiColors.magenta,
syntaxFunction: ansiColors.blue,
syntaxVariable: fg,
syntaxString: ansiColors.green,
syntaxNumber: ansiColors.yellow,
syntaxType: ansiColors.cyan,
syntaxOperator: ansiColors.cyan,
syntaxPunctuation: fg,
},
bg,
fg,
transparent,
grays,
textMuted,
ansi,
diffAddedBg,
diffRemovedBg,
diffContextBg,
diffAddedLineNumberBg,
diffRemovedLineNumberBg,
}
}
@@ -142,3 +191,41 @@ function generateMutedTextColor(bg: RGBA, isDark: boolean): RGBA {
const gray = luminance > 245 ? 75 : Math.max(Math.floor(100 - (255 - luminance) * 0.2), 60)
return RGBA.fromInts(gray, gray, gray)
}
function neutralScale(value: ReturnType<typeof resolveSystem>, mode: Mode) {
const light: { step: HueStep; color: RGBA }[] = [
{ step: 100, color: value.transparent },
{ step: 200, color: value.grays[2] },
{ step: 300, color: value.grays[3] },
{ step: 700, color: value.textMuted },
{ step: 900, color: value.fg },
]
const anchors =
mode === "light"
? light
: light.toReversed().map((source) => ({ ...source, step: (1000 - source.step) as HueStep }))
return Object.fromEntries(
HueStep.literals.map((step) => {
const exact = anchors.find((anchor) => anchor.step === step)
if (exact) return [step, hex(exact.color)]
const lower = anchors.filter((anchor) => anchor.step < step).at(-1)!
const upper = anchors.find((anchor) => anchor.step > step)!
return [step, interpolate(lower.color, upper.color, (step - lower.step) / (upper.step - lower.step))]
}),
) as Record<HueStep, string>
}
function interpolate(first: RGBA, second: RGBA, amount: number) {
const start = first.toInts()
const end = second.toInts()
return `#${start.map((value, index) => byte(Math.round(value + (end[index]! - value) * amount))).join("")}`
}
function hex(color: RGBA) {
const [red, green, blue, alpha] = color.toInts()
return `#${byte(red)}${byte(green)}${byte(blue)}${alpha === 255 ? "" : byte(alpha)}`
}
function byte(value: number) {
return value.toString(16).padStart(2, "0")
}
+37
View File
@@ -0,0 +1,37 @@
import { expect, test } from "bun:test"
import type { TerminalColors } from "@opentui/core"
import { generateSystemV2 } from "../../src/theme/system"
import { resolveThemeFile } from "../../src/theme/v2/resolve"
const colors: TerminalColors = {
palette: ["#15161e", "#f7768e", "#9ece6a", "#e0af68", "#7aa2f7", "#bb9af7", "#7dcfff", "#a9b1d6"],
defaultForeground: "#c0caf5",
defaultBackground: "#1a1b26",
cursorColor: null,
mouseForeground: null,
mouseBackground: null,
tekForeground: null,
tekBackground: null,
highlightBackground: null,
highlightForeground: null,
}
test("generates literal V2 hue scales from the system palette", () => {
const resolved = resolveThemeFile(generateSystemV2(colors), "dark")
for (const step of [100, 300, 500, 700, 900] as const) {
expect(resolved.hue.red[step].toInts()).toEqual([247, 118, 142, 255])
expect(resolved.hue.orange[step].toInts()).toEqual([224, 175, 104, 255])
expect(resolved.hue.yellow[step].toInts()).toEqual([224, 175, 104, 255])
expect(resolved.hue.green[step].toInts()).toEqual([158, 206, 106, 255])
expect(resolved.hue.cyan[step].toInts()).toEqual([125, 207, 255, 255])
expect(resolved.hue.blue[step].toInts()).toEqual([122, 162, 247, 255])
expect(resolved.hue.purple[step].toInts()).toEqual([187, 154, 247, 255])
expect(resolved.hue.accent[step].toInts()).toEqual([125, 207, 255, 255])
expect(resolved.hue.interactive[step].toInts()).toEqual([125, 207, 255, 255])
}
expect(resolved.text.default.toInts()).toEqual([192, 202, 245, 255])
expect(resolved.background.default.toInts()).toEqual([26, 27, 38, 0])
expect(resolved.background.action.primary.default.toInts()).toEqual([125, 207, 255, 255])
})