feat(desktop): themes
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" style="background-color: var(--background-base)">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
@@ -13,14 +13,12 @@
|
||||
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
|
||||
<meta property="og:image" content="/social-share.png" />
|
||||
<meta property="twitter:image" content="/social-share.png" />
|
||||
<!-- Theme preload script - injected by Vite plugin to avoid FOUC -->
|
||||
<script id="oc-theme-preload-script">
|
||||
/* THEME_PRELOAD_SCRIPT */
|
||||
</script>
|
||||
</head>
|
||||
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
||||
<script>
|
||||
;(function () {
|
||||
const savedTheme = localStorage.getItem("theme") || "oc-1"
|
||||
document.documentElement.setAttribute("data-theme", savedTheme)
|
||||
})()
|
||||
</script>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root" class="flex flex-col h-screen"></div>
|
||||
<script src="/src/entry.tsx" type="module"></script>
|
||||
|
||||
18
packages/app/script/inject-theme-preload.ts
Normal file
18
packages/app/script/inject-theme-preload.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Injects the theme preload script into index.html.
|
||||
* Run this as part of the build process.
|
||||
*/
|
||||
|
||||
import { generatePreloadScript } from "@opencode-ai/ui/theme"
|
||||
|
||||
const htmlPath = new URL("../index.html", import.meta.url).pathname
|
||||
const html = await Bun.file(htmlPath).text()
|
||||
|
||||
const script = generatePreloadScript()
|
||||
const injectedHtml = html.replace(
|
||||
/<script id="oc-theme-preload-script">\s*\/\* THEME_PRELOAD_SCRIPT \*\/\s*<\/script>/,
|
||||
`<script id="oc-theme-preload-script">${script}</script>`,
|
||||
)
|
||||
|
||||
await Bun.write(htmlPath, injectedHtml)
|
||||
console.log("Injected theme preload script into index.html")
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 />)
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
import solidPlugin from "vite-plugin-solid"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
import { fileURLToPath } from "url"
|
||||
import { generatePreloadScript } from "@opencode-ai/ui/theme"
|
||||
|
||||
/**
|
||||
* Vite plugin that injects the theme preload script into index.html.
|
||||
* This ensures the theme is applied before the page renders, avoiding FOUC.
|
||||
* @type {import("vite").Plugin}
|
||||
*/
|
||||
const themePreloadPlugin = {
|
||||
name: "opencode-desktop:theme-preload",
|
||||
transformIndexHtml(html) {
|
||||
const script = generatePreloadScript()
|
||||
return html.replace(
|
||||
/<script id="oc-theme-preload-script">\s*\/\* THEME_PRELOAD_SCRIPT \*\/\s*<\/script>/,
|
||||
`<script id="oc-theme-preload-script">${script}</script>`,
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import("vite").PluginOption}
|
||||
@@ -21,6 +38,7 @@ export default [
|
||||
}
|
||||
},
|
||||
},
|
||||
themePreloadPlugin,
|
||||
tailwindcss(),
|
||||
solidPlugin(),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user