diff --git a/packages/opencode/src/cli/cmd/run/theme.ts b/packages/opencode/src/cli/cmd/run/theme.ts index 00bf029003..c39daeacbf 100644 --- a/packages/opencode/src/cli/cmd/run/theme.ts +++ b/packages/opencode/src/cli/cmd/run/theme.ts @@ -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 +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 + theme: Omit, "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> + + return { + ...(resolved as Record), + 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 { + 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 } 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 } diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index a274825c85..63dddf031f 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -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"], diff --git a/packages/opencode/test/cli/run/theme.test.ts b/packages/opencode/test/cli/run/theme.test.ts index cfa6c30c8b..6f61c895fc 100644 --- a/packages/opencode/test/cli/run/theme.test.ts +++ b/packages/opencode/test/cli/run/theme.test.ts @@ -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) +})