feat(theme): register native V2 opencode theme (#49846)

This commit is contained in:
James Long
2026-09-18 16:33:59 -04:00
committed by GitHub
parent ec19dc09c7
commit 168aba6183
27 changed files with 584 additions and 671 deletions
+3
View File
@@ -0,0 +1,3 @@
import type { HueName } from "./schema.js"
export const DEFAULT_CATEGORICAL = ["blue", "purple", "green", "orange", "red", "cyan"] as const satisfies readonly HueName[]
-452
View File
@@ -1,452 +0,0 @@
import type { BaseThemeDefinition, HueName, Mode, ThemeDefinition, ThemeDocument } from "./schema.js"
export const DEFAULT_CATEGORICAL = [
"blue",
"purple",
"green",
"orange",
"red",
"cyan",
] as const satisfies readonly HueName[]
const modes = {
light: {
hue: {
gray: {
100: "#111827",
200: "#1f2937",
300: "#374151",
400: "#4b5563",
500: "#6b7280",
600: "#9ca3af",
700: "#d1d5db",
800: "#e5e7eb",
900: "#f3f4f6",
},
red: {
100: "#7f1d1d",
200: "#991b1b",
300: "#b91c1c",
400: "#dc2626",
500: "#ef4444",
600: "#f87171",
700: "#fca5a5",
800: "#fecaca",
900: "#fee2e2",
},
orange: {
100: "#7c2d12",
200: "#9a3412",
300: "#c2410c",
400: "#ea580c",
500: "#f97316",
600: "#fb923c",
700: "#fdba74",
800: "#fed7aa",
900: "#ffedd5",
},
yellow: {
100: "#713f12",
200: "#854d0e",
300: "#a16207",
400: "#ca8a04",
500: "#eab308",
600: "#facc15",
700: "#fde047",
800: "#fef08a",
900: "#fef9c3",
},
green: {
100: "#14532d",
200: "#166534",
300: "#15803d",
400: "#16a34a",
500: "#22c55e",
600: "#4ade80",
700: "#86efac",
800: "#bbf7d0",
900: "#dcfce7",
},
cyan: {
100: "#164e63",
200: "#155e75",
300: "#0e7490",
400: "#0891b2",
500: "#06b6d4",
600: "#22d3ee",
700: "#67e8f9",
800: "#a5f3fc",
900: "#cffafe",
},
blue: {
100: "#1e3a8a",
200: "#1e40af",
300: "#1d4ed8",
400: "#2563eb",
500: "#3b82f6",
600: "#60a5fa",
700: "#93c5fd",
800: "#bfdbfe",
900: "#dbeafe",
},
purple: {
100: "#581c87",
200: "#6b21a8",
300: "#7e22ce",
400: "#9333ea",
500: "#a855f7",
600: "#c084fc",
700: "#d8b4fe",
800: "#e9d5ff",
900: "#f3e8ff",
},
accent: "$hue.blue",
interactive: "$hue.blue",
neutral: "$hue.gray",
},
categorical: DEFAULT_CATEGORICAL,
text: {
base: "$hue.neutral.200",
muted: "$hue.neutral.400",
action: {
primary: { base: "$hue.neutral.800", $disabled: "$hue.neutral.500" },
secondary: { base: "$text.muted", $hovered: "$text.base" },
destructive: { base: "$hue.red.800", $disabled: "$hue.neutral.500" },
},
formfield: {
base: "$hue.neutral.200",
$focused: "$text.action.primary.base",
$pressed: "$hue.neutral.800",
$disabled: "$hue.neutral.500",
$selected: "$hue.interactive.300",
},
status: {
running: "$hue.interactive.200",
question: "$text.status.unread",
permission: "$text.status.unread",
unread: "$hue.accent.200",
},
feedback: {
error: { base: "$hue.red.300", muted: "$hue.red.400" },
warning: { base: "$hue.yellow.200", muted: "$hue.yellow.300" },
success: { base: "$hue.green.300", muted: "$hue.green.400" },
info: { base: "$hue.cyan.300", muted: "$hue.cyan.400" },
},
},
background: {
base: "$hue.neutral.800",
raised: {
base: "$hue.neutral.700",
high: "$hue.neutral.600",
max: "$hue.neutral.500",
},
action: {
primary: {
base: "$hue.interactive.400",
$hovered: "$hue.interactive.300",
$focused: "$hue.interactive.300",
$pressed: "$hue.interactive.200",
$selected: "$hue.interactive.300",
$disabled: "$hue.neutral.700",
},
secondary: { base: "transparent" },
destructive: {
base: "$hue.red.400",
$hovered: "$hue.red.300",
$focused: "$hue.red.300",
$pressed: "$hue.red.200",
$selected: "$hue.red.300",
$disabled: "$hue.neutral.700",
},
},
formfield: {
base: "$background.base",
$hovered: "$background.raised.base",
$focused: "$background.action.primary.base",
$pressed: "$hue.interactive.200",
$disabled: "$background.base",
$selected: "$background.formfield.base",
},
feedback: {
error: { base: "$background.base" },
warning: { base: "$background.base" },
success: { base: "$background.base" },
info: { base: "$background.base" },
},
},
border: { base: "$hue.neutral.700" },
scrollbar: { base: "$hue.neutral.600" },
diff: {
text: {
added: "$hue.green.300",
removed: "$hue.red.300",
context: "$hue.neutral.100",
hunkHeader: "$hue.purple.400",
},
background: { added: "$hue.green.900", removed: "$hue.red.900", context: "$hue.neutral.900" },
highlight: { added: "$hue.green.400", removed: "$hue.red.400" },
lineNumber: {
text: "$hue.neutral.400",
background: { added: "$hue.green.800", removed: "$hue.red.800" },
},
},
syntax: {
comment: "$hue.neutral.400",
keyword: "$hue.purple.400",
function: "$hue.accent.400",
variable: "$hue.neutral.100",
string: "$hue.green.300",
number: "$hue.yellow.200",
type: "$hue.yellow.500",
operator: "$hue.cyan.400",
punctuation: "$hue.neutral.100",
},
markdown: {
text: "$hue.neutral.100",
heading: "$hue.purple.400",
link: "$hue.accent.400",
linkText: "$hue.cyan.400",
code: "$hue.green.300",
blockQuote: "$hue.neutral.400",
emphasis: "$hue.yellow.500",
strong: "$hue.neutral.100",
horizontalRule: "$hue.neutral.700",
listItem: "$hue.accent.400",
listEnumeration: "$hue.cyan.400",
image: "$hue.accent.400",
imageText: "$hue.cyan.400",
codeBlock: "$hue.neutral.100",
},
"@dialog": {
text: { action: { primary: { base: "$hue.neutral.900" } } },
background: {
base: "$background.raised.base",
action: { primary: { base: "$hue.interactive.500", $hovered: "$background.raised.high" } },
},
},
},
dark: {
hue: {
gray: {
100: "#f3f4f6",
200: "#e5e7eb",
300: "#d1d5db",
400: "#9ca3af",
500: "#6b7280",
600: "#4b5563",
700: "#374151",
800: "#1f2937",
900: "#111827",
},
red: {
100: "#fee2e2",
200: "#fecaca",
300: "#fca5a5",
400: "#f87171",
500: "#ef4444",
600: "#dc2626",
700: "#b91c1c",
800: "#991b1b",
900: "#7f1d1d",
},
orange: {
100: "#ffedd5",
200: "#fed7aa",
300: "#fdba74",
400: "#fb923c",
500: "#f97316",
600: "#ea580c",
700: "#c2410c",
800: "#9a3412",
900: "#7c2d12",
},
yellow: {
100: "#fef9c3",
200: "#fef08a",
300: "#fde047",
400: "#facc15",
500: "#eab308",
600: "#ca8a04",
700: "#a16207",
800: "#854d0e",
900: "#713f12",
},
green: {
100: "#dcfce7",
200: "#bbf7d0",
300: "#86efac",
400: "#4ade80",
500: "#22c55e",
600: "#16a34a",
700: "#15803d",
800: "#166534",
900: "#14532d",
},
cyan: {
100: "#cffafe",
200: "#a5f3fc",
300: "#67e8f9",
400: "#22d3ee",
500: "#06b6d4",
600: "#0891b2",
700: "#0e7490",
800: "#155e75",
900: "#164e63",
},
blue: {
100: "#dbeafe",
200: "#bfdbfe",
300: "#93c5fd",
400: "#60a5fa",
500: "#3b82f6",
600: "#2563eb",
700: "#1d4ed8",
800: "#1e40af",
900: "#1e3a8a",
},
purple: {
100: "#f3e8ff",
200: "#e9d5ff",
300: "#d8b4fe",
400: "#c084fc",
500: "#a855f7",
600: "#9333ea",
700: "#7e22ce",
800: "#6b21a8",
900: "#581c87",
},
accent: "$hue.blue",
interactive: "$hue.blue",
neutral: "$hue.gray",
},
categorical: DEFAULT_CATEGORICAL,
text: {
base: "$hue.neutral.200",
muted: "$hue.neutral.400",
action: {
primary: { base: "$hue.neutral.200", $disabled: "$hue.neutral.500" },
secondary: { base: "$text.muted", $hovered: "$text.base" },
destructive: { base: "$hue.red.200", $disabled: "$hue.neutral.500" },
},
formfield: {
base: "$hue.neutral.200",
$focused: "$text.action.primary.base",
$pressed: "$hue.neutral.200",
$disabled: "$hue.neutral.500",
$selected: "$hue.interactive.500",
},
status: {
running: "$hue.interactive.200",
question: "$text.status.unread",
permission: "$text.status.unread",
unread: "$hue.accent.200",
},
feedback: {
error: { base: "$hue.red.300", muted: "$hue.red.400" },
warning: { base: "$hue.yellow.200", muted: "$hue.yellow.300" },
success: { base: "$hue.green.300", muted: "$hue.green.400" },
info: { base: "$hue.cyan.300", muted: "$hue.cyan.400" },
},
},
background: {
base: "$hue.neutral.800",
raised: {
base: "$hue.neutral.700",
high: "$hue.neutral.600",
max: "$hue.neutral.500",
},
action: {
primary: {
base: "$hue.interactive.500",
$hovered: "$hue.interactive.600",
$focused: "$hue.interactive.600",
$pressed: "$hue.interactive.800",
$selected: "$hue.interactive.600",
$disabled: "$hue.neutral.800",
},
secondary: { base: "transparent" },
destructive: {
base: "$hue.red.600",
$hovered: "$hue.red.700",
$focused: "$hue.red.700",
$pressed: "$hue.red.800",
$selected: "$hue.red.700",
$disabled: "$hue.neutral.800",
},
},
formfield: {
base: "$background.base",
$hovered: "$background.raised.base",
$focused: "$background.action.primary.base",
$pressed: "$hue.interactive.800",
$disabled: "$background.base",
$selected: "$background.formfield.base",
},
feedback: {
error: { base: "$background.base" },
warning: { base: "$background.base" },
success: { base: "$background.base" },
info: { base: "$background.base" },
},
},
border: { base: "$hue.neutral.700" },
scrollbar: { base: "$hue.neutral.600" },
diff: {
text: {
added: "$hue.green.300",
removed: "$hue.red.300",
context: "$hue.neutral.100",
hunkHeader: "$hue.purple.400",
},
background: { added: "$hue.green.900", removed: "$hue.red.900", context: "$hue.neutral.900" },
highlight: { added: "$hue.green.400", removed: "$hue.red.400" },
lineNumber: {
text: "$hue.neutral.400",
background: { added: "$hue.green.800", removed: "$hue.red.800" },
},
},
syntax: {
comment: "$hue.neutral.400",
keyword: "$hue.purple.400",
function: "$hue.accent.400",
variable: "$hue.neutral.100",
string: "$hue.green.300",
number: "$hue.yellow.200",
type: "$hue.yellow.500",
operator: "$hue.cyan.400",
punctuation: "$hue.neutral.100",
},
markdown: {
text: "$hue.neutral.100",
heading: "$hue.purple.400",
link: "$hue.accent.400",
linkText: "$hue.cyan.400",
code: "$hue.green.300",
blockQuote: "$hue.neutral.400",
emphasis: "$hue.yellow.500",
strong: "$hue.neutral.100",
horizontalRule: "$hue.neutral.700",
listItem: "$hue.accent.400",
listEnumeration: "$hue.cyan.400",
image: "$hue.accent.400",
imageText: "$hue.cyan.400",
codeBlock: "$hue.neutral.100",
},
"@dialog": {
text: { action: { primary: { base: "$hue.neutral.200" } } },
background: {
base: "$background.raised.base",
action: { primary: { base: "$hue.interactive.400", $hovered: "$background.raised.high" } },
},
},
},
} satisfies Record<Mode, ThemeDefinition>
const { hue: _, ...base } = modes.light
export const DEFAULT_THEME = {
version: 2,
base: base satisfies BaseThemeDefinition,
light: { hue: modes.light.hue },
dark: modes.dark,
} satisfies ThemeDocument
+1 -1
View File
@@ -42,7 +42,7 @@ export type {
ResolvedThemeTokens,
StatefulColor,
} from "./types.js"
export { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
export { DEFAULT_CATEGORICAL } from "./categorical.js"
export { expandTheme } from "./expand.js"
export { migrateV1 } from "./v1-migrate.js"
export { resolveTheme, resolveThemeDocument, themeDecodeError } from "./resolve.js"
-14
View File
@@ -93,14 +93,6 @@ const TextDefinition = Schema.Struct({
muted: Schema.optional(ColorValue),
action: Schema.optional(ActionColorDefinition),
formfield: Schema.optional(StatefulColorDefinition),
status: Schema.optional(
Schema.Struct({
running: Schema.optional(ColorValue),
question: Schema.optional(ColorValue),
permission: Schema.optional(ColorValue),
unread: Schema.optional(ColorValue),
}),
),
feedback: Schema.optional(
Schema.Struct({
error: Schema.optional(TextFeedbackDefinition),
@@ -234,12 +226,6 @@ const CompleteThemeTokensDefinition = Schema.Struct({
muted: ColorValue,
action: CompleteActionColorDefinition,
formfield: CompleteStatefulColorDefinition,
status: Schema.Struct({
running: ColorValue,
question: ColorValue,
permission: ColorValue,
unread: ColorValue,
}),
feedback: Schema.Struct({
error: CompleteTextFeedbackDefinition,
warning: CompleteTextFeedbackDefinition,
-6
View File
@@ -34,12 +34,6 @@ export type ResolvedThemeTokens = {
readonly muted: RGBA
readonly action: Readonly<Record<ActionVariant, StatefulColor>>
readonly formfield: FormfieldColor
readonly status: {
readonly running: RGBA
readonly question: RGBA
readonly permission: RGBA
readonly unread: RGBA
}
readonly feedback: Readonly<Record<FeedbackKind, { readonly base: RGBA; readonly muted: RGBA }>>
}
readonly background: {
+1 -7
View File
@@ -1,6 +1,6 @@
import { RGBA } from "@opentui/core"
import { oklchToHex, rgbToOklch } from "./color.js"
import { DEFAULT_CATEGORICAL } from "./defaults.js"
import { DEFAULT_CATEGORICAL } from "./categorical.js"
import type { BaseThemeDefinition, HueDefinition, Mode, ThemeDefinition, ThemeDocument } from "./index.js"
import { HueStep } from "./schema.js"
import type { Theme, ThemeV1Json } from "./v1.js"
@@ -130,12 +130,6 @@ function migrateMode(theme: Theme, mode: Mode): ThemeDefinition {
$disabled: textMuted,
$selected: primary,
},
status: {
running: "$hue.interactive.200",
question: "$text.status.unread",
permission: "$text.status.unread",
unread: "$hue.accent.200",
},
feedback: {
error: { base: color("error") },
warning: { base: color("warning") },
-73
View File
@@ -1,73 +0,0 @@
import { expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import { Schema } from "effect"
import { DEFAULT_THEME, ThemeDocument, migrateV1, resolveThemeDocument, selectTheme } from "../src/tui/index.js"
import type { ThemeV1Json } from "../src/tui/v1.js"
test.each(["light", "dark"] as const)("built-in %s themes resolve status colors", async (mode) => {
const source: ThemeV1Json = await Bun.file(
new URL("../../tui/src/theme/assets/opencode.json", import.meta.url),
).json()
for (const document of [DEFAULT_THEME, migrateV1(source)]) {
const theme = resolveThemeDocument(document, mode)
expect(theme.text.status.running.equals(theme.hue.interactive[200])).toBeTrue()
expect(theme.text.status.question.equals(theme.text.status.unread)).toBeTrue()
expect(theme.text.status.permission.equals(theme.text.status.unread)).toBeTrue()
expect(theme.text.status.unread.equals(theme.hue.accent[200])).toBeTrue()
expect(theme.surface("dialog").text.status).toEqual(theme.text.status)
}
})
test.each(["light", "dark"] as const)("custom %s themes inherit the unread attention color", (mode) => {
const base = selectTheme(DEFAULT_THEME, mode)
const definition = {
...base,
hue: { ...base.hue, accent: "$hue.purple" },
text: {
...base.text,
status: { ...base.text.status, unread: "#abcdef" },
},
}
const { hue, ...tokens } = definition
const theme = resolveThemeDocument(
Schema.decodeUnknownSync(ThemeDocument)({
version: 2,
base: tokens,
[mode]: { hue },
}),
mode,
)
expect(theme.text.status.unread.equals(RGBA.fromHex("#abcdef"))).toBeTrue()
expect(theme.text.status.question.equals(theme.text.status.unread)).toBeTrue()
expect(theme.text.status.permission.equals(theme.text.status.unread)).toBeTrue()
expect(theme.surface("dialog").text.status).toEqual(theme.text.status)
})
test.each(["light", "dark"] as const)("custom %s themes inherit and override status colors", (mode) => {
const base = selectTheme(DEFAULT_THEME, mode)
const definition = {
...base,
hue: { ...base.hue, interactive: "$hue.purple", accent: "$hue.orange" },
text: {
...base.text,
status: {
...base.text.status,
question: "#123456",
permission: "#654321",
},
},
}
const { hue, ...tokens } = definition
const theme = resolveThemeDocument(
Schema.decodeUnknownSync(ThemeDocument)({
version: 2,
base: tokens,
[mode]: { hue },
}),
mode,
)
expect(theme.text.status.running.equals(theme.hue.purple[200])).toBeTrue()
expect(theme.text.status.unread.equals(theme.hue.orange[200])).toBeTrue()
expect(theme.text.status.question.equals(RGBA.fromHex("#123456"))).toBeTrue()
expect(theme.text.status.permission.equals(RGBA.fromHex("#654321"))).toBeTrue()
})
+15
View File
@@ -0,0 +1,15 @@
import { expect, test } from "bun:test"
import { migrateV1, resolveThemeDocument } from "../src/tui/index.js"
import type { ThemeV1Json } from "../src/tui/v1.js"
const source: ThemeV1Json = await Bun.file(
new URL("../../tui/src/theme/assets/opencode.json", import.meta.url),
).json()
const document = migrateV1(source)
test.each(["light", "dark"] as const)("resolves %s themes without status tokens", (mode) => {
const theme = resolveThemeDocument(document, mode)
expect("status" in theme.text).toBeFalse()
expect(theme.hue.accent[800]).toBeDefined()
expect(theme.hue.interactive[800]).toBeDefined()
})
+5 -5
View File
@@ -120,7 +120,7 @@ const glowTextColor = (base: RGBA, glow: RGBA, index: number, width: number, lev
tint(base, glow, 0.12 * unreadGlowIntensity(index, width) * level)
function tabFeedbackColor(status: SessionTabsStatus, theme: ReturnType<typeof useTheme>) {
if (status.attention) return theme.text.status[status.attention]
if (status.attention) return theme.hue.interactive[800]
if (status.unread === "error") return theme.text.feedback.error.base
return undefined
}
@@ -528,8 +528,8 @@ function VerticalSessionTabs(props: {
const compact = createMemo(() => width() < SESSION_TABS_COMPACT_BREAKPOINT)
const tooltipWidth = () => Math.min(54, dimensions().width - width())
const stride = () => (compact() ? 2 : 3)
const unreadColor = () => theme.text.status.unread
const activeNumber = () => theme.text.status.running
const unreadColor = () => theme.hue.interactive[800]
const activeNumber = () => theme.hue.accent[800]
const idleNumber = () => tint(theme.text.formfield.base, background(), 0.55)
const separatorUpperPulseColor = createMemo(() => tint(background(), theme.text.base, 0.04))
const separatorLowerPulseColor = createMemo(() => tint(background(), theme.text.base, 0.05))
@@ -1291,8 +1291,8 @@ function HorizontalSessionTabs(props: {
onCleanup(clearCloseHold)
// A captured drag ends with a synthetic up on its drop target; do not turn that into a click.
let suppressClick = false
const unreadColor = () => theme.text.status.unread
const activeNumber = () => theme.text.status.running
const unreadColor = () => theme.hue.interactive[800]
const activeNumber = () => theme.hue.accent[800]
const idleNumber = () => tint(theme.text.formfield.base, theme.background.base, 0.55)
const newTab = () => tabs.newTab?.() ?? false
const activeID = createMemo(() => (newTab() ? NEW_SESSION_TAB.sessionID : tabs.current()))
@@ -32,7 +32,7 @@ export default Plugin.define({
return (
<Show when={pending() > 0}>
<box flexShrink={0}>
<Spinner color={theme.text.status.running}>/btw</Spinner>
<Spinner color={theme.hue.accent[800]}>/btw</Spinner>
</box>
</Show>
)
@@ -181,7 +181,7 @@ function OneCellSpinnerStory(props: { context: Plugin.Context }) {
animations={animations()}
paused={paused()}
glow={glow()}
color={theme.text.status.running}
color={theme.hue.accent[800]}
/>
</box>
)}
@@ -236,7 +236,7 @@ function OneCellSpinnerStory(props: { context: Plugin.Context }) {
animations={animations()}
paused={paused()}
glow={glow()}
color={theme.text.status.running}
color={theme.hue.accent[800]}
/>
<text fg={theme.text.base}> esc stop</text>
</box>
@@ -284,10 +284,10 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
{ text: "", color: theme.text.base },
)
if (status.attention === "question")
lines.push({ text: "? Which approach should I take?", color: theme.text.status.question })
lines.push({ text: "? Which approach should I take?", color: theme.hue.interactive[800] })
else if (status.attention === "permission")
lines.push({ text: "! Waiting for permission to run the command", color: theme.text.status.permission })
else if (status.busy) lines.push({ text: "● Working…", color: theme.text.status.running })
lines.push({ text: "! Waiting for permission to run the command", color: theme.hue.interactive[800] })
else if (status.busy) lines.push({ text: "● Working…", color: theme.hue.accent[800] })
else if (outcome === "failed")
lines.push({
text: `✗ bun run test failed — 3 tests failing in ${file}`,
+9 -9
View File
@@ -1,6 +1,6 @@
import { RGBA, SyntaxStyle, type CliRenderer, type ColorInput, type TerminalColors } from "@opentui/core"
import { generateSyntax, resolveThemeDocument, themeModes, type ResolvedTheme } from "@opencode/theme/tui"
import { allThemes, DEFAULT_THEMES, isThemeSource, parseTheme, type ThemeDocumentSource } from "../theme"
import { allThemes, getOpenCodeTheme, isThemeSource, parseTheme, type ThemeDocumentSource } from "../theme"
import { ansiToRgba } from "../theme/color"
import { discoverThemes } from "../theme/discovery"
import { generateSystem, terminalMode } from "../theme/system"
@@ -142,9 +142,9 @@ function map(
formfieldFocusedBg: exact(theme.background.formfield.focused),
formfieldFocusedText: exact(theme.text.formfield.focused),
selection: exact(theme.text.formfield.selected),
running: exact(theme.text.status.running),
question: exact(theme.text.status.question),
permission: exact(theme.text.status.permission),
running: exact(theme.hue.accent[800]),
question: exact(theme.hue.interactive[800]),
permission: exact(theme.hue.interactive[800]),
success: exact(theme.text.feedback.success.base),
link: exact(theme.markdown.link),
categorical: dedupeWith(
@@ -192,11 +192,11 @@ function map(
}
export const RUN_THEME_FALLBACK = map(
resolveThemeDocument(parseTheme(DEFAULT_THEMES.opencode), "dark"),
resolveThemeDocument(parseTheme(getOpenCodeTheme()), "dark"),
ansiPalette,
)
export const RUN_THEME_FALLBACK_LIGHT = map(
resolveThemeDocument(parseTheme(DEFAULT_THEMES.opencode), "light"),
resolveThemeDocument(parseTheme(getOpenCodeTheme()), "light"),
ansiPalette,
)
@@ -292,7 +292,7 @@ export async function resolveRunTheme(
if (themeModes(document).includes(mode)) return resolveThemeDocument(document, mode)
})
.catch(() => undefined)
const theme = resolved ?? resolveThemeDocument(parseTheme(DEFAULT_THEMES.opencode), mode)
const theme = resolved ?? resolveThemeDocument(parseTheme(getOpenCodeTheme()), mode)
const indexed = colors
? ansiPalette.map((color, index) => (colors.palette[index] ? RGBA.fromIndex(index, colors.palette[index]!) : color))
: ansiPalette
@@ -312,6 +312,6 @@ async function themeSource(
const custom = await discoverThemes(
configDirectories(process.env.OPENCODE_CONFIG_DIR ?? Global.Path.config, process.cwd()),
)
const source = custom[name] ?? allThemes()[name] ?? DEFAULT_THEMES.opencode
return isThemeSource(source) ? source : DEFAULT_THEMES.opencode
const source = custom[name] ?? allThemes()[name] ?? getOpenCodeTheme()
return isThemeSource(source) ? source : getOpenCodeTheme()
}
@@ -0,0 +1,390 @@
{
"$schema": "https://opencode.ai/theme.json",
"version": 2,
"base": {
"categorical": [
"purple",
"orange",
"green",
"blue",
"red"
],
"text": {
"base": "$hue.neutral.200",
"muted": "$hue.neutral.400",
"action": {
"primary": {
"base": "$text.base",
"$disabled": "$hue.neutral.400",
"$focused": "$hue.neutral.900",
"$selected": "$hue.interactive.200"
},
"secondary": {
"base": "$text.muted",
"$hovered": "$text.base"
},
"destructive": {
"base": "$hue.neutral.900",
"$disabled": "$hue.neutral.400"
}
},
"formfield": {
"base": "$hue.neutral.200",
"$hovered": "$hue.interactive.200",
"$focused": "$hue.interactive.200",
"$pressed": "$hue.interactive.200",
"$disabled": "$hue.neutral.400",
"$selected": "$hue.interactive.200"
},
"feedback": {
"error": {
"base": "$hue.red.200"
},
"warning": {
"base": "$hue.accent.200"
},
"success": {
"base": "$hue.green.200"
},
"info": {
"base": "$hue.cyan.200"
}
}
},
"background": {
"base": "$hue.neutral.800",
"raised": {
"base": "$hue.neutral.700",
"high": "$hue.neutral.600",
"max": "$hue.neutral.500"
},
"action": {
"primary": {
"base": "transparent",
"$hovered": "$hue.neutral.700",
"$focused": "$hue.interactive.200",
"$selected": "transparent"
},
"secondary": {
"base": "transparent"
},
"destructive": {
"base": "$hue.red.200"
}
},
"formfield": {
"base": "$background.base"
},
"feedback": {
"error": {
"base": "$background.base"
},
"warning": {
"base": "$background.base"
},
"success": {
"base": "$background.base"
},
"info": {
"base": "$background.base"
}
}
},
"border": {
"base": "#b8b8b8"
},
"scrollbar": {
"base": "#a0a0a0"
},
"diff": {
"text": {
"added": "#1e725c",
"removed": "#c53b53",
"context": "#7086b5",
"hunkHeader": "#7086b5"
},
"background": {
"added": "#d5e5d5",
"removed": "#f7d8db",
"context": "$hue.neutral.700"
},
"highlight": {
"added": "#4db380",
"removed": "#f52a65"
},
"lineNumber": {
"text": "#595959",
"background": {
"added": "#c5d5c5",
"removed": "#e7c8cb"
}
}
},
"syntax": {
"comment": "$hue.neutral.400",
"keyword": "$hue.accent.200",
"function": "$hue.interactive.200",
"variable": "$hue.red.200",
"string": "$hue.green.200",
"number": "$hue.accent.200",
"type": "#b0851f",
"operator": "$hue.cyan.200",
"punctuation": "$hue.neutral.200"
},
"markdown": {
"text": "$hue.neutral.200",
"heading": "$hue.accent.200",
"link": "$hue.interactive.200",
"linkText": "$hue.cyan.200",
"code": "$hue.green.200",
"blockQuote": "#b0851f",
"emphasis": "#b0851f",
"strong": "$hue.accent.200",
"horizontalRule": "$hue.neutral.400",
"listItem": "$hue.interactive.200",
"listEnumeration": "$hue.cyan.200",
"image": "$hue.interactive.200",
"imageText": "$hue.cyan.200",
"codeBlock": "$hue.neutral.200"
},
"@dialog": {
"background": {
"base": "$background.raised.base",
"action": {
"primary": {
"$hovered": "$background.raised.high"
}
}
}
}
},
"light": {
"hue": {
"gray": {
"100": "#000000",
"200": "#1a1a1a",
"300": "#4e4e4e",
"400": "#8a8a8a",
"500": "#bebebe",
"600": "#f5f5f5",
"700": "#fafafa",
"800": "#ffffff",
"900": "#ffffff"
},
"red": {
"100": "#c20f26",
"200": "#d1383d",
"300": "#e05352",
"400": "#ee6b68",
"500": "#fc837d",
"600": "#fda19b",
"700": "#fcbeb9",
"800": "#fed8d5",
"900": "#fff2f0"
},
"orange": {
"100": "#c7811e",
"200": "#d68c27",
"300": "#df9a44",
"400": "#e8a85c",
"500": "#f1b671",
"600": "#fac386",
"700": "#fcd4a8",
"800": "#fee3c7",
"900": "#fef3e7"
},
"yellow": "$hue.gray",
"green": {
"100": "#1f8c44",
"200": "#3d9a57",
"300": "#55a96a",
"400": "#6cb77c",
"500": "#81c68f",
"600": "#96d5a3",
"700": "#ace4b6",
"800": "#c1f3ca",
"900": "#e1fee6"
},
"cyan": {
"100": "#077786",
"200": "#318795",
"300": "#4c97a4",
"400": "#64a8b4",
"500": "#7bb8c4",
"600": "#92c9d4",
"700": "#a8dbe4",
"800": "#bfecf4",
"900": "#e3fafe"
},
"blue": {
"100": "#226bcc",
"200": "#3b7dd8",
"300": "#518ee4",
"400": "#67a0f0",
"500": "#7db1fc",
"600": "#9cc3fa",
"700": "#b7d4fd",
"800": "#d4e5fd",
"900": "#f0f6fe"
},
"purple": {
"100": "#6b47a7",
"200": "#7b5bb6",
"300": "#8c6fc5",
"400": "#9d83d4",
"500": "#af98e3",
"600": "#c1acf2",
"700": "#d3c3fc",
"800": "#e4dbfd",
"900": "#f6f3ff"
},
"accent": "$hue.orange",
"interactive": "$hue.blue",
"neutral": "$hue.gray"
}
},
"dark": {
"hue": {
"gray": {
"100": "#ffffff",
"200": "#eeeeee",
"300": "#b5b5b5",
"400": "#808080",
"500": "#4c4c4c",
"600": "#1e1e1e",
"700": "#141414",
"800": "#0a0a0a",
"900": "#030303"
},
"red": {
"100": "#fd7e87",
"200": "#e06c75",
"300": "#c35a63",
"400": "#a74952",
"500": "#8c3941",
"600": "#722931",
"700": "#591921",
"800": "#410a13",
"900": "#280207"
},
"orange": {
"100": "#fddac5",
"200": "#fab283",
"300": "#d8976c",
"400": "#b67c56",
"500": "#966341",
"600": "#774a2c",
"700": "#593319",
"800": "#3d1d06",
"900": "#1f0b01"
},
"yellow": "$hue.gray",
"green": {
"100": "#96f7a7",
"200": "#7fd88f",
"300": "#68b977",
"400": "#539c61",
"500": "#3d7f4b",
"600": "#296336",
"700": "#144922",
"800": "#00300f",
"900": "#011705"
},
"cyan": {
"100": "#68d0dd",
"200": "#56b6c2",
"300": "#449da7",
"400": "#33848e",
"500": "#226c75",
"600": "#0f555d",
"700": "#093e44",
"800": "#02292e",
"900": "#001518"
},
"blue": {
"100": "#82b4fb",
"200": "#5c9cf5",
"300": "#4c86d6",
"400": "#3c70b8",
"500": "#2c5b9b",
"600": "#1d477f",
"700": "#0f3364",
"800": "#02214a",
"900": "#01112b"
},
"purple": {
"100": "#b38ff4",
"200": "#9d7cd8",
"300": "#8869bd",
"400": "#7357a2",
"500": "#5f4688",
"600": "#4b356f",
"700": "#392557",
"800": "#271640",
"900": "#17072b"
},
"accent": "$hue.purple",
"interactive": "$hue.orange",
"neutral": "$hue.gray"
},
"categorical": [
"blue",
"purple",
"green",
"orange",
"red"
],
"text": {
"action": {
"primary": {
"$focused": "$hue.neutral.800"
},
"destructive": {
"base": "$hue.neutral.800"
}
},
"feedback": {
"warning": {
"base": "#f5a742"
}
}
},
"border": {
"base": "#484848"
},
"scrollbar": {
"base": "#606060"
},
"diff": {
"text": {
"added": "#4fd6be",
"context": "#828bb8",
"hunkHeader": "#828bb8"
},
"background": {
"added": "#20303b",
"removed": "#37222c"
},
"highlight": {
"added": "#b8db87",
"removed": "#e26a75"
},
"lineNumber": {
"text": "#8f8f8f",
"background": {
"added": "#1b2b34",
"removed": "#2d1f26"
}
}
},
"syntax": {
"number": "#f5a742",
"type": "#e5c07b"
},
"markdown": {
"blockQuote": "#e5c07b",
"emphasis": "#e5c07b",
"strong": "#f5a742"
}
}
}
+14 -1
View File
@@ -1,7 +1,8 @@
import { Schema } from "effect"
import { migrateV1, resolveThemeDocument, ThemeDocument, themeDecodeError } from "@opencode/theme/tui"
import { migrateV1, resolveThemeDocument, ThemeDocument, themeDecodeError, type ModeDefinition } from "@opencode/theme/tui"
import { resolveThemeColors } from "./resolve"
import { DEFAULT_THEMES, type Theme, type ThemeV1Json } from "./v1"
import opencode from "./assets/v2/opencode.json" with { type: "json" }
export { DEFAULT_THEMES, generateSyntax, selectedForeground, type Theme, type ThemeV1Json } from "./v1"
export { resolveThemeDocument, type ThemeDocument }
@@ -14,11 +15,23 @@ let systemTheme: ThemeDocumentSource | undefined
const listeners = new Set<(themes: Record<string, ThemeDocumentSource>) => void>()
const parsed = new WeakMap<object, ThemeDocument>()
const decodeThemeDocument = Schema.decodeUnknownSync(ThemeDocument, { reportInput: true })
let opencodeTheme: (ThemeDocument & {
readonly light: ModeDefinition
readonly dark: ModeDefinition
}) | undefined
export function getOpenCodeTheme() {
if (opencodeTheme) return opencodeTheme
const decoded = decodeThemeDocument(opencode) as NonNullable<typeof opencodeTheme>
opencodeTheme = decoded
return decoded
}
function listThemes(): Record<string, ThemeDocumentSource> {
// Priority: defaults < plugin installs < custom files < generated system.
const themes: Record<string, ThemeDocumentSource> = {
...DEFAULT_THEMES,
opencode: getOpenCodeTheme(),
...pluginThemes,
...customThemes,
}
-2
View File
@@ -22,7 +22,6 @@ import monokai from "./assets/monokai.json" with { type: "json" }
import nightowl from "./assets/nightowl.json" with { type: "json" }
import nord from "./assets/nord.json" with { type: "json" }
import onedark from "./assets/one-dark.json" with { type: "json" }
import opencode from "./assets/opencode.json" with { type: "json" }
import orng from "./assets/orng.json" with { type: "json" }
import osakaJade from "./assets/osaka-jade.json" with { type: "json" }
import palenight from "./assets/palenight.json" with { type: "json" }
@@ -58,7 +57,6 @@ export const DEFAULT_THEMES: Record<string, ThemeV1Json> = {
nord,
["one-dark"]: onedark,
["osaka-jade"]: osakaJade,
opencode,
orng,
["lucent-orng"]: lucentOrng,
palenight,
@@ -2,9 +2,10 @@
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import { DEFAULT_THEME } from "@opencode/theme/tui"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { DEFAULT_THEMES } from "../../../src/theme"
import { getOpenCodeTheme } from "../../../src/theme"
import opencodeSource from "../../../src/theme/assets/opencode.json" with { type: "json" }
import type { ThemeV1Json } from "@opencode/theme/tui/v1"
import { ConfigProvider } from "../../../src/config"
import { ThemeContextProvider, ThemeProvider, type ThemeError, useTheme, useThemes } from "../../../src/context/theme"
@@ -17,19 +18,20 @@ async function wait(fn: () => boolean) {
}
test("uses an available mode while retaining the pinned preference", async () => {
const lightOnly = structuredClone(DEFAULT_THEMES.opencode)
const opencodeV1 = opencodeSource as ThemeV1Json
const lightOnly = structuredClone(opencodeV1)
lightOnly.theme.background = "#eeeeee"
lightOnly.theme.text = "#111111"
const dual = structuredClone(DEFAULT_THEMES.opencode)
const dual = structuredClone(opencodeV1)
dual.theme.background = { light: "#eeeeee", dark: "#111111" }
dual.theme.text = { light: "#111111", dark: "#eeeeee" }
const darkOnly = structuredClone(DEFAULT_THEMES.opencode)
const darkOnly = structuredClone(opencodeV1)
darkOnly.theme.background = "#111111"
darkOnly.theme.text = "#eeeeee"
const native = {
version: 2,
base: { ...DEFAULT_THEME.base, text: { ...DEFAULT_THEME.base.text, base: "#abcdef" } },
dark: { hue: DEFAULT_THEME.dark.hue },
base: { ...getOpenCodeTheme().base, text: { ...getOpenCodeTheme().base.text, base: "#abcdef" } },
dark: { hue: getOpenCodeTheme().dark.hue },
} as const
let themes: ReturnType<typeof useThemes> | undefined
@@ -139,7 +139,7 @@ for (const orientation of ["horizontal", "vertical"] as const) {
.captureSpans()
.lines.flatMap((line) => line.spans)
.find((span) => span.text.trim() === (attention === "question" ? "?" : "!"))?.fg
expect(indicatorColor()?.toInts()).toEqual(theme.text.status[attention].toInts())
expect(indicatorColor()?.toInts()).toEqual(theme.hue.interactive[800].toInts())
const glow = () => {
const colors = app
.captureSpans()
@@ -156,7 +156,7 @@ for (const orientation of ["horizontal", "vertical"] as const) {
expect(full).toBeGreaterThan(0)
setActive("first")
await app.renderOnce()
expect(indicatorColor()?.toInts()).toEqual(theme.text.status[attention].toInts())
expect(indicatorColor()?.toInts()).toEqual(theme.hue.interactive[800].toInts())
const dim = glow()
expect(dim).toBeGreaterThan(0)
expect(dim).toBeLessThan(full)
@@ -172,7 +172,7 @@ for (const orientation of ["horizontal", "vertical"] as const) {
.lines.flatMap((line) => line.spans)
.find((span) => span.text.trim() === glyph)?.fg
expect(color?.toInts()).toEqual(
(unread === "error" ? theme.text.feedback.error.base : theme.text.status.unread).toInts(),
(unread === "error" ? theme.text.feedback.error.base : theme.hue.interactive[800]).toInts(),
)
await app.mockMouse.click(1, orientation === "vertical" ? 1 : 0)
await app.renderOnce()
+3 -3
View File
@@ -1,7 +1,6 @@
import { expect, test } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
import { CliRenderEvents, RGBA, TextRenderable } from "@opentui/core"
import { DEFAULT_THEME } from "@opencode/theme/tui"
import path from "node:path"
import { Writable } from "node:stream"
import { coalesceProgressCommit, resolveRunAgent, RunFooter } from "../../src/mini/footer"
@@ -12,6 +11,7 @@ import type { MiniSettingChange, MiniSettings, RunAgent, RunTuiConfig, StreamCom
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
import { tmpdir } from "../fixture/fixture"
import { createFooterApiFixture } from "./fixture/footer-api"
import { getOpenCodeTheme } from "../../src/theme"
function progress(input: Partial<StreamCommit> = {}): StreamCommit {
return {
@@ -373,8 +373,8 @@ test("explicit theme refresh reloads custom colors without a palette event", asy
path.join(tmp.path, "themes", "mini-refresh.json"),
JSON.stringify({
version: 2,
base: { ...DEFAULT_THEME.base, text: { ...DEFAULT_THEME.base.text, base: color } },
dark: { hue: DEFAULT_THEME.dark.hue },
base: { ...getOpenCodeTheme().base, text: { ...getOpenCodeTheme().base.text, base: color } },
dark: { hue: getOpenCodeTheme().dark.hue },
}),
)
await app.footer.refreshTheme()
+20 -14
View File
@@ -1,7 +1,7 @@
import { afterAll, beforeAll, expect, test } from "bun:test"
import path from "node:path"
import { RGBA, type CliRenderer, type TerminalColors } from "@opentui/core"
import { DEFAULT_THEME, resolveThemeDocument, selectTheme, type ResolvedTheme } from "@opencode/theme/tui"
import { resolveThemeDocument, selectTheme, type ResolvedTheme } from "@opencode/theme/tui"
import {
RUN_THEME_MONO,
RUN_THEME_FALLBACK,
@@ -9,7 +9,7 @@ import {
resolveRunTheme,
type RunTheme,
} from "../../src/mini/theme"
import { DEFAULT_THEMES, parseTheme } from "../../src/theme"
import { DEFAULT_THEMES, getOpenCodeTheme, parseTheme } from "../../src/theme"
import { generateSystem } from "../../src/theme/system"
import { tmpdir } from "../fixture/fixture"
@@ -90,9 +90,9 @@ function expectFooter(actual: RunTheme, theme: ResolvedTheme) {
formfieldFocusedBg: theme.background.formfield.focused,
formfieldFocusedText: theme.text.formfield.focused,
selection: theme.text.formfield.selected,
running: theme.text.status.running,
question: theme.text.status.question,
permission: theme.text.status.permission,
running: theme.hue.accent[800],
question: theme.hue.interactive[800],
permission: theme.hue.interactive[800],
success: theme.text.feedback.success.base,
link: theme.markdown.link,
shade: theme.background.raised.base,
@@ -136,7 +136,10 @@ test.each(["light", "dark"] as const)("uses shared %s defaults and named built-i
const colors = terminalColors({}, mode)
for (const name of [undefined, "opencode", "tokyonight"] as const) {
const theme = await resolveRunTheme(renderer({ colors }), { name, mode })
const expected = resolveThemeDocument(parseTheme(DEFAULT_THEMES[name ?? "opencode"]), mode)
const expected = resolveThemeDocument(
name === undefined || name === "opencode" ? getOpenCodeTheme() : parseTheme(DEFAULT_THEMES[name]),
mode,
)
try {
expectFooter(theme, expected)
expect(rgba(theme.background).toInts()).toEqual(RGBA.fromHex(colors.defaultBackground!).toInts())
@@ -187,7 +190,7 @@ test.each(["light", "dark"] as const)("shares the %s system scheme while retaini
test.each(["light", "dark"] as const)(
"loads complete %s custom theme files",
async (mode) => {
const base = selectTheme(DEFAULT_THEME, mode)
const base = selectTheme(getOpenCodeTheme(), mode)
const definition = {
...base,
text: {
@@ -236,7 +239,7 @@ test.each(["light", "dark"] as const)(
test.each(["light", "dark"] as const)(
"falls back to shared %s defaults for unknown or invalid themes",
async (mode) => {
const expected = resolveThemeDocument(parseTheme(DEFAULT_THEMES.opencode), mode)
const expected = resolveThemeDocument(getOpenCodeTheme(), mode)
for (const source of [
{ version: 2, [mode]: { categorical: [] } },
{ version: 2, [mode]: { text: { base: "$missing" } } },
@@ -263,7 +266,10 @@ test.each(["light", "dark"] as const)("resolves dark-only Aura on an automatic %
})
const theme = await resolveRunTheme(renderer({ colors }), { name: "aura" })
try {
expectFooter(theme, resolveThemeDocument(parseTheme(DEFAULT_THEMES[mode === "light" ? "opencode" : "aura"]), mode))
expectFooter(
theme,
resolveThemeDocument(mode === "light" ? getOpenCodeTheme() : parseTheme(DEFAULT_THEMES.aura), mode),
)
expect(rgba(theme.background).toInts()).toEqual(RGBA.fromHex(colors.defaultBackground!).toInts())
} finally {
theme.block.syntax?.destroy()
@@ -273,7 +279,7 @@ test.each(["light", "dark"] as const)("resolves dark-only Aura on an automatic %
test.each(["light", "dark"] as const)(
"falls back only for unsupported modes of a %s-only custom theme",
async (mode) => {
const base = selectTheme(DEFAULT_THEME, mode)
const base = selectTheme(getOpenCodeTheme(), mode)
const definition = { ...base, text: { ...base.text, base: "#123456" } }
const { hue, ...tokens } = definition
const source = { version: 2, base: tokens, [mode]: { hue } }
@@ -286,7 +292,7 @@ test.each(["light", "dark"] as const)(
try {
expectFooter(
theme,
resolveThemeDocument(parseTheme(requested === mode ? source : DEFAULT_THEMES.opencode), requested),
resolveThemeDocument(requested === mode ? parseTheme(source) : getOpenCodeTheme(), requested),
)
} finally {
theme.block.syntax?.destroy()
@@ -296,7 +302,7 @@ test.each(["light", "dark"] as const)(
)
test.each(["light", "dark"] as const)("handles unavailable palettes in %s mode", async (mode) => {
const expected = resolveThemeDocument(parseTheme(DEFAULT_THEMES.opencode), mode)
const expected = resolveThemeDocument(getOpenCodeTheme(), mode)
for (const input of [
{ fail: true },
{ colors: emptyColors },
@@ -332,7 +338,7 @@ test("uses refreshed background brightness rather than stale mode or ANSI slot z
})
test.each(["light", "dark"] as const)("follows physical %s mode over opposite configuration", async (mode) => {
const expected = resolveThemeDocument(parseTheme(DEFAULT_THEMES.opencode), mode)
const expected = resolveThemeDocument(getOpenCodeTheme(), mode)
for (const fail of [false, true]) {
const theme = await resolveRunTheme(renderer({ colors: terminalColors({}, mode), themeMode: mode, fail }), {
mode: mode === "light" ? "dark" : "light",
@@ -350,7 +356,7 @@ test.each(["system", "opencode"])(
async (name) => {
const colors = terminalColors()
const expected = resolveThemeDocument(
parseTheme(name === "system" ? generateSystem(colors, "light") : DEFAULT_THEMES.opencode),
name === "system" ? parseTheme(generateSystem(colors, "light")) : getOpenCodeTheme(),
"light",
)
const input = { colors, fail: false, themeMode: "dark" as "dark" | "light" }
+19 -11
View File
@@ -2,21 +2,29 @@ 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_THEME } from "@opencode/theme/tui"
import { DEFAULT_THEMES, hasTheme, parseTheme, resolveTheme } from "../src/theme"
import { allThemes, hasTheme, getOpenCodeTheme, parseTheme, resolveTheme } from "../src/theme"
import { discoverThemes } from "../src/theme/discovery"
import { configDirectories } from "../src/util/config-directories"
import { terminalMode } from "../src/theme/system"
import opencodeSource from "../src/theme/assets/opencode.json" with { type: "json" }
import type { ThemeV1Json } from "@opencode/theme/tui/v1"
import { tmpdir } from "./fixture/fixture"
const opencodeV1 = opencodeSource as ThemeV1Json
test("parseTheme delegates malformed V1 sources and rejects unknown versions", () => {
expect(() => parseTheme({})).toThrow()
expect(() => parseTheme({ version: 3 })).toThrow("Unsupported theme version: 3")
})
test("registers opencode as a native V2 theme", () => {
expect(allThemes().opencode).toBe(getOpenCodeTheme())
expect(parseTheme(getOpenCodeTheme()).version).toBe(2)
})
test("parses unversioned and explicit V1 themes lazily once", () => {
const unversioned = structuredClone(DEFAULT_THEMES.opencode)
const explicit = { ...structuredClone(DEFAULT_THEMES.opencode), version: 1 }
const unversioned = structuredClone(opencodeV1)
const explicit = { ...structuredClone(opencodeV1), version: 1 }
const first = parseTheme(unversioned, "unversioned")
const second = parseTheme(explicit, "explicit")
@@ -29,8 +37,8 @@ test("parses unversioned and explicit V1 themes lazily once", () => {
test("decodes native V2 themes lazily once", () => {
const source = {
version: 2,
base: DEFAULT_THEME.base,
light: { ...DEFAULT_THEME.light, categorical: ["red"] },
base: getOpenCodeTheme().base,
light: { ...getOpenCodeTheme().light, categorical: ["red"] },
} as const
const document = parseTheme(source)
@@ -45,7 +53,7 @@ test("rejects invalid V2 themes when parsing", () => {
})
test("rejects invalid V1 themes when parsing", () => {
const source = structuredClone(DEFAULT_THEMES.opencode)
const source = structuredClone(opencodeV1)
source.defs = { ...source.defs, one: "two", two: "one" }
source.theme.primary = "one"
@@ -53,8 +61,8 @@ test("rejects invalid V1 themes when parsing", () => {
})
test("replacement sources receive independent parse caches", () => {
const first = structuredClone(DEFAULT_THEMES.opencode)
const second = structuredClone(DEFAULT_THEMES.opencode)
const first = structuredClone(opencodeV1)
const second = structuredClone(opencodeV1)
second.theme.primary = "#123456"
const previous = parseTheme(first)
@@ -69,14 +77,14 @@ test("hasTheme checks theme presence", () => {
})
test("resolveTheme rejects circular color refs", () => {
const item = structuredClone(DEFAULT_THEMES.opencode)
const item = structuredClone(opencodeV1)
item.defs = { ...item.defs, one: "two", two: "one" }
item.theme.primary = "one"
expect(() => resolveTheme(item, "dark")).toThrow("Circular color reference")
})
test("resolveTheme preserves full theme numeric color and marker semantics", () => {
const item = structuredClone(DEFAULT_THEMES.opencode)
const item = structuredClone(opencodeV1)
item.theme.primary = 6
delete item.theme.selectedListItemText
+4 -3
View File
@@ -1,11 +1,12 @@
import { expect, test } from "bun:test"
import { createSignal } from "solid-js"
import { RGBA } from "@opentui/core"
import { DEFAULT_THEME, resolveTheme, selectTheme } from "@opencode/theme/tui"
import { resolveTheme, selectTheme } from "@opencode/theme/tui"
import { createComponentTheme } from "../../../src/theme/component"
import { getOpenCodeTheme } from "../../../src/theme"
test("provides reactive properties, states, surfaces, and color operations", () => {
const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "light")))
const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(getOpenCodeTheme(), "light")))
const theme = createComponentTheme(resolved)
const current = theme.surface("dialog")
@@ -78,7 +79,7 @@ test("provides reactive properties, states, surfaces, and color operations", ()
expect(current.background.action.primary.hovered).toBe(resolved().background.raised.high)
expect(current.background.formfield.selected).toBe(resolved().surface("dialog").background.formfield.selected)
setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "dark")))
setResolved(resolveTheme(selectTheme(getOpenCodeTheme(), "dark")))
expect(current.text.base).toBe(resolved().surface("dialog").text.base)
expect(current.background.base).toBe(resolved().background.raised.base)
expect(current.decrease(current.background.raised.base, 1)).toBe(resolved().hue.neutral[600])
+17 -8
View File
@@ -2,7 +2,6 @@ import { expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import {
BaseHue,
DEFAULT_THEME,
generateSyntax,
resolveTheme,
resolveThemeDocument,
@@ -10,10 +9,20 @@ import {
type Mode,
type ThemeDefinition,
} from "@opencode/theme/tui"
import { parseTheme, type ThemeDocumentSource } from "../../../src/theme"
import { getOpenCodeTheme, parseTheme, type ThemeDocumentSource } from "../../../src/theme"
const light = selectTheme(DEFAULT_THEME, "light")
const dark = selectTheme(DEFAULT_THEME, "dark")
const opencodeLight = selectTheme(getOpenCodeTheme(), "light")
const opencodeDark = selectTheme(getOpenCodeTheme(), "dark")
const light = {
...opencodeLight,
categorical: ["blue", "purple"],
hue: { ...opencodeLight.hue, accent: "$hue.blue", interactive: "$hue.blue", neutral: "$hue.gray" },
} satisfies ThemeDefinition
const dark = {
...opencodeDark,
categorical: ["blue", "purple"],
hue: { ...opencodeDark.hue, accent: "$hue.blue", interactive: "$hue.blue", neutral: "$hue.gray" },
} satisfies ThemeDefinition
test("orders light hues dark-to-light and dark hues light-to-dark", () => {
const lightTheme = resolveTheme(light)
@@ -102,16 +111,16 @@ test("resolves independent definitions and hue aliases", () => {
expect(lightTheme.background.raised.base).toBe(lightTheme.hue.neutral[700])
expect(lightTheme.background.raised.high).toBe(lightTheme.hue.neutral[600])
expect(lightTheme.syntax.keyword).toBeInstanceOf(RGBA)
expect(lightTheme.text.action.primary.base).toBe(lightTheme.hue.neutral[800])
expect(lightTheme.text.action.primary.base).toBe(lightTheme.text.base)
// Surfaces re-resolve the palette after applying their theme-provided overrides.
const dialog = lightTheme.surface("dialog")
expect(dialog.background.base).toBe(lightTheme.background.raised.base)
expect(dialog.background.formfield.base).toBe(lightTheme.background.raised.base)
expect(dialog.background.feedback.error.base).toBe(lightTheme.background.raised.base)
expect(dialog.background.action.primary.hovered).toBe(lightTheme.background.raised.high)
expect(dialog.background.action.primary.base).toBe(lightTheme.hue.interactive[500])
expect(dialog.background.action.primary.focused).toBe(lightTheme.hue.interactive[500])
expect(dialog.text.action.primary.base).toBe(lightTheme.hue.neutral[900])
expect(dialog.background.action.primary.base.equals(lightTheme.background.action.primary.base)).toBeTrue()
expect(dialog.background.action.primary.focused.equals(lightTheme.background.action.primary.focused)).toBeTrue()
expect(dialog.text.action.primary.base.equals(lightTheme.text.action.primary.base)).toBeTrue()
expect(dialog.surface("dialog")).toBe(dialog)
expect(darkTheme.surface("dialog").background.base).toBe(darkTheme.background.raised.base)
})
+13 -18
View File
@@ -1,30 +1,25 @@
import { expect, test } from "bun:test"
import { DEFAULT_THEME, selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "@opencode/theme/tui"
import { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "@opencode/theme/tui"
import { getOpenCodeTheme } from "../../../src/theme"
test("selects complete light and dark themes independently", () => {
expect(selectTheme(DEFAULT_THEME)).toEqual({ ...DEFAULT_THEME.base, ...DEFAULT_THEME.light })
expect(selectTheme(DEFAULT_THEME, "light")).toEqual({ ...DEFAULT_THEME.base, ...DEFAULT_THEME.light })
expect(selectTheme(DEFAULT_THEME, "dark")).toEqual({ ...DEFAULT_THEME.base, ...DEFAULT_THEME.dark })
expect(selectThemeMode(DEFAULT_THEME, "dark")).toEqual({
theme: { ...DEFAULT_THEME.base, ...DEFAULT_THEME.dark },
mode: "dark",
})
const light = selectTheme(getOpenCodeTheme(), "light")
const dark = selectTheme(getOpenCodeTheme(), "dark")
expect(selectTheme(getOpenCodeTheme())).toEqual(light)
expect(light.hue).toEqual(getOpenCodeTheme().light.hue)
expect(light.text).toEqual(getOpenCodeTheme().base.text)
expect(dark.hue).toEqual(getOpenCodeTheme().dark.hue)
expect(selectThemeMode(getOpenCodeTheme(), "dark")).toEqual({ theme: dark, mode: "dark" })
})
test("selects the available mode when the requested mode is missing", () => {
const lightOnly = { version: 2, base: DEFAULT_THEME.base, light: DEFAULT_THEME.light } as const
const darkOnly = { version: 2, base: DEFAULT_THEME.base, dark: DEFAULT_THEME.dark } as const
const lightOnly = { version: 2, base: getOpenCodeTheme().base, light: getOpenCodeTheme().light } as const
const darkOnly = { version: 2, base: getOpenCodeTheme().base, dark: getOpenCodeTheme().dark } as const
expect(themeModes(lightOnly)).toEqual(["light"])
expect(themeModes(darkOnly)).toEqual(["dark"])
expect(supportsThemeMode(lightOnly, "light")).toBeTrue()
expect(supportsThemeMode(lightOnly, "dark")).toBeFalse()
expect(selectThemeMode(lightOnly, "dark")).toEqual({
theme: { ...DEFAULT_THEME.base, ...DEFAULT_THEME.light },
mode: "light",
})
expect(selectThemeMode(darkOnly, "light")).toEqual({
theme: { ...DEFAULT_THEME.base, ...DEFAULT_THEME.dark },
mode: "dark",
})
expect(selectThemeMode(lightOnly, "dark")).toEqual({ theme: selectTheme(getOpenCodeTheme(), "light"), mode: "light" })
expect(selectThemeMode(darkOnly, "light")).toEqual({ theme: selectTheme(getOpenCodeTheme(), "dark"), mode: "dark" })
})
@@ -1,5 +1,6 @@
import { DEFAULT_THEME, selectTheme } from "@opencode/theme/tui"
import { selectTheme } from "@opencode/theme/tui"
import type { BackgroundDefinition, TextDefinition, ThemeDefinition, ThemeDocument } from "@opencode/theme/tui"
import { getOpenCodeTheme } from "../../../src/theme"
const text = {
base: "$hue.neutral.900",
@@ -39,18 +40,18 @@ const background = {
} satisfies BackgroundDefinition
const definition = {
...selectTheme(DEFAULT_THEME, "light"),
...selectTheme(getOpenCodeTheme(), "light"),
"@dialog": { background: { base: "$background.raised.base" } },
} satisfies ThemeDefinition
export const document = {
version: 2,
base: DEFAULT_THEME.base,
base: getOpenCodeTheme().base,
light: { hue: definition.hue },
dark: definition,
} satisfies ThemeDocument
export const lightOnly = { version: 2, base: DEFAULT_THEME.base, light: { hue: definition.hue } } satisfies ThemeDocument
export const darkOnly = { version: 2, base: DEFAULT_THEME.base, dark: definition } satisfies ThemeDocument
export const lightOnly = { version: 2, base: getOpenCodeTheme().base, light: { hue: definition.hue } } satisfies ThemeDocument
export const darkOnly = { version: 2, base: getOpenCodeTheme().base, dark: definition } satisfies ThemeDocument
// @ts-expect-error A theme document must provide at least one mode.
export const empty = { version: 2 } satisfies ThemeDocument
// @ts-expect-error A base mode must be complete; partial tokens are only valid under @dialog.
+45 -22
View File
@@ -1,18 +1,23 @@
import { expect, test } from "bun:test"
import {
DEFAULT_CATEGORICAL,
DEFAULT_THEME,
migrateV1,
resolveThemeDocument,
selectThemeMode,
themeModes,
} from "@opencode/theme/tui"
import { DEFAULT_THEMES, resolveTheme as resolveV1 } from "../../../src/theme"
import { DEFAULT_THEMES, getOpenCodeTheme, resolveTheme as resolveV1 } from "../../../src/theme"
import opencodeSource from "../../../src/theme/assets/opencode.json" with { type: "json" }
import type { ThemeV1Json } from "@opencode/theme/tui/v1"
const opencodeV1 = opencodeSource as ThemeV1Json
const opencodeLight = resolveThemeDocument(getOpenCodeTheme(), "light")
const opencodeDark = resolveThemeDocument(getOpenCodeTheme(), "dark")
test("migrates resolved V1 modes into V2 tokens", () => {
const migrated = migrateV1(DEFAULT_THEMES.opencode)
const migrated = migrateV1(opencodeV1)
if (!migrated.light || !migrated.dark) throw new Error("Expected both modes")
const legacy = resolveV1(DEFAULT_THEMES.opencode, "light")
const legacy = resolveV1(opencodeV1, "light")
const resolved = resolveThemeDocument(migrated, "light")
expect(migrated.base.categorical?.length).toBeGreaterThan(0)
@@ -52,7 +57,7 @@ test("migrates resolved V1 modes into V2 tokens", () => {
})
test("references generated hues from matching token colors", () => {
const source = structuredClone(DEFAULT_THEMES.opencode)
const source = structuredClone(opencodeV1)
source.theme.border = source.theme.primary
source.theme.borderActive = source.theme.accent
source.theme.syntaxKeyword = source.theme.error
@@ -68,7 +73,7 @@ test("references generated hues from matching token colors", () => {
})
test("infers chromatic hues, anchors light and dark colors, and aliases ambiguous hues to gray", () => {
const source = structuredClone(DEFAULT_THEMES.opencode)
const source = structuredClone(opencodeV1)
const ambiguous = { light: "#808080", dark: "#808080" }
source.theme.accent = ambiguous
source.theme.warning = ambiguous
@@ -101,10 +106,28 @@ test("infers chromatic hues, anchors light and dark colors, and aliases ambiguou
})
test("orders categorical hues by V1 semantic color mapping", () => {
const source = structuredClone(DEFAULT_THEMES.opencode)
const source = structuredClone(opencodeV1)
const colors = {
light: {
red: "#fca5a5",
orange: "#fdba74",
yellow: "#fde047",
green: "#86efac",
blue: "#93c5fd",
purple: "#d8b4fe",
},
dark: {
red: "#b91c1c",
orange: "#c2410c",
yellow: "#a16207",
green: "#15803d",
blue: "#1d4ed8",
purple: "#7e22ce",
},
} as const
const mapped = (name: "red" | "orange" | "yellow" | "green" | "blue" | "purple") => ({
light: DEFAULT_THEME.light.hue[name][300],
dark: DEFAULT_THEME.dark.hue[name][300],
light: colors.light[name],
dark: colors.dark[name],
})
source.theme.secondary = mapped("purple")
source.theme.accent = mapped("orange")
@@ -122,11 +145,11 @@ test("orders categorical hues by V1 semantic color mapping", () => {
})
test("gives accent and primary ownership of their inferred hues", () => {
const source = structuredClone(DEFAULT_THEMES.opencode)
source.theme.success = DEFAULT_THEME.light.hue.orange[700]
source.theme.accent = DEFAULT_THEME.light.hue.orange[600]
source.theme.info = DEFAULT_THEME.light.hue.blue[700]
source.theme.primary = DEFAULT_THEME.light.hue.blue[600]
const source = structuredClone(opencodeV1)
source.theme.success = hex(opencodeLight.hue.orange[700])
source.theme.accent = hex(opencodeLight.hue.orange[600])
source.theme.info = hex(opencodeLight.hue.blue[700])
source.theme.primary = hex(opencodeLight.hue.blue[600])
const migrated = migrateV1(source)
if (!migrated.light) throw new Error("Expected light mode")
@@ -139,7 +162,7 @@ test("gives accent and primary ownership of their inferred hues", () => {
expect(migrated.light.hue?.accent).toBe("$hue.orange")
expect(migrated.light.hue?.interactive).toBe("$hue.blue")
source.theme.primary = DEFAULT_THEME.light.hue.orange[500]
source.theme.primary = hex(opencodeLight.hue.orange[500])
const collisionMode = migrateV1(source).light
const collision = collisionMode?.hue?.orange
if (typeof collision !== "object") throw new Error("Expected concrete orange scale")
@@ -149,7 +172,7 @@ test("gives accent and primary ownership of their inferred hues", () => {
})
test("uses default categorical hues when V1 semantic colors are ambiguous", () => {
const source = structuredClone(DEFAULT_THEMES.opencode)
const source = structuredClone(opencodeV1)
source.theme.secondary = "transparent"
source.theme.accent = "transparent"
source.theme.success = "transparent"
@@ -163,7 +186,7 @@ test("uses default categorical hues when V1 semantic colors are ambiguous", () =
})
test("builds and extrapolates gray from V1 surfaces and text without using menus or borders", () => {
const source = structuredClone(DEFAULT_THEMES.opencode)
const source = structuredClone(opencodeV1)
source.theme.background = { light: "#eeeeee", dark: "#111111" }
source.theme.backgroundPanel = { light: "#dddddd", dark: "#222222" }
source.theme.backgroundElement = { light: "#cccccc", dark: "#333333" }
@@ -202,7 +225,7 @@ test("builds and extrapolates gray from V1 surfaces and text without using menus
})
test("uses the base text reference for primary actions on transparent backgrounds", () => {
const source = structuredClone(DEFAULT_THEMES.opencode)
const source = structuredClone(opencodeV1)
source.theme.background = "transparent"
source.theme.primary = { light: "#ffffff", dark: "#000000" }
delete source.theme.selectedListItemText
@@ -214,7 +237,7 @@ test("uses the base text reference for primary actions on transparent background
})
test("retains V1 circular reference errors", () => {
const source = structuredClone(DEFAULT_THEMES.opencode)
const source = structuredClone(opencodeV1)
source.defs = { ...source.defs, one: "two", two: "one" }
source.theme.primary = "one"
@@ -231,7 +254,7 @@ test("migrates every built-in V1 theme in its supported modes", () => {
})
test("collapses identical V1 backgrounds when both variants infer one mode", () => {
const dark = structuredClone(DEFAULT_THEMES.opencode)
const dark = structuredClone(opencodeV1)
dark.theme.background = "#111111"
dark.theme.text = "#eeeeee"
const migratedDark = migrateV1(dark)
@@ -240,7 +263,7 @@ test("collapses identical V1 backgrounds when both variants infer one mode", ()
expect(themeModes(migratedDark)).toEqual(["dark"])
expect(selectThemeMode(migratedDark, "light").mode).toBe("dark")
const light = structuredClone(DEFAULT_THEMES.opencode)
const light = structuredClone(opencodeV1)
light.theme.background = "#eeeeee"
light.theme.text = "#111111"
const migratedLight = migrateV1(light)
@@ -251,7 +274,7 @@ test("collapses identical V1 backgrounds when both variants infer one mode", ()
})
test("keeps both modes when a shared background has different contrast", () => {
const source = structuredClone(DEFAULT_THEMES.opencode)
const source = structuredClone(opencodeV1)
source.theme.background = "#808080"
source.theme.text = { light: "#111111", dark: "#eeeeee" }
const migrated = migrateV1(source)
+1 -1
View File
@@ -227,7 +227,7 @@ palette:
| Group | Purpose |
| ------------ | ------------------------------------------------------------- |
| `text` | Base, muted, action, form-field, status, and feedback text |
| `text` | Base, muted, action, form-field, and feedback text |
| `background` | Base, surfaces, actions, form fields, and feedback fills |
| `border` | Borders |
| `scrollbar` | Scrollbars |