diff --git a/packages/opencode/src/cli/cmd/run/theme.ts b/packages/opencode/src/cli/cmd/run/theme.ts index e4fb315426fa..76dda0b63c33 100644 --- a/packages/opencode/src/cli/cmd/run/theme.ts +++ b/packages/opencode/src/cli/cmd/run/theme.ts @@ -68,7 +68,7 @@ export type RunTheme = { block: RunBlockTheme } -type ThemeColor = Exclude +type ThemeColor = Exclude type HexColor = `#${string}` type RefName = string type Variant = { @@ -87,6 +87,7 @@ type ThemeJson = { type SharedSyntaxTheme = TuiThemeCurrent & { _hasSelectedListItemText: boolean + transparent: boolean } export const transparent = RGBA.fromValues(0, 0, 0, 0) @@ -675,6 +676,7 @@ export async function resolveRunTheme(renderer: CliRenderer): Promise const syntaxTheme: SharedSyntaxTheme = { ...scrollbackTheme, _hasSelectedListItemText: true, + transparent: false, } const syntax = shared.generateSyntax(syntaxTheme) return map( diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 17a9a554c2e4..eeda29501c63 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -116,6 +116,7 @@ const appBindingCommands = [ "theme.switch", "theme.switch_mode", "theme.mode.lock", + "theme.transparency", "help.show", "docs.open", "workspace.list", @@ -362,7 +363,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi const sdk = useSDK() const toast = useToast() const themeState = useTheme() - const { theme, mode, setMode, locked, lock, unlock } = themeState + const { theme, mode, setMode, locked, lock, unlock, transparency, setTransparency } = themeState const sync = useSync() const project = useProject() const exit = useExit() @@ -783,6 +784,17 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi }, category: "System", }, + { + name: "theme.transparency", + title: `Transparency: ${ + transparency() === "auto" ? `Auto (${theme.transparent ? "on" : "off"})` : transparency() === "on" ? "Force on" : "Force off" + }`, + run: () => { + setTransparency(transparency() === "auto" ? "on" : transparency() === "on" ? "off" : "auto") + dialog.clear() + }, + category: "System", + }, { name: "help.show", title: "Help", diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 0028b610f2a3..68950ff112c1 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -77,6 +77,7 @@ export const Definitions = { theme_list: keybind("t", "List available themes"), theme_switch_mode: keybind("none", "Switch between light and dark theme mode"), theme_mode_lock: keybind("none", "Lock or unlock theme mode"), + theme_transparency: keybind("none", "Cycle theme transparency policy"), sidebar_toggle: keybind("b", "Toggle sidebar"), scrollbar_toggle: keybind("none", "Toggle session scrollbar"), status_view: keybind("s", "View status"), @@ -282,6 +283,7 @@ export const CommandMap = { theme_list: "theme.switch", theme_switch_mode: "theme.switch_mode", theme_mode_lock: "theme.mode.lock", + theme_transparency: "theme.transparency", sidebar_toggle: "session.sidebar.toggle", scrollbar_toggle: "session.toggle.scrollbar", status_view: "opencode.status", diff --git a/packages/tui/src/context/theme.tsx b/packages/tui/src/context/theme.tsx index 909dd69ac249..d7ddf76841c7 100644 --- a/packages/tui/src/context/theme.tsx +++ b/packages/tui/src/context/theme.tsx @@ -17,6 +17,7 @@ import { terminalMode, tint, upsertTheme, + type ThemeTransparency, type ThemeJson, } from "../theme" import { createEffect, createMemo, onCleanup, onMount } from "solid-js" @@ -77,6 +78,7 @@ export { type Theme, type ThemeJson, type SyntaxStyleOverrides, + type ThemeTransparency, } from "../theme" const THEME_REFRESH_DELAYS = [250, 1000] as const @@ -86,6 +88,7 @@ type State = { mode: "dark" | "light" lock: "dark" | "light" | undefined active: string + transparency: ThemeTransparency ready: boolean } @@ -94,6 +97,7 @@ const [store, setStore] = createStore({ mode: "dark", lock: undefined, active: "opencode", + transparency: "auto", ready: false, }) @@ -110,6 +114,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ if (value === "dark" || value === "light") return value return } + const pickTransparency = (value: unknown): ThemeTransparency => { + if (value === "auto" || value === "on" || value === "off") return value + return value === true ? "on" : "auto" + } setStore( produce((draft) => { @@ -120,6 +128,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ draft.lock = lock const active = config.theme ?? kv.get("theme", "opencode") draft.active = typeof active === "string" ? active : "opencode" + draft.transparency = pickTransparency(kv.get("theme_transparent", "auto")) draft.ready = false }), ) @@ -255,15 +264,15 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ const values = createMemo(() => { const active = store.themes[store.active] - if (active) return resolveTheme(active, store.mode) + if (active) return resolveTheme(active, store.mode, store.transparency) const saved = kv.get("theme") if (typeof saved === "string") { const theme = store.themes[saved] - if (theme) return resolveTheme(theme, store.mode) + if (theme) return resolveTheme(theme, store.mode, store.transparency) } - return resolveTheme(store.themes.opencode, store.mode) + return resolveTheme(store.themes.opencode, store.mode, store.transparency) }) createEffect(() => renderer.setBackgroundColor(values().background)) @@ -296,6 +305,13 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ kv.set("theme", theme) return true }, + transparency() { + return store.transparency + }, + setTransparency(transparency: ThemeTransparency) { + setStore("transparency", transparency) + kv.set("theme_transparent", transparency) + }, get ready() { return store.ready }, diff --git a/packages/tui/src/theme/index.ts b/packages/tui/src/theme/index.ts index e8a5f2c5a973..412c3b943683 100644 --- a/packages/tui/src/theme/index.ts +++ b/packages/tui/src/theme/index.ts @@ -88,8 +88,10 @@ export type Theme = { readonly syntaxPunctuation: RGBA readonly thinkingOpacity: number _hasSelectedListItemText: boolean + transparent: boolean } -type ThemeColor = Exclude +export type ThemeTransparency = "auto" | "on" | "off" +type ThemeColor = Exclude export type SyntaxStyleOverrides = Record export function selectedForeground(theme: Theme, bg?: RGBA): RGBA { @@ -157,8 +159,8 @@ export const DEFAULT_THEMES: Record = { solarized, synthwave84, tokyonight, - vesper, vercel, + vesper, zenburn, carbonfox, } @@ -238,7 +240,7 @@ export function upsertTheme(name: string, theme: unknown) { return true } -export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") { +export function resolveTheme(theme: ThemeJson, mode: "dark" | "light", transparency: ThemeTransparency = "auto") { const defs = theme.defs ?? {} function resolveColor(c: ColorValue, chain: string[] = []): RGBA { if (c instanceof RGBA) return c @@ -291,10 +293,18 @@ export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") { // Handle thinkingOpacity - optional with default of 0.6 const thinkingOpacity = theme.theme.thinkingOpacity ?? 0.6 + if (transparency === "on" && resolved.background) { + resolved.background = RGBA.fromValues(resolved.background.r, resolved.background.g, resolved.background.b, 0) + } + if (transparency === "off" && resolved.background && resolved.background.a < 1) { + resolved.background = RGBA.fromValues(resolved.background.r, resolved.background.g, resolved.background.b, 1) + } + return { ...resolved, _hasSelectedListItemText: hasSelectedListItemText, thinkingOpacity, + transparent: resolved.background !== undefined && resolved.background.a < 1, } as Theme } diff --git a/packages/tui/test/theme.test.ts b/packages/tui/test/theme.test.ts index acdb2a39c41e..bc7ca070b612 100644 --- a/packages/tui/test/theme.test.ts +++ b/packages/tui/test/theme.test.ts @@ -1,8 +1,8 @@ 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 { RGBA, type TerminalColors } from "@opentui/core" +import { DEFAULT_THEMES, addTheme, allThemes, generateSystem, hasTheme, resolveTheme, terminalMode } from "../src/theme" import { discoverThemes } from "../src/context/theme" import { tmpdir } from "./fixture/fixture" @@ -44,6 +44,43 @@ test("resolveTheme rejects circular color refs", () => { expect(() => resolveTheme(item, "dark")).toThrow("Circular color reference") }) +test("resolveTheme transparency policy respects auto, on, and off", () => { + const base = resolveTheme(DEFAULT_THEMES.opencode, "dark", "auto") + const forced = resolveTheme(DEFAULT_THEMES.opencode, "dark", "on") + + expect(base.background.a).toBe(1) + expect(forced.background.a).toBe(0) + expect(forced.background.r).toBe(base.background.r) + expect(forced.background.g).toBe(base.background.g) + expect(forced.background.b).toBe(base.background.b) + expect(forced.transparent).toBe(true) + + const palette = Array.from({ length: 16 }, () => "#1a1b26") + palette[7] = "#c0caf5" + + const system = generateSystem(terminalColors("#1a1b26", palette), "dark") + const automatic = resolveTheme(system, "dark", "auto") + const disabled = resolveTheme(system, "dark", "off") + + expect(automatic.background.a).toBe(0) + expect(automatic.transparent).toBe(true) + expect(disabled.background.a).toBe(1) + expect(disabled.background.r).toBe(automatic.background.r) + expect(disabled.background.g).toBe(automatic.background.g) + expect(disabled.background.b).toBe(automatic.background.b) + expect(disabled.transparent).toBe(false) + + const partial = structuredClone(DEFAULT_THEMES.opencode) + partial.theme.background = RGBA.fromValues(base.background.r, base.background.g, base.background.b, 0.5) + const automaticPartial = resolveTheme(partial, "dark", "auto") + const disabledPartial = resolveTheme(partial, "dark", "off") + + expect(automaticPartial.background.a).toBeCloseTo(0.5) + expect(automaticPartial.transparent).toBe(true) + expect(disabledPartial.background.a).toBe(1) + expect(disabledPartial.transparent).toBe(false) +}) + function terminalColors(defaultBackground: string | null, palette: Array = []): TerminalColors { return { palette,