diff --git a/packages/theme/src/tui/index.ts b/packages/theme/src/tui/index.ts index a9bca0c12ce..6fb827ed5bf 100644 --- a/packages/theme/src/tui/index.ts +++ b/packages/theme/src/tui/index.ts @@ -43,6 +43,7 @@ export type { StatefulColor, } from "./types.js" export { DEFAULT_CATEGORICAL } from "./categorical.js" +export { rgbToOklch } from "./color.js" export { expandTheme } from "./expand.js" export { migrateV1 } from "./v1-migrate.js" export { resolveTheme, resolveThemeDocument, themeDecodeError } from "./resolve.js" diff --git a/packages/tui/src/component/terminal-pane.tsx b/packages/tui/src/component/terminal-pane.tsx index d81814831fc..51ce39e5c1e 100644 --- a/packages/tui/src/component/terminal-pane.tsx +++ b/packages/tui/src/component/terminal-pane.tsx @@ -1,5 +1,5 @@ import { EmbeddedTerminalRenderable, type RGBA } from "@opentui/core" -import type { ResolvedThemeTokens } from "@opencode/theme/tui" +import { rgbToOklch, type ResolvedThemeTokens } from "@opencode/theme/tui" import { extend, useRenderer } from "@opentui/solid" import { createEffect, createSignal, onCleanup, onMount, Show } from "solid-js" import { useClient } from "../context/client" @@ -156,7 +156,7 @@ export function TerminalPane(props: { createEffect(() => { const tokens = themes.currentTokens() - terminalTheme = terminalPalette(tokens, tokens.background.raised.base) + terminalTheme = terminalPalette(tokens, themes.mode(), tokens.background.raised.base) applyTerminalTheme() }) @@ -335,26 +335,27 @@ function sameSize(first: TerminalSize | undefined, second: TerminalSize | undefi return !!first && !!second && first.cols === second.cols && first.rows === second.rows } -function terminalPalette(theme: ResolvedThemeTokens, background: RGBA) { - const base = 200 - const bright = 100 - const colors = [ - background, +export function terminalPalette(theme: ResolvedThemeTokens, mode: "light" | "dark", background: RGBA) { + const black = theme.hue.neutral[mode === "dark" ? 800 : 200] + const white = theme.hue.neutral[mode === "dark" ? 200 : 800] + const brightWhite = theme.hue.neutral[mode === "dark" ? 100 : 900] + const blue = terminalHue(theme, rgbToOklch(0, 0, 1).h) + const magenta = terminalHue(theme, rgbToOklch(1, 0, 1).h) + const normal = [ theme.text.feedback.error.base, theme.text.feedback.success.base, theme.text.feedback.warning.base, - theme.hue.blue[base], - theme.hue.purple[base], + blue, + magenta, theme.text.feedback.info.base, - theme.text.base, + white, + ] + const colors = [ + black, + ...normal, theme.text.muted, - theme.text.feedback.error.muted, - theme.text.feedback.success.muted, - theme.text.feedback.warning.muted, - theme.hue.blue[bright], - theme.hue.purple[bright], - theme.hue.cyan[bright], - theme.hue.neutral[100], + ...normal.slice(0, -1).map((color) => theme.decrease(color)), + brightWhite, ] return Buffer.from( colors @@ -364,6 +365,25 @@ function terminalPalette(theme: ResolvedThemeTokens, background: RGBA) { ) } +function terminalHue(theme: ResolvedThemeTokens, target: number) { + const nearest = Object.values(theme.hue) + .map((scale) => { + const color = scale[200] + const [red, green, blue, alpha] = color.toInts() + const converted = rgbToOklch(red / 255, green / 255, blue / 255) + return { color, alpha, chroma: converted.c, distance: hueDistance(converted.h, target) } + }) + .filter((item) => item.alpha > 0 && item.chroma >= 0.03) + .sort((first, second) => first.distance - second.distance)[0] + if (!nearest || nearest.distance > 30) return theme.hue.accent[200] + return nearest.color +} + +function hueDistance(first: number, second: number) { + const difference = Math.abs(first - second) + return Math.min(difference, 360 - difference) +} + function hex(color: RGBA) { return `#${color .toInts() diff --git a/packages/tui/test/component/terminal-pane-theme.test.ts b/packages/tui/test/component/terminal-pane-theme.test.ts new file mode 100644 index 00000000000..17dc7758a6a --- /dev/null +++ b/packages/tui/test/component/terminal-pane-theme.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from "bun:test" +import { resolveThemeDocument } from "@opencode/theme/tui" +import { terminalPalette } from "../../src/component/terminal-pane" +import { getOpenCodeTheme } from "../../src/theme" + +test.each(["light", "dark"] as const)("builds the %s ANSI palette from semantic colors", (mode) => { + const theme = resolveThemeDocument(getOpenCodeTheme(), mode) + const black = theme.hue.neutral[mode === "dark" ? 800 : 200] + const white = theme.hue.neutral[mode === "dark" ? 200 : 800] + const brightWhite = theme.hue.neutral[mode === "dark" ? 100 : 900] + const normal = [ + theme.text.feedback.error.base, + theme.text.feedback.success.base, + theme.text.feedback.warning.base, + theme.hue.blue[200], + mode === "dark" ? theme.hue.purple[200] : theme.hue.accent[200], + theme.text.feedback.info.base, + white, + ] + const expected = [ + black, + ...normal, + theme.text.muted, + ...normal.slice(0, -1).map((color) => theme.decrease(color)), + brightWhite, + ] + const output = terminalPalette(theme, mode, theme.background.raised.base).toString() + const palette = [...output.matchAll(/\]4;(\d+);(#[0-9a-f]+)/g)] + + expect(palette).toHaveLength(16) + expected.forEach((color, index) => { + expect(palette[index]?.[1]).toBe(String(index)) + expect(palette[index]?.[2]).toBe(hex(color)) + }) + expect(output).toContain(`]10;${hex(theme.text.base)}`) + expect(output).toContain(`]11;${hex(theme.background.raised.base)}`) +}) + +test("falls back to accent when no hue is confidently blue or magenta", () => { + const resolved = resolveThemeDocument(getOpenCodeTheme(), "dark") + const hue = { + ...Object.fromEntries(Object.keys(resolved.hue).map((name) => [name, resolved.hue.red])), + accent: resolved.hue.green, + } as typeof resolved.hue + const theme = { ...resolved, hue } + const palette = [ + ...terminalPalette(theme, "dark", theme.background.raised.base) + .toString() + .matchAll(/\]4;(\d+);(#[0-9a-f]+)/g), + ] + + expect(palette[4]?.[2]).toBe(hex(theme.hue.accent[200])) + expect(palette[5]?.[2]).toBe(hex(theme.hue.accent[200])) +}) + +function byte(value: number) { + return value.toString(16).padStart(2, "0") +} + +function hex(color: { toInts(): [number, number, number, number] }) { + return `#${color.toInts().slice(0, 3).map(byte).join("")}` +}