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