From 99209e60143c3d64b3e1d3775df5d74428592799 Mon Sep 17 00:00:00 2001 From: JosXa Date: Tue, 3 Feb 2026 02:38:56 +0100 Subject: [PATCH 1/3] feat(tui): add transparent background toggle Adds a new command to toggle transparent background for terminal emulators that support transparency. The toggle text dynamically updates based on the current transparency state. Closes #11866 --- packages/tui/src/app.tsx | 11 ++++++++++- packages/tui/src/context/theme.tsx | 16 +++++++++++++--- packages/tui/src/theme/index.ts | 14 +++++++++++--- 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 17a9a554c2e4..0add51f31018 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -362,7 +362,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, transparent, setTransparent } = themeState const sync = useSync() const project = useProject() const exit = useExit() @@ -783,6 +783,15 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi }, category: "System", }, + { + name: "theme.transparency", + title: transparent() ? "Disable transparency" : "Enable transparency", + run: () => { + setTransparent(!transparent()) + dialog.clear() + }, + category: "System", + }, { name: "help.show", title: "Help", diff --git a/packages/tui/src/context/theme.tsx b/packages/tui/src/context/theme.tsx index 909dd69ac249..a5ee1110203d 100644 --- a/packages/tui/src/context/theme.tsx +++ b/packages/tui/src/context/theme.tsx @@ -86,6 +86,7 @@ type State = { mode: "dark" | "light" lock: "dark" | "light" | undefined active: string + transparent: boolean ready: boolean } @@ -94,6 +95,7 @@ const [store, setStore] = createStore({ mode: "dark", lock: undefined, active: "opencode", + transparent: false, ready: false, }) @@ -120,6 +122,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.transparent = kv.get("theme_transparent", false) === true draft.ready = false }), ) @@ -255,15 +258,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.transparent) 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.transparent) } - return resolveTheme(store.themes.opencode, store.mode) + return resolveTheme(store.themes.opencode, store.mode, store.transparent) }) createEffect(() => renderer.setBackgroundColor(values().background)) @@ -296,6 +299,13 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ kv.set("theme", theme) return true }, + transparent() { + return store.transparent + }, + setTransparent(transparent: boolean) { + setStore("transparent", transparent) + kv.set("theme_transparent", transparent) + }, get ready() { return store.ready }, diff --git a/packages/tui/src/theme/index.ts b/packages/tui/src/theme/index.ts index e8a5f2c5a973..acb67f347c69 100644 --- a/packages/tui/src/theme/index.ts +++ b/packages/tui/src/theme/index.ts @@ -88,8 +88,9 @@ export type Theme = { readonly syntaxPunctuation: RGBA readonly thinkingOpacity: number _hasSelectedListItemText: boolean + transparent: boolean } -type ThemeColor = Exclude +type ThemeColor = Exclude export type SyntaxStyleOverrides = Record export function selectedForeground(theme: Theme, bg?: RGBA): RGBA { @@ -157,8 +158,8 @@ export const DEFAULT_THEMES: Record = { solarized, synthwave84, tokyonight, - vesper, vercel, + vesper, zenburn, carbonfox, } @@ -238,7 +239,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", transparent: boolean = false) { const defs = theme.defs ?? {} function resolveColor(c: ColorValue, chain: string[] = []): RGBA { if (c instanceof RGBA) return c @@ -291,10 +292,17 @@ export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") { // Handle thinkingOpacity - optional with default of 0.6 const thinkingOpacity = theme.theme.thinkingOpacity ?? 0.6 + if (transparent) { + resolved.background = RGBA.fromInts(0, 0, 0, 0) + // NOTE: Could alternatively apply an alpha channel to the theme's base background color + // instead of forcing full transparency, allowing for adjustable opacity levels + } + return { ...resolved, _hasSelectedListItemText: hasSelectedListItemText, thinkingOpacity, + transparent, } as Theme } From d6f11bca53520eaa4d6ba71eeccb3b4b313943e9 Mon Sep 17 00:00:00 2001 From: JosXa Date: Sun, 21 Jun 2026 04:02:20 +0200 Subject: [PATCH 2/3] fix(tui): align run theme transparency typing --- packages/opencode/src/cli/cmd/run/theme.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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( From cc6fbb5c34ffb31007e2757ee43e46ee2a2da192 Mon Sep 17 00:00:00 2001 From: JosXa Date: Sun, 21 Jun 2026 23:22:46 +0200 Subject: [PATCH 3/3] feat(tui): add transparency policy --- packages/tui/src/app.tsx | 9 ++++--- packages/tui/src/config/keybind.ts | 2 ++ packages/tui/src/context/theme.tsx | 28 ++++++++++++-------- packages/tui/src/theme/index.ts | 14 +++++----- packages/tui/test/theme.test.ts | 41 ++++++++++++++++++++++++++++-- 5 files changed, 72 insertions(+), 22 deletions(-) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 0add51f31018..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, transparent, setTransparent } = themeState + const { theme, mode, setMode, locked, lock, unlock, transparency, setTransparency } = themeState const sync = useSync() const project = useProject() const exit = useExit() @@ -785,9 +786,11 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi }, { name: "theme.transparency", - title: transparent() ? "Disable transparency" : "Enable transparency", + title: `Transparency: ${ + transparency() === "auto" ? `Auto (${theme.transparent ? "on" : "off"})` : transparency() === "on" ? "Force on" : "Force off" + }`, run: () => { - setTransparent(!transparent()) + setTransparency(transparency() === "auto" ? "on" : transparency() === "on" ? "off" : "auto") dialog.clear() }, category: "System", 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 a5ee1110203d..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,7 +88,7 @@ type State = { mode: "dark" | "light" lock: "dark" | "light" | undefined active: string - transparent: boolean + transparency: ThemeTransparency ready: boolean } @@ -95,7 +97,7 @@ const [store, setStore] = createStore({ mode: "dark", lock: undefined, active: "opencode", - transparent: false, + transparency: "auto", ready: false, }) @@ -112,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) => { @@ -122,7 +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.transparent = kv.get("theme_transparent", false) === true + draft.transparency = pickTransparency(kv.get("theme_transparent", "auto")) draft.ready = false }), ) @@ -258,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, store.transparent) + 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, store.transparent) + if (theme) return resolveTheme(theme, store.mode, store.transparency) } - return resolveTheme(store.themes.opencode, store.mode, store.transparent) + return resolveTheme(store.themes.opencode, store.mode, store.transparency) }) createEffect(() => renderer.setBackgroundColor(values().background)) @@ -299,12 +305,12 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ kv.set("theme", theme) return true }, - transparent() { - return store.transparent + transparency() { + return store.transparency }, - setTransparent(transparent: boolean) { - setStore("transparent", transparent) - kv.set("theme_transparent", transparent) + 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 acb67f347c69..412c3b943683 100644 --- a/packages/tui/src/theme/index.ts +++ b/packages/tui/src/theme/index.ts @@ -90,6 +90,7 @@ export type Theme = { _hasSelectedListItemText: boolean transparent: boolean } +export type ThemeTransparency = "auto" | "on" | "off" type ThemeColor = Exclude export type SyntaxStyleOverrides = Record @@ -239,7 +240,7 @@ export function upsertTheme(name: string, theme: unknown) { return true } -export function resolveTheme(theme: ThemeJson, mode: "dark" | "light", transparent: boolean = false) { +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 @@ -292,17 +293,18 @@ export function resolveTheme(theme: ThemeJson, mode: "dark" | "light", transpare // Handle thinkingOpacity - optional with default of 0.6 const thinkingOpacity = theme.theme.thinkingOpacity ?? 0.6 - if (transparent) { - resolved.background = RGBA.fromInts(0, 0, 0, 0) - // NOTE: Could alternatively apply an alpha channel to the theme's base background color - // instead of forcing full transparency, allowing for adjustable opacity levels + 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, + 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,