fix(cli): use terminal accents for system theme primary

This commit is contained in:
Simon Klee
2026-04-20 15:06:48 +02:00
parent f686455ce3
commit c32cd2975d
2 changed files with 132 additions and 30 deletions

View File

@@ -513,6 +513,32 @@ 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 [{ ...item, 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 {
@@ -552,13 +578,37 @@ 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: {
// Primary colors using ANSI
primary: ansiColors.cyan,
secondary: ansiColors.magenta,
accent: ansiColors.cyan,
// 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,
// Status colors using ANSI
error: ansiColors.red,

View File

@@ -1,29 +1,81 @@
import { describe, expect, test } from "bun:test"
import { RGBA, SyntaxStyle } from "@opentui/core"
import { opaqueSyntaxStyle } from "@/cli/cmd/run/theme"
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"
describe("run 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))
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)
} finally {
syntax.destroy()
subtle?.destroy()
}
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))
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)
} finally {
syntax.destroy()
subtle?.destroy()
}
})
const colors: TerminalColors = {
palette: [
"#15161e",
"#f7768e",
"#9ece6a",
"#e0af68",
"#7aa2f7",
"#bb9af7",
"#7dcfff",
"#a9b1d6",
"#414868",
"#f7768e",
"#9ece6a",
"#e0af68",
"#7aa2f7",
"#bb9af7",
"#7dcfff",
"#c0caf5",
],
defaultBackground: "#1a1b26",
defaultForeground: "#c0caf5",
cursorColor: "#ff9e64",
mouseForeground: null,
mouseBackground: null,
tekForeground: null,
tekBackground: null,
highlightBackground: "#33467c",
highlightForeground: "#c0caf5",
}
function renderer(themeMode: "dark" | "light") {
const item = {
themeMode,
getPalette: async () => colors,
} satisfies Pick<CliRenderer, "themeMode" | "getPalette">
return item as CliRenderer
}
test("system theme uses terminal ui colors for primary", () => {
const theme = resolveTheme(generateSystem(colors, "dark"), "dark")
expect(theme.primary).toEqual(RGBA.fromHex(colors.cursorColor!))
expect(theme.primary).not.toEqual(RGBA.fromHex(colors.palette[6]!))
})
test("resolve run theme uses the system primary for footer highlight", async () => {
const expected = resolveTheme(generateSystem(colors, "dark"), "dark")
const theme = await resolveRunTheme(renderer("dark"))
expect(theme.footer.highlight).toEqual(expected.primary)
})