diff options
| author | Adam <[email protected]> | 2025-12-28 05:12:32 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-12-28 05:12:36 -0600 |
| commit | 4a9ff9412e8daedc36319bd2ee8ca62d5aa52be7 (patch) | |
| tree | ad035366cdebc87d4eea75475201af3d11bd64ed /packages/app/src | |
| parent | d6db6ff198c4513ca5511ae49b03d4277bc21718 (diff) | |
| download | opencode-4a9ff9412e8daedc36319bd2ee8ca62d5aa52be7.tar.gz opencode-4a9ff9412e8daedc36319bd2ee8ca62d5aa52be7.zip | |
feat(desktop): themes
Diffstat (limited to 'packages/app/src')
| -rw-r--r-- | packages/app/src/app.tsx | 87 | ||||
| -rw-r--r-- | packages/app/src/components/terminal.tsx | 67 | ||||
| -rw-r--r-- | packages/app/src/pages/layout.tsx | 173 |
3 files changed, 221 insertions, 106 deletions
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 ( <MetaProvider> <Font /> - <ErrorBoundary fallback={(error) => <ErrorPage error={error} />}> - <DialogProvider> - <MarkedProvider> - <DiffComponentProvider component={Diff}> - <CodeComponentProvider component={Code}> - <GlobalSDKProvider url={url}> - <GlobalSyncProvider> - <LayoutProvider> - <NotificationProvider> - <Router - root={(props) => ( - <CommandProvider> - <Layout>{props.children}</Layout> - </CommandProvider> - )} - > - <Route path="/" component={Home} /> - <Route path="/:dir" component={DirectoryLayout}> - <Route path="/" component={() => <Navigate href="session" />} /> - <Route - path="/session/:id?" - component={(p) => ( - <Show when={p.params.id || true} keyed> - <TerminalProvider> - <PromptProvider> - <Session /> - </PromptProvider> - </TerminalProvider> - </Show> - )} - /> - </Route> - </Router> - </NotificationProvider> - </LayoutProvider> - </GlobalSyncProvider> - </GlobalSDKProvider> - </CodeComponentProvider> - </DiffComponentProvider> - </MarkedProvider> - </DialogProvider> - </ErrorBoundary> + <ThemeProvider> + <ErrorBoundary fallback={(error) => <ErrorPage error={error} />}> + <DialogProvider> + <MarkedProvider> + <DiffComponentProvider component={Diff}> + <CodeComponentProvider component={Code}> + <GlobalSDKProvider url={url}> + <GlobalSyncProvider> + <LayoutProvider> + <NotificationProvider> + <Router + root={(props) => ( + <CommandProvider> + <Layout>{props.children}</Layout> + </CommandProvider> + )} + > + <Route path="/" component={Home} /> + <Route path="/:dir" component={DirectoryLayout}> + <Route path="/" component={() => <Navigate href="session" />} /> + <Route + path="/session/:id?" + component={(p) => ( + <Show when={p.params.id || true} keyed> + <TerminalProvider> + <PromptProvider> + <Session /> + </PromptProvider> + </TerminalProvider> + </Show> + )} + /> + </Route> + </Router> + </NotificationProvider> + </LayoutProvider> + </GlobalSyncProvider> + </GlobalSDKProvider> + </CodeComponentProvider> + </DiffComponentProvider> + </MarkedProvider> + </DialogProvider> + </ErrorBoundary> + </ThemeProvider> </MetaProvider> ) } 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<TerminalColors>(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<ColorScheme, string> = { + 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(() => <DialogSelectProvider />) |
