diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx
index e1ddd4ca8c..066bd0278e 100644
--- a/.storybook/preview.tsx
+++ b/.storybook/preview.tsx
@@ -2,12 +2,31 @@ import type { Preview } from "@storybook/react-vite";
import "../src/browser/styles/globals.css";
const preview: Preview = {
+ globalTypes: {
+ theme: {
+ description: "Global theme for components",
+ defaultValue: "dark",
+ toolbar: {
+ title: "Theme",
+ icon: "circlehollow",
+ items: [
+ { value: "light", title: "Light", icon: "sun" },
+ { value: "dark", title: "Dark", icon: "moon" },
+ ],
+ dynamicTitle: true,
+ },
+ },
+ },
decorators: [
- (Story) => (
- <>
-
- >
- ),
+ (Story, context) => {
+ const theme = context.globals.theme || "dark";
+
+ return (
+
+
+
+ );
+ },
],
parameters: {
controls: {
diff --git a/src/browser/App.tsx b/src/browser/App.tsx
index 6afb546e1a..6ac79616e3 100644
--- a/src/browser/App.tsx
+++ b/src/browser/App.tsx
@@ -22,6 +22,7 @@ import { CommandRegistryProvider, useCommandRegistry } from "./contexts/CommandR
import type { CommandAction } from "./contexts/CommandRegistryContext";
import { ModeProvider } from "./contexts/ModeContext";
import { ThinkingProvider } from "./contexts/ThinkingContext";
+import { ThemeProvider } from "./contexts/ThemeContext";
import { CommandPalette } from "./components/CommandPalette";
import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sources";
@@ -32,6 +33,7 @@ import { getThinkingLevelKey } from "@/common/constants/storage";
import type { BranchListResult } from "@/common/types/ipc";
import { useTelemetry } from "./hooks/useTelemetry";
import { useStartWorkspaceCreation, getFirstProjectPath } from "./hooks/useStartWorkspaceCreation";
+import { useTheme } from "./contexts/ThemeContext";
const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"];
@@ -60,6 +62,10 @@ function AppInner() {
// Auto-collapse sidebar on mobile by default
const isMobile = typeof window !== "undefined" && window.innerWidth <= 768;
const [sidebarCollapsed, setSidebarCollapsed] = usePersistedState("sidebarCollapsed", isMobile);
+
+ // Theme state (global, not workspace-specific)
+ const { toggleTheme } = useTheme();
+
const defaultProjectPath = getFirstProjectPath(projects);
const creationChatInputRef = useRef(null);
const creationProjectPath = !selectedWorkspace
@@ -402,6 +408,7 @@ function AppInner() {
onAddProject: addProjectFromPalette,
onRemoveProject: removeProjectFromPalette,
onToggleSidebar: toggleSidebarFromPalette,
+ onToggleTheme: toggleTheme,
onNavigateWorkspace: navigateWorkspaceFromPalette,
onOpenWorkspaceInTerminal: openWorkspaceInTerminal,
};
@@ -618,9 +625,15 @@ function AppInner() {
function App() {
return (
-
-
-
+
+
+
+
+
+
+
+
+
);
}
diff --git a/src/browser/components/Messages/MarkdownComponents.tsx b/src/browser/components/Messages/MarkdownComponents.tsx
index be98648e85..af43774246 100644
--- a/src/browser/components/Messages/MarkdownComponents.tsx
+++ b/src/browser/components/Messages/MarkdownComponents.tsx
@@ -1,12 +1,8 @@
import type { ReactNode } from "react";
import React, { useState, useEffect } from "react";
import { Mermaid } from "./Mermaid";
-import {
- getShikiHighlighter,
- mapToShikiLang,
- SHIKI_THEME,
-} from "@/browser/utils/highlighting/shikiHighlighter";
-import { extractShikiLines } from "@/browser/utils/highlighting/shiki-shared";
+import { getShikiHighlighter, mapToShikiLang } from "@/browser/utils/highlighting/shikiHighlighter";
+import { extractShikiLines, getShikiTheme } from "@/browser/utils/highlighting/shiki-shared";
import { CopyButton } from "@/browser/components/ui/CopyButton";
interface CodeProps {
@@ -79,7 +75,7 @@ const CodeBlock: React.FC = ({ code, language }) => {
const html = highlighter.codeToHtml(code, {
lang: shikiLang,
- theme: SHIKI_THEME,
+ theme: getShikiTheme(),
});
if (!cancelled) {
diff --git a/src/browser/components/Messages/Mermaid.tsx b/src/browser/components/Messages/Mermaid.tsx
index 5d9c2252ed..9059652d73 100644
--- a/src/browser/components/Messages/Mermaid.tsx
+++ b/src/browser/components/Messages/Mermaid.tsx
@@ -7,26 +7,36 @@ import { usePersistedState } from "@/browser/hooks/usePersistedState";
const MIN_HEIGHT = 300;
const MAX_HEIGHT = 1200;
-// Initialize mermaid
-mermaid.initialize({
- startOnLoad: false,
- theme: "dark",
- layout: "elk",
- securityLevel: "loose",
- fontFamily: "var(--font-monospace)",
- darkMode: true,
- elk: {
- nodePlacementStrategy: "LINEAR_SEGMENTS",
- mergeEdges: true,
- },
- wrap: true,
- markdownAutoWrap: true,
- flowchart: {
- nodeSpacing: 60,
- curve: "linear",
- defaultRenderer: "elk",
- },
-});
+/**
+ * Get mermaid theme configuration based on current theme
+ */
+function getMermaidConfig() {
+ const isDark =
+ typeof document === "undefined" || document.documentElement.dataset.theme !== "light";
+ const theme: "dark" | "default" = isDark ? "dark" : "default";
+ return {
+ startOnLoad: false,
+ theme,
+ layout: "elk",
+ securityLevel: "loose" as const,
+ fontFamily: "var(--font-monospace)",
+ darkMode: isDark,
+ elk: {
+ nodePlacementStrategy: "LINEAR_SEGMENTS" as const,
+ mergeEdges: true,
+ },
+ wrap: true,
+ markdownAutoWrap: true,
+ flowchart: {
+ nodeSpacing: 60,
+ curve: "linear" as const,
+ defaultRenderer: "elk" as const,
+ },
+ };
+}
+
+// Initialize mermaid with default dark theme
+mermaid.initialize(getMermaidConfig());
// Common button styles
const getButtonStyle = (disabled = false): CSSProperties => ({
@@ -141,6 +151,9 @@ export const Mermaid: React.FC<{ chart: string }> = ({ chart }) => {
try {
setError(null);
+ // Re-initialize mermaid with current theme
+ mermaid.initialize(getMermaidConfig());
+
// Parse first to validate syntax without rendering
await mermaid.parse(chart);
diff --git a/src/browser/contexts/ThemeContext.tsx b/src/browser/contexts/ThemeContext.tsx
new file mode 100644
index 0000000000..f3ea4c4aab
--- /dev/null
+++ b/src/browser/contexts/ThemeContext.tsx
@@ -0,0 +1,40 @@
+import { createContext, useContext, useEffect, type ReactNode } from "react";
+import { usePersistedState } from "../hooks/usePersistedState";
+import { THEME_KEY } from "@/common/constants/storage";
+
+type Theme = "light" | "dark";
+
+interface ThemeContextValue {
+ theme: Theme;
+ setTheme: (theme: Theme) => void;
+ toggleTheme: () => void;
+}
+
+const ThemeContext = createContext(undefined);
+
+export function ThemeProvider(props: { children: ReactNode }) {
+ const [theme, setTheme] = usePersistedState(THEME_KEY, "dark");
+
+ // Apply theme to document root
+ useEffect(() => {
+ document.documentElement.dataset.theme = theme;
+ }, [theme]);
+
+ const toggleTheme = () => {
+ setTheme((prev) => (prev === "dark" ? "light" : "dark"));
+ };
+
+ return (
+
+ {props.children}
+
+ );
+}
+
+export function useTheme(): ThemeContextValue {
+ const context = useContext(ThemeContext);
+ if (!context) {
+ throw new Error("useTheme must be used within ThemeProvider");
+ }
+ return context;
+}
diff --git a/src/browser/styles/globals.css b/src/browser/styles/globals.css
index 30819dbbfa..0e94469056 100644
--- a/src/browser/styles/globals.css
+++ b/src/browser/styles/globals.css
@@ -193,6 +193,182 @@
--radius: 0.5rem;
}
+/* Light theme overrides using standard CSS selector */
+[data-theme="light"] {
+ /* Mode Colors - Keep hue, adjust lightness for light backgrounds */
+ --color-plan-mode: hsl(210 80% 45%);
+ --color-plan-mode-hover: hsl(210 80% 38%);
+ --color-plan-mode-light: hsl(210 80% 55%);
+
+ --color-exec-mode: hsl(268.56 70% 50%);
+ --color-exec-mode-hover: hsl(268.56 70% 43%);
+ --color-exec-mode-light: hsl(268.56 70% 60%);
+
+ --color-edit-mode: hsl(120 45% 38%);
+ --color-edit-mode-hover: hsl(120 45% 32%);
+ --color-edit-mode-light: hsl(120 45% 48%);
+
+ --color-read: hsl(210 80% 45%);
+ --color-editing-mode: hsl(30 100% 40%);
+ --color-pending: hsl(30 100% 50%);
+
+ --color-debug-mode: hsl(214 100% 50%);
+ --color-debug-light: hsl(214 100% 60%);
+ --color-debug-text: hsl(214 100% 45%);
+
+ --color-thinking-mode: hsl(271 76% 48%);
+ --color-thinking-mode-light: hsl(271 76% 58%);
+ --color-thinking-border: hsl(271 76% 48%);
+
+ /* Background & Layout - Inverted */
+ --color-background: hsl(0 0% 98%);
+ --color-background-secondary: hsl(0 0% 96%);
+ --color-border: hsl(240 3% 85%);
+ --color-foreground: hsl(0 0% 15%);
+ --color-muted-foreground: hsl(0 0% 40%);
+ --color-secondary: hsl(0 0% 55%);
+
+ /* Code */
+ --color-code-bg: hsl(0 0% 95%);
+
+ /* Buttons */
+ --color-button-bg: hsl(0 0% 90%);
+ --color-button-text: hsl(0 0% 20%);
+ --color-button-hover: hsl(0 0% 85%);
+
+ /* Messages */
+ --color-user-border: hsl(0 0% 70%);
+ --color-user-border-hover: hsl(0 0% 65%);
+ --color-assistant-border: hsl(207 45% 60%);
+ --color-assistant-border-hover: hsl(207 45% 55%);
+ --color-message-header: hsl(0 0% 20%);
+
+ /* Tokens */
+ --color-token-prompt: hsl(0 0% 55%);
+ --color-token-completion: hsl(207 100% 45%);
+ --color-token-variable: hsl(207 100% 45%);
+ --color-token-fixed: hsl(0 0% 55%);
+ --color-token-input: hsl(120 40% 38%);
+ --color-token-output: hsl(207 100% 45%);
+ --color-token-cached: hsl(0 0% 50%);
+
+ /* Toggle */
+ --color-toggle-bg: hsl(0 0% 92%);
+ --color-toggle-active: hsl(0 0% 88%);
+ --color-toggle-hover: hsl(0 0% 90%);
+ --color-toggle-text: hsl(0 0% 45%);
+ --color-toggle-text-active: hsl(0 0% 10%);
+ --color-toggle-text-hover: hsl(0 0% 30%);
+
+ /* Status */
+ --color-interrupted: hsl(38 92% 45%);
+ --color-review-accent: hsl(48 70% 45%);
+ --color-git-dirty: hsl(38 92% 45%);
+ --color-error: hsl(0 70% 50%);
+ --color-error-bg: hsl(0 50% 95%);
+
+ /* Input */
+ --color-input-bg: hsl(0 0% 100%);
+ --color-input-text: hsl(0 0% 15%);
+ --color-input-border: hsl(207 51% 65%);
+ --color-input-border-focus: hsl(193 91% 55%);
+
+ /* Scrollbar */
+ --color-scrollbar-track: hsl(0 0% 93%);
+ --color-scrollbar-thumb: hsl(0 0% 75%);
+ --color-scrollbar-thumb-hover: hsl(0 0% 65%);
+
+ /* Additional Semantic Colors */
+ --color-muted: hsl(0 0% 50%);
+ --color-muted-light: hsl(0 0% 55%);
+ --color-muted-dark: hsl(0 0% 45%);
+ --color-placeholder: hsl(0 0% 60%);
+ --color-subtle: hsl(0 0% 45%);
+ --color-dim: hsl(0 0% 55%);
+ --color-light: hsl(0 0% 25%);
+ --color-lighter: hsl(0 0% 20%);
+ --color-bright: hsl(0 0% 30%);
+ --color-subdued: hsl(0 0% 45%);
+ --color-label: hsl(0 0% 40%);
+ --color-gray: hsl(0 0% 52%);
+ --color-medium: hsl(0 0% 45%);
+
+ --color-border-light: hsl(240 3% 88%);
+ --color-border-medium: hsl(0 0% 82%);
+ --color-border-darker: hsl(0 0% 78%);
+ --color-border-subtle: hsl(0 0% 75%);
+ --color-border-gray: hsl(240 1% 80%);
+
+ --color-dark: hsl(0 0% 93%);
+ --color-darker: hsl(0 0% 95%);
+ --color-hover: hsl(0 0% 92%);
+ --color-bg-medium: hsl(0 0% 85%);
+ --color-bg-light: hsl(0 0% 88%);
+ --color-bg-subtle: hsl(240 3% 94%);
+
+ --color-separator: hsl(0 0% 90%);
+ --color-separator-light: hsl(0 0% 85%);
+ --color-modal-bg: hsl(0 0% 97%);
+
+ --color-accent: hsl(207 100% 45%);
+ --color-accent-hover: hsl(207 100% 40%);
+ --color-accent-dark: hsl(207 100% 38%);
+ --color-accent-darker: hsl(202 100% 30%);
+ --color-accent-light: hsl(198 100% 55%);
+
+ --color-success: hsl(122 39% 45%);
+ --color-success-light: hsl(123 46% 55%);
+
+ --color-danger: hsl(4 90% 52%);
+ --color-danger-light: hsl(0 91% 62%);
+ --color-danger-soft: hsl(6 93% 60%);
+
+ --color-warning: hsl(45 100% 45%);
+ --color-warning-light: hsl(0 91% 65%);
+
+ /* Code syntax highlighting */
+ --color-code-type: hsl(197 71% 40%);
+ --color-code-keyword: hsl(210 59% 45%);
+
+ /* Toast and notification backgrounds */
+ --color-toast-success-bg: hsl(207 100% 45% / 0.1);
+ --color-toast-success-text: hsl(207 100% 40%);
+ --color-toast-error-bg: hsl(5 89% 50% / 0.1);
+ --color-toast-error-text: hsl(5 89% 45%);
+ --color-toast-error-border: hsl(5 89% 50%);
+ --color-toast-fatal-bg: hsl(0 50% 95%);
+ --color-toast-fatal-border: hsl(0 50% 85%);
+
+ /* Semi-transparent overlays */
+ --color-danger-overlay: hsl(4 90% 52% / 0.1);
+ --color-warning-overlay: hsl(45 100% 45% / 0.1);
+ --color-gray-overlay: hsl(0 0% 50% / 0.05);
+ --color-white-overlay-light: hsl(0 0% 0% / 0.03);
+ --color-white-overlay: hsl(0 0% 0% / 0.05);
+ --color-selection: hsl(204 100% 65% / 0.3);
+ --color-vim-status: hsl(0 0% 20% / 0.6);
+ --color-code-keyword-overlay-light: hsl(210 100% 50% / 0.05);
+ --color-code-keyword-overlay: hsl(210 100% 50% / 0.15);
+
+ /* Info/status colors */
+ --color-info-light: hsl(5 100% 60%);
+ --color-info-yellow: hsl(38 100% 55%);
+
+ /* Review/diff backgrounds */
+ --color-review-bg-blue: hsl(201 31% 90%);
+ --color-review-bg-info: hsl(202 33% 92%);
+ --color-review-bg-warning: hsl(40 100% 95%);
+ --color-review-warning: hsl(38 100% 40%);
+ --color-review-warning-medium: hsl(38 100% 45%);
+ --color-review-warning-light: hsl(40 100% 92%);
+
+ /* Error backgrounds */
+ --color-error-bg-dark: hsl(0 50% 93%);
+
+ /* Radius */
+ --radius: 0.5rem;
+}
+
:root {
/* Legacy RGB for special uses */
--plan-mode-rgb: 31, 107, 184;
diff --git a/src/browser/utils/commandIds.ts b/src/browser/utils/commandIds.ts
index e30ae88544..01d0167c7b 100644
--- a/src/browser/utils/commandIds.ts
+++ b/src/browser/utils/commandIds.ts
@@ -33,6 +33,7 @@ export const CommandIds = {
navNext: () => "nav:next" as const,
navPrev: () => "nav:prev" as const,
navToggleSidebar: () => "nav:toggleSidebar" as const,
+ navToggleTheme: () => "nav:toggleTheme" as const,
// Chat commands
chatClear: () => "chat:clear" as const,
diff --git a/src/browser/utils/commands/sources.test.ts b/src/browser/utils/commands/sources.test.ts
index 8b6d613d7e..73e501507f 100644
--- a/src/browser/utils/commands/sources.test.ts
+++ b/src/browser/utils/commands/sources.test.ts
@@ -44,6 +44,7 @@ const mk = (over: Partial[0]> = {}) => {
onAddProject: () => undefined,
onRemoveProject: () => undefined,
onToggleSidebar: () => undefined,
+ onToggleTheme: () => undefined,
onNavigateWorkspace: () => undefined,
onOpenWorkspaceInTerminal: () => undefined,
getBranchesForProject: () =>
diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts
index 2c3aa93483..1d24e7a3f4 100644
--- a/src/browser/utils/commands/sources.ts
+++ b/src/browser/utils/commands/sources.ts
@@ -39,6 +39,7 @@ export interface BuildSourcesParams {
onAddProject: () => void;
onRemoveProject: (path: string) => void;
onToggleSidebar: () => void;
+ onToggleTheme: () => void;
onNavigateWorkspace: (dir: "next" | "prev") => void;
onOpenWorkspaceInTerminal: (workspaceId: string) => void;
}
@@ -303,6 +304,12 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi
shortcutHint: formatKeybind(KEYBINDS.TOGGLE_SIDEBAR),
run: () => p.onToggleSidebar(),
},
+ {
+ id: CommandIds.navToggleTheme(),
+ title: "Toggle Theme",
+ section: section.navigation,
+ run: () => p.onToggleTheme(),
+ },
]);
// Chat utilities
diff --git a/src/browser/utils/highlighting/highlightDiffChunk.ts b/src/browser/utils/highlighting/highlightDiffChunk.ts
index ca41d361d6..228f30facd 100644
--- a/src/browser/utils/highlighting/highlightDiffChunk.ts
+++ b/src/browser/utils/highlighting/highlightDiffChunk.ts
@@ -1,9 +1,5 @@
-import {
- getShikiHighlighter,
- mapToShikiLang,
- SHIKI_THEME,
- MAX_DIFF_SIZE_BYTES,
-} from "./shikiHighlighter";
+import { getShikiHighlighter, mapToShikiLang, MAX_DIFF_SIZE_BYTES } from "./shikiHighlighter";
+import { getShikiTheme } from "./shiki-shared";
import type { DiffChunk } from "./diffChunking";
/**
@@ -83,7 +79,7 @@ export async function highlightDiffChunk(
const html = highlighter.codeToHtml(code, {
lang: shikiLang,
- theme: SHIKI_THEME,
+ theme: getShikiTheme(),
});
// Parse HTML to extract line contents
diff --git a/src/browser/utils/highlighting/shiki-shared.ts b/src/browser/utils/highlighting/shiki-shared.ts
index a07cd252be..23c39d5430 100644
--- a/src/browser/utils/highlighting/shiki-shared.ts
+++ b/src/browser/utils/highlighting/shiki-shared.ts
@@ -3,8 +3,15 @@
* Used by both the main app and documentation theme
*/
-// Shiki theme used throughout the application
-export const SHIKI_THEME = "min-dark";
+/**
+ * Get the appropriate Shiki theme based on the current theme
+ * Falls back to reading from document if called outside React context
+ */
+export function getShikiTheme(): "min-dark" | "min-light" {
+ if (typeof document === "undefined") return "min-dark";
+ const theme = document.documentElement.dataset.theme;
+ return theme === "light" ? "min-light" : "min-dark";
+}
/**
* Map language names to Shiki-compatible language IDs
diff --git a/src/browser/utils/highlighting/shikiHighlighter.ts b/src/browser/utils/highlighting/shikiHighlighter.ts
index b98a54e600..f50ee810f2 100644
--- a/src/browser/utils/highlighting/shikiHighlighter.ts
+++ b/src/browser/utils/highlighting/shikiHighlighter.ts
@@ -1,8 +1,5 @@
import { createHighlighter, type Highlighter } from "shiki";
-// Shiki theme used throughout the application
-export const SHIKI_THEME = "min-dark";
-
// Maximum diff size to highlight (in bytes)
// Diffs larger than this will fall back to plain text for performance
export const MAX_DIFF_SIZE_BYTES = 32768; // 32kb
@@ -21,7 +18,7 @@ export async function getShikiHighlighter(): Promise {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
if (!highlighterPromise) {
highlighterPromise = createHighlighter({
- themes: [SHIKI_THEME],
+ themes: ["min-dark", "min-light"],
langs: [], // Load languages on-demand via highlightDiffChunk
});
}
diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts
index 5a2b2f1215..e9d00eea0c 100644
--- a/src/common/constants/storage.ts
+++ b/src/common/constants/storage.ts
@@ -120,6 +120,12 @@ export const USE_1M_CONTEXT_KEY = "use1MContext";
*/
export const PREFERRED_COMPACTION_MODEL_KEY = "preferredCompactionModel";
+/**
+ * Get the localStorage key for theme preference (global)
+ * Format: "theme"
+ */
+export const THEME_KEY = "theme";
+
/**
* Get the localStorage key for vim mode preference (global)
* Format: "vimEnabled"