Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/opencode/src/cli/cmd/run/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export type RunTheme = {
block: RunBlockTheme
}

type ThemeColor = Exclude<keyof TuiThemeCurrent, "thinkingOpacity">
type ThemeColor = Exclude<keyof TuiThemeCurrent, "thinkingOpacity" | "_hasSelectedListItemText" | "transparent">
type HexColor = `#${string}`
type RefName = string
type Variant = {
Expand All @@ -87,6 +87,7 @@ type ThemeJson = {

type SharedSyntaxTheme = TuiThemeCurrent & {
_hasSelectedListItemText: boolean
transparent: boolean
}

export const transparent = RGBA.fromValues(0, 0, 0, 0)
Expand Down Expand Up @@ -675,6 +676,7 @@ export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme>
const syntaxTheme: SharedSyntaxTheme = {
...scrollbackTheme,
_hasSelectedListItemText: true,
transparent: false,
}
const syntax = shared.generateSyntax(syntaxTheme)
return map(
Expand Down
14 changes: 13 additions & 1 deletion packages/tui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ const appBindingCommands = [
"theme.switch",
"theme.switch_mode",
"theme.mode.lock",
"theme.transparency",
"help.show",
"docs.open",
"workspace.list",
Expand Down Expand Up @@ -362,7 +363,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; 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()
Expand Down Expand Up @@ -783,6 +784,17 @@ function App(props: { onSnapshot?: () => Promise<string[]>; 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",
Expand Down
2 changes: 2 additions & 0 deletions packages/tui/src/config/keybind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export const Definitions = {
theme_list: keybind("<leader>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("<leader>b", "Toggle sidebar"),
scrollbar_toggle: keybind("none", "Toggle session scrollbar"),
status_view: keybind("<leader>s", "View status"),
Expand Down Expand Up @@ -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",
Expand Down
22 changes: 19 additions & 3 deletions packages/tui/src/context/theme.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
terminalMode,
tint,
upsertTheme,
type ThemeTransparency,
type ThemeJson,
} from "../theme"
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
Expand Down Expand Up @@ -77,6 +78,7 @@ export {
type Theme,
type ThemeJson,
type SyntaxStyleOverrides,
type ThemeTransparency,
} from "../theme"

const THEME_REFRESH_DELAYS = [250, 1000] as const
Expand All @@ -86,6 +88,7 @@ type State = {
mode: "dark" | "light"
lock: "dark" | "light" | undefined
active: string
transparency: ThemeTransparency
ready: boolean
}

Expand All @@ -94,6 +97,7 @@ const [store, setStore] = createStore<State>({
mode: "dark",
lock: undefined,
active: "opencode",
transparency: "auto",
ready: false,
})

Expand All @@ -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) => {
Expand All @@ -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
}),
)
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
},
Expand Down
16 changes: 13 additions & 3 deletions packages/tui/src/theme/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,10 @@ export type Theme = {
readonly syntaxPunctuation: RGBA
readonly thinkingOpacity: number
_hasSelectedListItemText: boolean
transparent: boolean
}
type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
export type ThemeTransparency = "auto" | "on" | "off"
type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText" | "transparent">
export type SyntaxStyleOverrides = Record<string, { italic?: boolean }>

export function selectedForeground(theme: Theme, bg?: RGBA): RGBA {
Expand Down Expand Up @@ -157,8 +159,8 @@ export const DEFAULT_THEMES: Record<string, ThemeJson> = {
solarized,
synthwave84,
tokyonight,
vesper,
vercel,
vesper,
zenburn,
carbonfox,
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
41 changes: 39 additions & 2 deletions packages/tui/test/theme.test.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -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<string | null> = []): TerminalColors {
return {
palette,
Expand Down
Loading