diff --git a/packages/tui/src/component/bg-pulse.tsx b/packages/tui/src/component/bg-pulse.tsx index 58596ccb93..2112fe4420 100644 --- a/packages/tui/src/component/bg-pulse.tsx +++ b/packages/tui/src/component/bg-pulse.tsx @@ -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 & { diff --git a/packages/tui/src/component/logo.tsx b/packages/tui/src/component/logo.tsx index db8e8dc716..bc24b3c553 100644 --- a/packages/tui/src/component/logo.tsx +++ b/packages/tui/src/component/logo.tsx @@ -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() { diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index e9a22dff0b..55a7f2ee7d 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -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" diff --git a/packages/tui/src/context/theme.tsx b/packages/tui/src/context/theme.tsx index 62ab31792e..19fbeb6ec6 100644 --- a/packages/tui/src/context/theme.tsx +++ b/packages/tui/src/context/theme.tsx @@ -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, diff --git a/packages/tui/src/feature-plugins/system/diff-viewer-file-tree.tsx b/packages/tui/src/feature-plugins/system/diff-viewer-file-tree.tsx index cb15eaa503..53bea203fd 100644 --- a/packages/tui/src/feature-plugins/system/diff-viewer-file-tree.tsx +++ b/packages/tui/src/feature-plugins/system/diff-viewer-file-tree.tsx @@ -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" diff --git a/packages/tui/src/routes/session/form.tsx b/packages/tui/src/routes/session/form.tsx index d09673d775..922c8b1afd 100644 --- a/packages/tui/src/routes/session/form.tsx +++ b/packages/tui/src/routes/session/form.tsx @@ -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" diff --git a/packages/tui/src/theme/color.ts b/packages/tui/src/theme/color.ts new file mode 100644 index 0000000000..b704a3e933 --- /dev/null +++ b/packages/tui/src/theme/color.ts @@ -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)) +} diff --git a/packages/tui/src/theme/index.ts b/packages/tui/src/theme/index.ts index 27ac47bf0b..36263febae 100644 --- a/packages/tui/src/theme/index.ts +++ b/packages/tui/src/theme/index.ts @@ -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 { - const grays: Record = {} - - // 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)) } diff --git a/packages/tui/src/theme/system.ts b/packages/tui/src/theme/system.ts new file mode 100644 index 0000000000..4c584b7a59 --- /dev/null +++ b/packages/tui/src/theme/system.ts @@ -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 { + const grays: Record = {} + 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) +} diff --git a/packages/tui/test/theme.test.ts b/packages/tui/test/theme.test.ts index acdb2a39c4..767c0d3d48 100644 --- a/packages/tui/test/theme.test.ts +++ b/packages/tui/test/theme.test.ts @@ -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", () => {