refactor(tui): extract system theme generation (#36753)

This commit is contained in:
James Long 2026-07-13 16:39:06 -04:00 committed by GitHub
commit 4c45a5cf23
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 206 additions and 268 deletions

View file

@ -7,7 +7,8 @@ import {
} from "@opentui/core"
import { extend, useRenderer } from "@opentui/solid"
import { onCleanup, onMount } from "solid-js"
import { tint, useTheme } from "../context/theme"
import { useTheme } from "../context/theme"
import { tint } from "../theme/color"
import { GoUpsellArtPainter } from "./bg-pulse-render"
type GoUpsellArtOptions = RenderableOptions<FrameBufferRenderable> & {

View file

@ -1,6 +1,7 @@
import { RGBA, TextAttributes } from "@opentui/core"
import { For, type JSX } from "solid-js"
import { tint, useTheme } from "../context/theme"
import { useTheme } from "../context/theme"
import { tint } from "../theme/color"
import { logo } from "../logo"
export function Logo() {

View file

@ -15,7 +15,8 @@ import path from "path"
import { fileURLToPath } from "url"
import { useLocal } from "../../context/local"
import { Flag } from "@opencode-ai/core/flag/flag"
import { tint, useTheme } from "../../context/theme"
import { useTheme } from "../../context/theme"
import { tint } from "../../theme/color"
import { EmptyBorder, SplitBorder } from "../../ui/border"
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
import { useClipboard } from "../../context/clipboard"

View file

@ -5,7 +5,6 @@ import {
addTheme,
allThemes,
generateSyntax,
generateSystem,
hasTheme,
isTheme,
resolveTheme,
@ -13,11 +12,10 @@ import {
setCustomThemes,
setSystemTheme,
subscribeThemes,
terminalMode,
tint,
upsertTheme,
type ThemeJson,
} from "../theme"
import { generateSystem, terminalMode } from "../theme/system"
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "./helper"
@ -63,13 +61,10 @@ export {
addTheme,
allThemes,
generateSyntax,
generateSystem,
hasTheme,
isTheme,
resolveTheme,
selectedForeground,
terminalMode,
tint,
upsertTheme,
type Theme,
type ThemeJson,

View file

@ -1,7 +1,7 @@
/** @jsxImportSource @opentui/solid */
import type { ColorInput, RGBA, ScrollBoxRenderable } from "@opentui/core"
import { Locale } from "../../util/locale"
import { tint } from "../../context/theme"
import { tint } from "../../theme/color"
import { createEffect, createMemo, For, Match, Switch } from "solid-js"
import { buildFileTree, flattenFileTree, type FileTreeItem, type FileTreeRow } from "./diff-viewer-file-tree-utils"
import { Panel } from "./diff-viewer-ui"

View file

@ -3,7 +3,8 @@ import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-j
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core"
import open from "open"
import { selectedForeground, tint, useTheme } from "../../context/theme"
import { selectedForeground, useTheme } from "../../context/theme"
import { tint } from "../../theme/color"
import type { FormField, FormValue } from "@opencode-ai/client"
import type { FormWithLocation } from "../../context/data"
import { useSDK } from "../../context/sdk"

View file

@ -0,0 +1,48 @@
import { RGBA } from "@opentui/core"
export function ansiToRgba(code: number): RGBA {
if (code < 16) {
const ansiColors = [
"#000000",
"#800000",
"#008000",
"#808000",
"#000080",
"#800080",
"#008080",
"#c0c0c0",
"#808080",
"#ff0000",
"#00ff00",
"#ffff00",
"#0000ff",
"#ff00ff",
"#00ffff",
"#ffffff",
]
return RGBA.fromHex(ansiColors[code] ?? "#000000")
}
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 val = (x: number) => (x === 0 ? 0 : x * 40 + 55)
return RGBA.fromInts(val(r), val(g), val(b))
}
if (code < 256) {
const gray = (code - 232) * 10 + 8
return RGBA.fromInts(gray, gray, gray)
}
return RGBA.fromInts(0, 0, 0)
}
export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA {
const r = base.r + (overlay.r - base.r) * alpha
const g = base.g + (overlay.g - base.g) * alpha
const b = base.b + (overlay.b - base.b) * alpha
return RGBA.fromInts(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255))
}

View file

@ -1,4 +1,5 @@
import { SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core"
import { SyntaxStyle, RGBA } from "@opentui/core"
import { ansiToRgba } from "./color"
import aura from "./assets/aura.json" with { type: "json" }
import ayu from "./assets/ayu.json" with { type: "json" }
import carbonfox from "./assets/carbonfox.json" with { type: "json" }
@ -297,261 +298,6 @@ export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
} as Theme
}
function ansiToRgba(code: number): RGBA {
// Standard ANSI colors (0-15)
if (code < 16) {
const ansiColors = [
"#000000", // Black
"#800000", // Red
"#008000", // Green
"#808000", // Yellow
"#000080", // Blue
"#800080", // Magenta
"#008080", // Cyan
"#c0c0c0", // White
"#808080", // Bright Black
"#ff0000", // Bright Red
"#00ff00", // Bright Green
"#ffff00", // Bright Yellow
"#0000ff", // Bright Blue
"#ff00ff", // Bright Magenta
"#00ffff", // Bright Cyan
"#ffffff", // Bright White
]
return RGBA.fromHex(ansiColors[code] ?? "#000000")
}
// 6x6x6 Color Cube (16-231)
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 val = (x: number) => (x === 0 ? 0 : x * 40 + 55)
return RGBA.fromInts(val(r), val(g), val(b))
}
// Grayscale Ramp (232-255)
if (code < 256) {
const gray = (code - 232) * 10 + 8
return RGBA.fromInts(gray, gray, gray)
}
// Fallback for invalid codes
return RGBA.fromInts(0, 0, 0)
}
export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA {
const r = base.r + (overlay.r - base.r) * alpha
const g = base.g + (overlay.g - base.g) * alpha
const b = base.b + (overlay.b - base.b) * alpha
return RGBA.fromInts(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255))
}
export function terminalMode(colors: TerminalColors): "dark" | "light" | undefined {
const bg = colors.defaultBackground
if (!bg) return
const { r, g, b } = RGBA.fromHex(bg)
return 0.299 * r + 0.587 * g + 0.114 * b > 0.5 ? "light" : "dark"
}
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]!)
const transparent = RGBA.fromValues(bg.r, bg.g, bg.b, 0)
const isDark = mode == "dark"
const col = (i: number) => {
const value = colors.palette[i]
if (value) return RGBA.fromHex(value)
return ansiToRgba(i)
}
// Generate gray scale based on terminal background
const grays = generateGrayScale(bg, isDark)
const textMuted = generateMutedTextColor(bg, isDark)
// ANSI color references
const ansiColors = {
black: col(0),
red: col(1),
green: col(2),
yellow: col(3),
blue: col(4),
magenta: col(5),
cyan: col(6),
white: col(7),
redBright: col(9),
greenBright: col(10),
}
const diffAlpha = isDark ? 0.22 : 0.14
const diffAddedBg = tint(bg, ansiColors.green, diffAlpha)
const diffRemovedBg = tint(bg, ansiColors.red, diffAlpha)
const diffContextBg = grays[2]
const diffAddedLineNumberBg = tint(diffContextBg, ansiColors.green, diffAlpha)
const diffRemovedLineNumberBg = tint(diffContextBg, ansiColors.red, diffAlpha)
const diffLineNumber = textMuted
return {
theme: {
// Primary colors using ANSI
primary: ansiColors.cyan,
secondary: ansiColors.magenta,
accent: ansiColors.cyan,
// Status colors using ANSI
error: ansiColors.red,
warning: ansiColors.yellow,
success: ansiColors.green,
info: ansiColors.cyan,
// Text colors
text: fg,
textMuted,
selectedListItemText: bg,
// Background colors - use transparent to respect terminal transparency
background: transparent,
backgroundPanel: grays[2],
backgroundElement: grays[3],
backgroundMenu: grays[3],
// Border colors
borderSubtle: grays[6],
border: grays[7],
borderActive: grays[8],
// Diff colors
diffAdded: ansiColors.green,
diffRemoved: ansiColors.red,
diffContext: grays[7],
diffHunkHeader: grays[7],
diffHighlightAdded: ansiColors.greenBright,
diffHighlightRemoved: ansiColors.redBright,
diffAddedBg,
diffRemovedBg,
diffContextBg,
diffLineNumber,
diffAddedLineNumberBg,
diffRemovedLineNumberBg,
// Markdown colors
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,
// Syntax colors
syntaxComment: textMuted,
syntaxKeyword: ansiColors.magenta,
syntaxFunction: ansiColors.blue,
syntaxVariable: fg,
syntaxString: ansiColors.green,
syntaxNumber: ansiColors.yellow,
syntaxType: ansiColors.cyan,
syntaxOperator: ansiColors.cyan,
syntaxPunctuation: fg,
},
}
}
function generateGrayScale(bg: RGBA, isDark: boolean): Record<number, RGBA> {
const grays: Record<number, RGBA> = {}
// RGBA stores floats in range 0-1, convert to 0-255
const bgR = bg.r * 255
const bgG = bg.g * 255
const bgB = bg.b * 255
const luminance = 0.299 * bgR + 0.587 * bgG + 0.114 * bgB
for (let i = 1; i <= 12; i++) {
const factor = i / 12.0
let grayValue: number
let newR: number
let newG: number
let newB: number
if (isDark) {
if (luminance < 10) {
grayValue = Math.floor(factor * 0.4 * 255)
newR = grayValue
newG = grayValue
newB = grayValue
} else {
const newLum = luminance + (255 - luminance) * factor * 0.4
const ratio = newLum / luminance
newR = Math.min(bgR * ratio, 255)
newG = Math.min(bgG * ratio, 255)
newB = Math.min(bgB * ratio, 255)
}
} else {
if (luminance > 245) {
grayValue = Math.floor(255 - factor * 0.4 * 255)
newR = grayValue
newG = grayValue
newB = grayValue
} else {
const newLum = luminance * (1 - factor * 0.4)
const ratio = newLum / luminance
newR = Math.max(bgR * ratio, 0)
newG = Math.max(bgG * ratio, 0)
newB = Math.max(bgB * ratio, 0)
}
}
grays[i] = RGBA.fromInts(Math.floor(newR), Math.floor(newG), Math.floor(newB))
}
return grays
}
function generateMutedTextColor(bg: RGBA, isDark: boolean): RGBA {
// RGBA stores floats in range 0-1, convert to 0-255
const bgR = bg.r * 255
const bgG = bg.g * 255
const bgB = bg.b * 255
const bgLum = 0.299 * bgR + 0.587 * bgG + 0.114 * bgB
let grayValue: number
if (isDark) {
if (bgLum < 10) {
// Very dark/black background
grayValue = 180 // #b4b4b4
} else {
// Scale up for lighter dark backgrounds
grayValue = Math.min(Math.floor(160 + bgLum * 0.3), 200)
}
} else {
if (bgLum > 245) {
// Very light/white background
grayValue = 75 // #4b4b4b
} else {
// Scale down for darker light backgrounds
grayValue = Math.max(Math.floor(100 - (255 - bgLum) * 0.2), 60)
}
}
return RGBA.fromInts(grayValue, grayValue, grayValue)
}
export function generateSyntax(theme: Theme) {
return SyntaxStyle.fromTheme(getSyntaxRules(theme))
}

View file

@ -0,0 +1,144 @@
import { RGBA, type TerminalColors } from "@opentui/core"
import { ansiToRgba, tint } from "./color"
export function terminalMode(colors: TerminalColors): "dark" | "light" | undefined {
const bg = colors.defaultBackground
if (!bg) return
const { r, g, b } = RGBA.fromHex(bg)
return 0.299 * r + 0.587 * g + 0.114 * b > 0.5 ? "light" : "dark"
}
export function generateSystem(colors: TerminalColors, mode: "dark" | "light") {
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)
const isDark = mode === "dark"
const col = (index: number) => {
const value = colors.palette[index]
if (value) return RGBA.fromHex(value)
return ansiToRgba(index)
}
const grays = generateGrayScale(bg, isDark)
const textMuted = generateMutedTextColor(bg, isDark)
const ansiColors = {
red: col(1),
green: col(2),
yellow: col(3),
blue: col(4),
magenta: col(5),
cyan: col(6),
redBright: col(9),
greenBright: col(10),
}
const diffAlpha = isDark ? 0.22 : 0.14
const diffAddedBg = tint(bg, ansiColors.green, diffAlpha)
const diffRemovedBg = tint(bg, ansiColors.red, diffAlpha)
const diffContextBg = grays[2]
const diffAddedLineNumberBg = tint(diffContextBg, ansiColors.green, diffAlpha)
const diffRemovedLineNumberBg = tint(diffContextBg, ansiColors.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,
},
}
}
function generateGrayScale(bg: RGBA, isDark: boolean): Record<number, RGBA> {
const grays: Record<number, RGBA> = {}
const bgR = bg.r * 255
const bgG = bg.g * 255
const bgB = bg.b * 255
const luminance = 0.299 * bgR + 0.587 * bgG + 0.114 * bgB
for (let i = 1; i <= 12; i++) {
const factor = i / 12
if (isDark && luminance < 10) {
const gray = Math.floor(factor * 0.4 * 255)
grays[i] = RGBA.fromInts(gray, gray, gray)
continue
}
if (!isDark && luminance > 245) {
const gray = Math.floor(255 - factor * 0.4 * 255)
grays[i] = RGBA.fromInts(gray, gray, gray)
continue
}
const next = isDark ? luminance + (255 - luminance) * factor * 0.4 : luminance * (1 - factor * 0.4)
const ratio = next / luminance
grays[i] = RGBA.fromInts(
Math.floor(Math.min(Math.max(bgR * ratio, 0), 255)),
Math.floor(Math.min(Math.max(bgG * ratio, 0), 255)),
Math.floor(Math.min(Math.max(bgB * ratio, 0), 255)),
)
}
return grays
}
function generateMutedTextColor(bg: RGBA, isDark: boolean): RGBA {
const luminance = 0.299 * bg.r * 255 + 0.587 * bg.g * 255 + 0.114 * bg.b * 255
if (isDark) {
const gray = luminance < 10 ? 180 : Math.min(Math.floor(160 + luminance * 0.3), 200)
return RGBA.fromInts(gray, gray, gray)
}
const gray = luminance > 245 ? 75 : Math.max(Math.floor(100 - (255 - luminance) * 0.2), 60)
return RGBA.fromInts(gray, gray, gray)
}

View file

@ -2,8 +2,9 @@ import { expect, test } from "bun:test"
import { mkdir, writeFile } from "node:fs/promises"
import path from "node:path"
import type { TerminalColors } from "@opentui/core"
import { DEFAULT_THEMES, addTheme, allThemes, hasTheme, resolveTheme, terminalMode } from "../src/theme"
import { DEFAULT_THEMES, addTheme, allThemes, hasTheme, resolveTheme } from "../src/theme"
import { discoverThemes } from "../src/context/theme"
import { terminalMode } from "../src/theme/system"
import { tmpdir } from "./fixture/fixture"
test("addTheme writes into module theme store", () => {