From c32cd2975d9d93759d1c4109431e699049b50f25 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Mon, 20 Apr 2026 15:06:48 +0200 Subject: [PATCH] fix(cli): use terminal accents for system theme primary --- .../src/cli/cmd/tui/context/theme.tsx | 58 +++++++++- packages/opencode/test/cli/run/theme.test.ts | 104 +++++++++++++----- 2 files changed, 132 insertions(+), 30 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index acbaa682ae..5cfeb47546 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -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, diff --git a/packages/opencode/test/cli/run/theme.test.ts b/packages/opencode/test/cli/run/theme.test.ts index 304e8a623c..cfa6c30c8b 100644 --- a/packages/opencode/test/cli/run/theme.test.ts +++ b/packages/opencode/test/cli/run/theme.test.ts @@ -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 + + 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) })