pull in run theme changes

This commit is contained in:
Simon Klee
2026-04-20 22:23:06 +02:00
parent fb230ca53b
commit 2cae0239b2
3 changed files with 474 additions and 143 deletions

View File

@@ -2,14 +2,10 @@
//
// Derives scrollback and footer colors from the terminal's actual palette.
// resolveRunTheme() queries the renderer for the terminal's 16-color palette,
// detects dark/light mode, and maps through the TUI's theme system to produce
// a RunTheme. Falls back to a hardcoded dark-mode palette if detection fails.
//
// The theme has three parts:
// entry → per-EntryKind colors for plain scrollback text
// footer → highlight, muted, text, surface, and line colors for the footer
// block → richer text/syntax/diff colors for static tool snapshots
import { RGBA, SyntaxStyle, type CliRenderer, type ColorInput } from "@opentui/core"
// detects dark/light mode, builds a small system theme locally, and maps it to
// the run footer + scrollback color model. Falls back to a hardcoded dark-mode
// palette if detection fails.
import { RGBA, SyntaxStyle, type CliRenderer, type ColorInput, type TerminalColors } from "@opentui/core"
import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
import type { EntryKind } from "./types"
@@ -58,29 +54,37 @@ export type RunTheme = {
block: RunBlockTheme
}
type ThemeColor = Exclude<keyof TuiThemeCurrent, "thinkingOpacity">
type HexColor = `#${string}`
type RefName = string
type Variant = {
dark: HexColor | RefName
light: HexColor | RefName
}
type ColorValue = HexColor | RefName | Variant | RGBA | number
type ThemeJson = {
defs?: Record<string, HexColor | RefName>
theme: Omit<Record<ThemeColor, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
selectedListItemText?: ColorValue
backgroundMenu?: ColorValue
thinkingOpacity?: number
}
}
export const transparent = RGBA.fromValues(0, 0, 0, 0)
function alpha(color: RGBA, value: number): RGBA {
const a = Math.max(0, Math.min(1, value))
return RGBA.fromValues(color.r, color.g, color.b, a)
return RGBA.fromValues(color.r, color.g, color.b, Math.max(0, Math.min(1, value)))
}
function rgba(hex: string, value?: number): RGBA {
const color = RGBA.fromHex(hex)
if (value === undefined) {
return color
}
return alpha(color, value)
return value === undefined ? color : alpha(color, value)
}
function mode(bg: RGBA): "dark" | "light" {
const lum = 0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b
if (lum > 0.5) {
return "light"
}
return "dark"
return lum > 0.5 ? "light" : "dark"
}
function fade(color: RGBA, base: RGBA, fallback: number, scale: number, limit: number): RGBA {
@@ -99,46 +103,398 @@ function fade(color: RGBA, base: RGBA, fallback: number, scale: number, limit: n
)
}
function blend(color: RGBA, bg: RGBA): RGBA {
if (color.a >= 1) {
return color
function ansiToRgba(code: number): RGBA {
if (code < 16) {
const ansi = [
"#000000",
"#800000",
"#008000",
"#808000",
"#000080",
"#800080",
"#008080",
"#c0c0c0",
"#808080",
"#ff0000",
"#00ff00",
"#ffff00",
"#0000ff",
"#ff00ff",
"#00ffff",
"#ffffff",
]
return RGBA.fromHex(ansi[code] ?? "#000000")
}
return RGBA.fromValues(
bg.r + (color.r - bg.r) * color.a,
bg.g + (color.g - bg.g) * color.a,
bg.b + (color.b - bg.b) * color.a,
1,
if (code < 232) {
const index = code - 16
const b = index % 6
const g = Math.floor(index / 6) % 6
const r = Math.floor(index / 36)
const value = (x: number) => (x === 0 ? 0 : x * 40 + 55)
return RGBA.fromInts(value(r), value(g), value(b))
}
if (code < 256) {
const gray = (code - 232) * 10 + 8
return RGBA.fromInts(gray, gray, gray)
}
return RGBA.fromInts(0, 0, 0)
}
function tint(base: RGBA, overlay: RGBA, value: number): RGBA {
return RGBA.fromInts(
Math.round((base.r + (overlay.r - base.r) * value) * 255),
Math.round((base.g + (overlay.g - base.g) * value) * 255),
Math.round((base.b + (overlay.b - base.b) * value) * 255),
)
}
export function opaqueSyntaxStyle(style: SyntaxStyle | undefined, bg: RGBA): SyntaxStyle | undefined {
if (!style) {
return undefined
}
return SyntaxStyle.fromStyles(
Object.fromEntries(
[...style.getAllStyles()].map(([name, value]) => [
name,
{
...value,
fg: value.fg ? blend(value.fg, bg) : value.fg,
bg: value.bg ? blend(value.bg, bg) : value.bg,
},
]),
),
)
function luminance(color: RGBA) {
return 0.299 * color.r + 0.587 * color.g + 0.114 * color.b
}
function map(theme: TuiThemeCurrent, syntax?: SyntaxStyle, subtleSyntax?: SyntaxStyle): RunTheme {
const bg = theme.background
const opaqueSubtleSyntax = opaqueSyntaxStyle(subtleSyntax, bg)
subtleSyntax?.destroy()
const pane = theme.backgroundElement
const shade = fade(pane, bg, 0.12, 0.56, 0.72)
const surface = fade(pane, bg, 0.18, 0.76, 0.9)
const line = fade(pane, bg, 0.24, 0.9, 0.98)
function chroma(color: RGBA) {
return Math.max(color.r, color.g, color.b) - Math.min(color.r, color.g, color.b)
}
export function resolveTheme(theme: ThemeJson, pick: "dark" | "light"): TuiThemeCurrent {
const defs = theme.defs ?? {}
const resolveColor = (value: ColorValue, chain: string[] = []): RGBA => {
if (value instanceof RGBA) return value
if (typeof value === "number") {
return ansiToRgba(value)
}
if (typeof value !== "string") {
return resolveColor(value[pick], chain)
}
if (value === "transparent" || value === "none") {
return RGBA.fromInts(0, 0, 0, 0)
}
if (value.startsWith("#")) {
return RGBA.fromHex(value)
}
if (chain.includes(value)) {
throw new Error(`Circular color reference: ${[...chain, value].join(" -> ")}`)
}
const next = defs[value] ?? theme.theme[value as ThemeColor]
if (next === undefined) {
throw new Error(`Color reference "${value}" not found in defs or theme`)
}
return resolveColor(next, [...chain, value])
}
const resolved = Object.fromEntries(
Object.entries(theme.theme)
.filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu" && key !== "thinkingOpacity")
.map(([key, value]) => [key, resolveColor(value as ColorValue)]),
) as Partial<Record<ThemeColor, RGBA>>
return {
...(resolved as Record<ThemeColor, RGBA>),
selectedListItemText:
theme.theme.selectedListItemText === undefined
? resolved.background!
: resolveColor(theme.theme.selectedListItemText),
backgroundMenu:
theme.theme.backgroundMenu === undefined ? resolved.backgroundElement! : resolveColor(theme.theme.backgroundMenu),
thinkingOpacity: theme.theme.thinkingOpacity ?? 0.6,
}
}
function pickPrimaryColor(
bg: RGBA,
candidates: Array<{
key: string
color: RGBA | undefined
}>,
) {
return candidates
.flatMap((item) => {
if (!item.color) return []
const contrast = Math.abs(luminance(item.color) - luminance(bg))
const vivid = chroma(item.color)
if (contrast < 0.16 || vivid < 0.12) return []
return [{ key: item.key, color: item.color, score: vivid * 1.5 + contrast }]
})
.sort((a, b) => b.score - a.score)[0]
}
function generateGrayScale(bg: RGBA, isDark: boolean): Record<number, RGBA> {
const r = bg.r * 255
const g = bg.g * 255
const b = bg.b * 255
const lum = 0.299 * r + 0.587 * g + 0.114 * b
const cast = 0.25 * (1 - chroma(bg)) ** 2
const gray = (level: number) => {
const factor = level / 12
if (isDark && lum < 10) {
const value = Math.floor(factor * 0.4 * 255)
return RGBA.fromInts(value, value, value)
}
if (!isDark && lum > 245) {
const value = Math.floor(255 - factor * 0.4 * 255)
return RGBA.fromInts(value, value, value)
}
const value = isDark ? lum + (255 - lum) * factor * 0.4 : lum * (1 - factor * 0.4)
const tone = RGBA.fromInts(Math.floor(value), Math.floor(value), Math.floor(value))
if (cast === 0) return tone
const ratio = lum === 0 ? 0 : value / lum
return tint(
tone,
RGBA.fromInts(
Math.floor(Math.max(0, Math.min(r * ratio, 255))),
Math.floor(Math.max(0, Math.min(g * ratio, 255))),
Math.floor(Math.max(0, Math.min(b * ratio, 255))),
),
cast,
)
}
return Object.fromEntries(Array.from({ length: 12 }, (_, index) => [index + 1, gray(index + 1)]))
}
function generateMutedTextColor(bg: RGBA, isDark: boolean): RGBA {
const lum = 0.299 * bg.r * 255 + 0.587 * bg.g * 255 + 0.114 * bg.b * 255
const gray = isDark
? lum < 10
? 180
: Math.min(Math.floor(160 + lum * 0.3), 200)
: lum > 245
? 75
: Math.max(Math.floor(100 - (255 - lum) * 0.2), 60)
return RGBA.fromInts(gray, gray, gray)
}
export function generateSystem(colors: TerminalColors, pick: "dark" | "light"): ThemeJson {
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
const isDark = pick === "dark"
const grays = generateGrayScale(bg, isDark)
const textMuted = generateMutedTextColor(bg, isDark)
const color = (index: number) => {
const value = colors.palette[index]
return value ? RGBA.fromHex(value) : ansiToRgba(index)
}
const ansi = {
red: color(1),
green: color(2),
yellow: color(3),
blue: color(4),
magenta: color(5),
cyan: color(6),
red_bright: color(9),
green_bright: color(10),
}
const diff_alpha = isDark ? 0.22 : 0.14
const diff_context_bg = grays[2]
const primary =
pickPrimaryColor(bg, [
{
key: "cursor",
color: colors.cursorColor ? RGBA.fromHex(colors.cursorColor) : undefined,
},
{
key: "selection",
color: colors.highlightBackground ? RGBA.fromHex(colors.highlightBackground) : undefined,
},
{
key: "blue",
color: ansi.blue,
},
{
key: "magenta",
color: ansi.magenta,
},
]) ?? {
key: "blue",
color: ansi.blue,
}
return {
theme: {
primary: primary.color,
secondary: primary.key === "magenta" ? ansi.blue : ansi.magenta,
accent: primary.color,
error: ansi.red,
warning: ansi.yellow,
success: ansi.green,
info: ansi.cyan,
text: fg,
textMuted,
selectedListItemText: bg,
background: RGBA.fromValues(bg.r, bg.g, bg.b, 0),
backgroundPanel: grays[2],
backgroundElement: grays[3],
backgroundMenu: grays[3],
borderSubtle: grays[6],
border: grays[7],
borderActive: grays[8],
diffAdded: ansi.green,
diffRemoved: ansi.red,
diffContext: grays[7],
diffHunkHeader: grays[7],
diffHighlightAdded: ansi.green_bright,
diffHighlightRemoved: ansi.red_bright,
diffAddedBg: tint(bg, ansi.green, diff_alpha),
diffRemovedBg: tint(bg, ansi.red, diff_alpha),
diffContextBg: diff_context_bg,
diffLineNumber: textMuted,
diffAddedLineNumberBg: tint(diff_context_bg, ansi.green, diff_alpha),
diffRemovedLineNumberBg: tint(diff_context_bg, ansi.red, diff_alpha),
markdownText: fg,
markdownHeading: fg,
markdownLink: ansi.blue,
markdownLinkText: ansi.cyan,
markdownCode: ansi.green,
markdownBlockQuote: ansi.yellow,
markdownEmph: ansi.yellow,
markdownStrong: fg,
markdownHorizontalRule: grays[7],
markdownListItem: ansi.blue,
markdownListEnumeration: ansi.cyan,
markdownImage: ansi.blue,
markdownImageText: ansi.cyan,
markdownCodeBlock: fg,
syntaxComment: textMuted,
syntaxKeyword: ansi.magenta,
syntaxFunction: ansi.blue,
syntaxVariable: fg,
syntaxString: ansi.green,
syntaxNumber: ansi.yellow,
syntaxType: ansi.cyan,
syntaxOperator: ansi.cyan,
syntaxPunctuation: fg,
},
}
}
function generateSyntax(theme: TuiThemeCurrent) {
return SyntaxStyle.fromTheme([
{
scope: ["default"],
style: {
foreground: theme.text,
},
},
{
scope: ["comment", "comment.documentation"],
style: {
foreground: theme.syntaxComment,
italic: true,
},
},
{
scope: ["string", "symbol", "character", "markup.raw", "markup.raw.block", "markup.raw.inline"],
style: {
foreground: theme.markdownCode,
},
},
{
scope: ["number", "boolean", "constant"],
style: {
foreground: theme.syntaxNumber,
},
},
{
scope: ["keyword", "keyword.import", "keyword.operator"],
style: {
foreground: theme.syntaxKeyword,
italic: true,
},
},
{
scope: ["function", "function.call", "function.method", "function.method.call", "constructor"],
style: {
foreground: theme.syntaxFunction,
},
},
{
scope: ["type", "class", "module", "namespace"],
style: {
foreground: theme.syntaxType,
},
},
{
scope: ["operator", "punctuation.delimiter", "punctuation.special"],
style: {
foreground: theme.syntaxOperator,
},
},
{
scope: ["markup.heading"],
style: {
foreground: theme.markdownHeading,
bold: true,
},
},
{
scope: ["markup.link", "markup.link.url", "markup.link.label", "string.special.url"],
style: {
foreground: theme.markdownLink,
underline: true,
},
},
{
scope: ["diff.plus"],
style: {
foreground: theme.diffAdded,
background: theme.diffAddedBg,
},
},
{
scope: ["diff.minus"],
style: {
foreground: theme.diffRemoved,
background: theme.diffRemovedBg,
},
},
{
scope: ["diff.delta"],
style: {
foreground: theme.diffContext,
background: theme.diffContextBg,
},
},
{
scope: ["error"],
style: {
foreground: theme.error,
bold: true,
},
},
{
scope: ["warning"],
style: {
foreground: theme.warning,
bold: true,
},
},
])
}
function map(theme: TuiThemeCurrent, syntax?: SyntaxStyle): RunTheme {
const shade = fade(theme.backgroundElement, theme.background, 0.12, 0.56, 0.72)
const surface = fade(theme.backgroundElement, theme.background, 0.18, 0.76, 0.9)
const line = fade(theme.backgroundElement, theme.background, 0.24, 0.9, 0.98)
return {
background: theme.background,
@@ -151,7 +507,7 @@ function map(theme: TuiThemeCurrent, syntax?: SyntaxStyle, subtleSyntax?: Syntax
text: theme.text,
shade,
surface,
pane,
pane: theme.backgroundElement,
border: theme.border,
line,
},
@@ -180,7 +536,6 @@ function map(theme: TuiThemeCurrent, syntax?: SyntaxStyle, subtleSyntax?: Syntax
text: theme.text,
muted: theme.textMuted,
syntax,
subtleSyntax: opaqueSubtleSyntax,
diffAdded: theme.diffAdded,
diffRemoved: theme.diffRemoved,
diffAddedBg: theme.diffAddedBg,
@@ -262,13 +617,8 @@ export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme>
}
const pick = renderer.themeMode ?? mode(RGBA.fromHex(bg))
const mod = await import("../tui/context/theme")
const theme = mod.resolveTheme(mod.generateSystem(colors, pick), pick) as TuiThemeCurrent
try {
return map(theme, mod.generateSyntax(theme), mod.generateSubtleSyntax(theme))
} catch {
return map(theme)
}
const theme = resolveTheme(generateSystem(colors, pick), pick)
return map(theme, generateSyntax(theme))
} catch {
return RUN_THEME_FALLBACK
}

View File

@@ -513,34 +513,6 @@ export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA {
return RGBA.fromInts(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255))
}
function luminance(color: RGBA) {
return 0.299 * color.r + 0.587 * color.g + 0.114 * color.b
}
function chroma(color: RGBA) {
return Math.max(color.r, color.g, color.b) - Math.min(color.r, color.g, color.b)
}
function pickPrimaryColor(
bg: RGBA,
candidates: Array<{
key: string
color: RGBA | undefined
}>,
) {
return candidates
.flatMap((item) => {
if (!item.color) return []
const contrast = Math.abs(luminance(item.color) - luminance(bg))
const vivid = chroma(item.color)
if (contrast < 0.16 || vivid < 0.12) return []
return [{ key: item.key, color: item.color, score: vivid * 1.5 + contrast }]
})
.sort((a, b) => b.score - a.score)[0]
}
// TODO: i exported this, just for keeping it simple for now, but this should
// probably go into something shared if we decide to use this in opencode run
export function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
@@ -578,37 +550,13 @@ export function generateSystem(colors: TerminalColors, mode: "dark" | "light"):
const diffAddedLineNumberBg = tint(diffContextBg, ansiColors.green, diffAlpha)
const diffRemovedLineNumberBg = tint(diffContextBg, ansiColors.red, diffAlpha)
const diffLineNumber = textMuted
// The generated system theme also feeds the run footer highlight, so prefer
// the terminal's own cursor/selection accent when it stays legible.
const primary =
pickPrimaryColor(bg, [
{
key: "cursor",
color: colors.cursorColor ? RGBA.fromHex(colors.cursorColor) : undefined,
},
{
key: "selection",
color: colors.highlightBackground ? RGBA.fromHex(colors.highlightBackground) : undefined,
},
{
key: "blue",
color: ansiColors.blue,
},
{
key: "magenta",
color: ansiColors.magenta,
},
]) ?? {
key: "blue",
color: ansiColors.blue,
}
return {
theme: {
// Fall back to blue/magenta when the terminal UI colors are too muted.
primary: primary.color,
secondary: primary.key === "magenta" ? ansiColors.blue : ansiColors.magenta,
accent: primary.color,
// Primary colors using ANSI
primary: ansiColors.cyan,
secondary: ansiColors.magenta,
accent: ansiColors.cyan,
// Status colors using ANSI
error: ansiColors.red,
@@ -761,11 +709,11 @@ function generateMutedTextColor(bg: RGBA, isDark: boolean): RGBA {
return RGBA.fromInts(grayValue, grayValue, grayValue)
}
export function generateSyntax(theme: TuiThemeCurrent) {
export function generateSyntax(theme: Theme) {
return SyntaxStyle.fromTheme(getSyntaxRules(theme))
}
export function generateSubtleSyntax(theme: TuiThemeCurrent) {
export function generateSubtleSyntax(theme: Theme) {
const rules = getSyntaxRules(theme)
return SyntaxStyle.fromTheme(
rules.map((rule) => {
@@ -789,7 +737,7 @@ export function generateSubtleSyntax(theme: TuiThemeCurrent) {
)
}
function getSyntaxRules(theme: TuiThemeCurrent) {
function getSyntaxRules(theme: Theme) {
return [
{
scope: ["default"],

View File

@@ -1,29 +1,15 @@
import { expect, test } from "bun:test"
import { RGBA, SyntaxStyle, type CliRenderer, type TerminalColors } from "@opentui/core"
import { opaqueSyntaxStyle, resolveRunTheme } from "@/cli/cmd/run/theme"
import { generateSystem, resolveTheme } from "@/cli/cmd/tui/context/theme"
test("flattens subtle syntax alpha against the run background", () => {
const syntax = SyntaxStyle.fromStyles({
default: {
fg: RGBA.fromInts(169, 177, 214, 153),
},
emphasis: {
fg: RGBA.fromInts(224, 175, 104, 153),
italic: true,
bold: true,
},
})
const subtle = opaqueSyntaxStyle(syntax, RGBA.fromInts(42, 43, 61))
import { RGBA, type CliRenderer, type TerminalColors } from "@opentui/core"
import { generateSystem, resolveRunTheme, resolveTheme } from "@/cli/cmd/run/theme"
test("resolve run theme keeps block syntax intentionally simple", async () => {
const theme = await resolveRunTheme(renderer("dark"))
try {
expect(subtle?.getStyle("default")?.fg?.toInts()).toEqual([118, 123, 153, 255])
expect(subtle?.getStyle("emphasis")?.fg?.toInts()).toEqual([151, 122, 87, 255])
expect(subtle?.getStyle("emphasis")?.italic).toBe(true)
expect(subtle?.getStyle("emphasis")?.bold).toBe(true)
expect(theme.block.subtleSyntax).toBeUndefined()
expect(theme.block.syntax?.getStyle("keyword")?.fg).toEqual(RGBA.fromHex(colors.palette[5]!))
expect(theme.block.syntax?.getStyle("string")?.fg).toEqual(RGBA.fromHex(colors.palette[2]!))
} finally {
syntax.destroy()
subtle?.destroy()
theme.block.syntax?.destroy()
}
})
@@ -66,6 +52,25 @@ function renderer(themeMode: "dark" | "light") {
return item as CliRenderer
}
function spread(color: RGBA) {
const [r, g, b] = color.toInts()
return Math.max(r, g, b) - Math.min(r, g, b)
}
function system(defaultBackground: string, defaultForeground: string, mode: "dark" | "light") {
return resolveTheme(
generateSystem(
{
...colors,
defaultBackground,
defaultForeground,
},
mode,
),
mode,
)
}
test("system theme uses terminal ui colors for primary", () => {
const theme = resolveTheme(generateSystem(colors, "dark"), "dark")
@@ -79,3 +84,31 @@ test("resolve run theme uses the system primary for footer highlight", async ()
expect(theme.footer.highlight).toEqual(expected.primary)
})
test("system theme keeps dark surfaces close to neutral on colored backgrounds", () => {
const theme = system("#002b36", "#93a1a1", "dark")
expect(spread(theme.backgroundPanel)).toBeLessThan(25)
expect(spread(theme.backgroundElement)).toBeLessThan(25)
})
test("system theme keeps light surfaces close to neutral on warm backgrounds", () => {
const theme = system("#fbf1c7", "#3c3836", "light")
expect(spread(theme.backgroundPanel)).toBeLessThan(20)
expect(spread(theme.backgroundElement)).toBeLessThan(20)
})
test("system theme keeps dark surfaces neutral on saturated backgrounds", () => {
const theme = system("#0000ff", "#ffffff", "dark")
expect(spread(theme.backgroundPanel)).toBeLessThan(5)
expect(spread(theme.backgroundElement)).toBeLessThan(5)
})
test("system theme keeps light surfaces neutral on saturated backgrounds", () => {
const theme = system("#ffff00", "#000000", "light")
expect(spread(theme.backgroundPanel)).toBeLessThan(5)
expect(spread(theme.backgroundElement)).toBeLessThan(5)
})