refactor(tui): derive bright terminal palette (#50433)

This commit is contained in:
James Long
2026-09-22 12:56:22 -04:00
committed by GitHub
parent 51d2b66760
commit cdccde7408
3 changed files with 100 additions and 17 deletions
+1
View File
@@ -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"
+37 -17
View File
@@ -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()
@@ -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("")}`
}