From 4a9ff9412e8daedc36319bd2ee8ca62d5aa52be7 Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Sun, 28 Dec 2025 05:12:32 -0600
Subject: feat(desktop): themes
---
packages/app/src/app.tsx | 87 ++++++++--------
packages/app/src/components/terminal.tsx | 67 +++++++++---
packages/app/src/pages/layout.tsx | 173 ++++++++++++++++++++++---------
3 files changed, 221 insertions(+), 106 deletions(-)
(limited to 'packages/app/src')
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx
index de8fcf7d1..bf5ba9566 100644
--- a/packages/app/src/app.tsx
+++ b/packages/app/src/app.tsx
@@ -8,6 +8,7 @@ import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
import { Diff } from "@opencode-ai/ui/diff"
import { Code } from "@opencode-ai/ui/code"
+import { ThemeProvider } from "@opencode-ai/ui/theme"
import { GlobalSyncProvider } from "@/context/global-sync"
import { LayoutProvider } from "@/context/layout"
import { GlobalSDKProvider } from "@/context/global-sdk"
@@ -45,48 +46,50 @@ export function App() {
return (
- }>
-
-
-
-
-
-
-
-
- (
-
- {props.children}
-
- )}
- >
-
-
- } />
- (
-
-
-
-
-
-
-
- )}
- />
-
-
-
-
-
-
-
-
-
-
-
+
+ }>
+
+
+
+
+
+
+
+
+ (
+
+ {props.children}
+
+ )}
+ >
+
+
+ } />
+ (
+
+
+
+
+
+
+
+ )}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
)
}
diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx
index 1d6bab2a4..03251fe5f 100644
--- a/packages/app/src/components/terminal.tsx
+++ b/packages/app/src/components/terminal.tsx
@@ -1,9 +1,9 @@
import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
-import { ComponentProps, onCleanup, onMount, splitProps } from "solid-js"
+import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
import { useSDK } from "@/context/sdk"
import { SerializeAddon } from "@/addons/serialize"
import { LocalPTY } from "@/context/terminal"
-import { usePrefersDark } from "@solid-primitives/media"
+import { resolveThemeVariant, useTheme } from "@opencode-ai/ui/theme"
export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY
@@ -12,8 +12,28 @@ export interface TerminalProps extends ComponentProps<"div"> {
onConnectError?: (error: unknown) => void
}
+type TerminalColors = {
+ background: string
+ foreground: string
+ cursor: string
+}
+
+const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
+ light: {
+ background: "#fcfcfc",
+ foreground: "#211e1e",
+ cursor: "#211e1e",
+ },
+ dark: {
+ background: "#191515",
+ foreground: "#d4d4d4",
+ cursor: "#d4d4d4",
+ },
+}
+
export const Terminal = (props: TerminalProps) => {
const sdk = useSDK()
+ const theme = useTheme()
let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"])
let ws: WebSocket
@@ -22,7 +42,35 @@ export const Terminal = (props: TerminalProps) => {
let serializeAddon: SerializeAddon
let fitAddon: FitAddon
let handleResize: () => void
- const prefersDark = usePrefersDark()
+
+ const getTerminalColors = (): TerminalColors => {
+ const mode = theme.mode()
+ const fallback = DEFAULT_TERMINAL_COLORS[mode]
+ const currentTheme = theme.themes()[theme.themeId()]
+ if (!currentTheme) return fallback
+ const variant = mode === "dark" ? currentTheme.dark : currentTheme.light
+ if (!variant?.seeds) return fallback
+ const resolved = resolveThemeVariant(variant, mode === "dark")
+ const text = resolved["text-base"] ?? fallback.foreground
+ const background = resolved["background-stronger"] ?? fallback.background
+ return {
+ background,
+ foreground: text,
+ cursor: text,
+ }
+ }
+
+ const [terminalColors, setTerminalColors] = createSignal(getTerminalColors())
+
+ createEffect(() => {
+ const colors = getTerminalColors()
+ setTerminalColors(colors)
+ if (!term) return
+ const setOption = (term as unknown as { setOption?: (key: string, value: TerminalColors) => void }).setOption
+ if (!setOption) return
+ setOption("theme", colors)
+ })
+
const focusTerminal = () => term?.focus()
const copySelection = () => {
if (!term || !term.hasSelection()) return false
@@ -62,17 +110,7 @@ export const Terminal = (props: TerminalProps) => {
fontSize: 14,
fontFamily: "IBM Plex Mono, monospace",
allowTransparency: true,
- theme: prefersDark()
- ? {
- background: "#191515",
- foreground: "#d4d4d4",
- cursor: "#d4d4d4",
- }
- : {
- background: "#fcfcfc",
- foreground: "#211e1e",
- cursor: "#211e1e",
- },
+ theme: terminalColors(),
scrollback: 10_000,
ghostty,
})
@@ -192,6 +230,7 @@ export const Terminal = (props: TerminalProps) => {
ref={container}
data-component="terminal"
data-prevent-autofocus
+ style={{ "background-color": terminalColors().background }}
classList={{
...(local.classList ?? {}),
"size-full px-6 py-3 font-mono": true,
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 0b4e040b7..08b340187 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -47,6 +47,7 @@ import { useNotification } from "@/context/notification"
import { Binary } from "@opencode-ai/util/binary"
import { Header } from "@/components/header"
import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { DialogSelectProvider } from "@/components/dialog-select-provider"
import { useCommand } from "@/context/command"
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
@@ -89,6 +90,41 @@ export default function Layout(props: ParentProps) {
const providers = useProviders()
const dialog = useDialog()
const command = useCommand()
+ const theme = useTheme()
+ const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
+ const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
+ const colorSchemeLabel: Record = {
+ system: "System",
+ light: "Light",
+ dark: "Dark",
+ }
+
+ function cycleTheme(direction = 1) {
+ const ids = availableThemeEntries().map(([id]) => id)
+ if (ids.length === 0) return
+ const currentIndex = ids.indexOf(theme.themeId())
+ const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length
+ const nextThemeId = ids[nextIndex]
+ theme.setTheme(nextThemeId)
+ const nextTheme = theme.themes()[nextThemeId]
+ showToast({
+ title: "Theme switched",
+ description: nextTheme?.name ?? nextThemeId,
+ })
+ }
+
+ function cycleColorScheme(direction = 1) {
+ const current = theme.colorScheme()
+ const currentIndex = colorSchemeOrder.indexOf(current)
+ const nextIndex =
+ currentIndex === -1 ? 0 : (currentIndex + direction + colorSchemeOrder.length) % colorSchemeOrder.length
+ const next = colorSchemeOrder[nextIndex]
+ theme.setColorScheme(next)
+ showToast({
+ title: "Color scheme",
+ description: colorSchemeLabel[next],
+ })
+ }
onMount(async () => {
if (platform.checkUpdate && platform.update && platform.restart) {
@@ -286,57 +322,94 @@ export default function Layout(props: ParentProps) {
}
}
- command.register(() => [
- {
- id: "sidebar.toggle",
- title: "Toggle sidebar",
- category: "View",
- keybind: "mod+b",
- onSelect: () => layout.sidebar.toggle(),
- },
- ...(platform.openDirectoryPickerDialog
- ? [
- {
- id: "project.open",
- title: "Open project",
- category: "Project",
- keybind: "mod+o",
- onSelect: () => chooseProject(),
- },
- ]
- : []),
- {
- id: "provider.connect",
- title: "Connect provider",
- category: "Provider",
- onSelect: () => connectProvider(),
- },
- {
- id: "session.previous",
- title: "Previous session",
- category: "Session",
- keybind: "alt+arrowup",
- onSelect: () => navigateSessionByOffset(-1),
- },
- {
- id: "session.next",
- title: "Next session",
- category: "Session",
- keybind: "alt+arrowdown",
- onSelect: () => navigateSessionByOffset(1),
- },
- {
- id: "session.archive",
- title: "Archive session",
- category: "Session",
- keybind: "mod+shift+backspace",
- disabled: !params.dir || !params.id,
- onSelect: () => {
- const session = currentSessions().find((s) => s.id === params.id)
- if (session) archiveSession(session)
+ command.register(() => {
+ const commands = [
+ {
+ id: "sidebar.toggle",
+ title: "Toggle sidebar",
+ category: "View",
+ keybind: "mod+b",
+ onSelect: () => layout.sidebar.toggle(),
+ },
+ ...(platform.openDirectoryPickerDialog
+ ? [
+ {
+ id: "project.open",
+ title: "Open project",
+ category: "Project",
+ keybind: "mod+o",
+ onSelect: () => chooseProject(),
+ },
+ ]
+ : []),
+ {
+ id: "provider.connect",
+ title: "Connect provider",
+ category: "Provider",
+ onSelect: () => connectProvider(),
+ },
+ {
+ id: "session.previous",
+ title: "Previous session",
+ category: "Session",
+ keybind: "alt+arrowup",
+ onSelect: () => navigateSessionByOffset(-1),
},
- },
- ])
+ {
+ id: "session.next",
+ title: "Next session",
+ category: "Session",
+ keybind: "alt+arrowdown",
+ onSelect: () => navigateSessionByOffset(1),
+ },
+ {
+ id: "session.archive",
+ title: "Archive session",
+ category: "Session",
+ keybind: "mod+shift+backspace",
+ disabled: !params.dir || !params.id,
+ onSelect: () => {
+ const session = currentSessions().find((s) => s.id === params.id)
+ if (session) archiveSession(session)
+ },
+ },
+ {
+ id: "theme.cycle",
+ title: "Cycle theme",
+ category: "Theme",
+ keybind: "mod+shift+t",
+ onSelect: () => cycleTheme(1),
+ },
+ ]
+
+ for (const [id, definition] of availableThemeEntries()) {
+ commands.push({
+ id: `theme.set.${id}`,
+ title: `Use theme: ${definition.name ?? id}`,
+ category: "Theme",
+ onSelect: () => theme.setTheme(id),
+ })
+ }
+
+ commands.push({
+ id: "theme.scheme.cycle",
+ title: "Cycle color scheme",
+ category: "Theme",
+ keybind: "mod+shift+s",
+ onSelect: () => cycleColorScheme(1),
+ })
+
+ for (const scheme of colorSchemeOrder) {
+ commands.push({
+ id: `theme.scheme.${scheme}`,
+ title: `Use color scheme: ${colorSchemeLabel[scheme]}`,
+ category: "Theme",
+ onSelect: () => theme.setColorScheme(scheme),
+ })
+ }
+
+ return commands
+ })
function connectProvider() {
dialog.show(() => )
--
cgit v1.2.3