diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index e1ddd4ca8..c2541ba7b 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,13 +1,50 @@ +import React, { useEffect } from "react"; import type { Preview } from "@storybook/react-vite"; +import { + ThemeProvider, + useTheme, + type ThemeMode, +} from "../src/browser/contexts/ThemeContext"; import "../src/browser/styles/globals.css"; +const ThemeStorySync: React.FC<{ mode: ThemeMode }> = ({ mode }) => { + const { theme, setTheme } = useTheme(); + + useEffect(() => { + if (theme !== mode) { + setTheme(mode); + } + }, [mode, setTheme, theme]); + + return null; +}; + const preview: Preview = { + globalTypes: { + theme: { + name: "Theme", + description: "Choose between light and dark UI themes", + defaultValue: "dark", + toolbar: { + icon: "mirror", + items: [ + { value: "dark", title: "Dark" }, + { value: "light", title: "Light" }, + ], + dynamicTitle: true, + }, + }, + }, decorators: [ - (Story) => ( - <> - - - ), + (Story, context) => { + const mode = (context.globals.theme ?? "dark") as ThemeMode; + return ( + + + + + ); + }, ], parameters: { controls: { @@ -16,6 +53,12 @@ const preview: Preview = { date: /Date$/i, }, }, + chromatic: { + modes: { + dark: { globals: { theme: "dark" } }, + light: { globals: { theme: "light" } }, + }, + }, }, }; diff --git a/bun.lock b/bun.lock index 2d4ab805b..8f535ca94 100644 --- a/bun.lock +++ b/bun.lock @@ -34,6 +34,7 @@ "ghostty-web": "next", "jsonc-parser": "^3.3.1", "lru-cache": "^11.2.2", + "lucide-react": "^0.553.0", "markdown-it": "^14.1.0", "minimist": "^1.2.8", "motion": "^12.23.24", @@ -964,7 +965,7 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], + "@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], "@types/plist": ["@types/plist@3.0.5", "", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="], @@ -1788,7 +1789,7 @@ "get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="], - "ghostty-web": ["ghostty-web@0.2.1-next.2.g6ea6f04", "", {}, "sha512-88Cf6NUnVoqD6oS5tmXnI/gEEKicZYucAXK6RGtH/Tlfd4lKddy+tIIkUZ7GnMaxkaFnyzjrQ2kAFJcy4Ld+/w=="], + "ghostty-web": ["ghostty-web@0.2.1-next.3.g5e035a2", "", { "bin": { "ghostty-web": "bin/ghostty-web.js" } }, "sha512-iQFTRn3N3+yZJkr2AA3EUpDglL/Vyc2BiPfJuOBHt7b+JyrjLPJxIU37fsf/2+WnS9y0yFvMI3WAPrkGPA2iBw=="], "glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="], @@ -2226,7 +2227,7 @@ "lru-cache": ["lru-cache@11.2.2", "", {}, "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg=="], - "lucide-react": ["lucide-react@0.542.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw=="], + "lucide-react": ["lucide-react@0.553.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-BRgX5zrWmNy/lkVAe0dXBgd7XQdZ3HTf+Hwe3c9WK6dqgnj9h+hxV+MDncM88xDWlCq27+TKvHGE70ViODNILw=="], "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], @@ -3286,7 +3287,7 @@ "dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], - "electron/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], + "electron/@types/node": ["@types/node@22.18.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A=="], "electron-builder/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -3514,6 +3515,8 @@ "stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], + "streamdown/lucide-react": ["lucide-react@0.542.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw=="], + "string-length/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], "string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], diff --git a/index.html b/index.html index a96f986c4..464c26382 100644 --- a/index.html +++ b/index.html @@ -16,13 +16,38 @@ margin: 0; padding: 0; overflow: hidden; - background: #1e1e1e; + background: var(--color-background, #1e1e1e); } #root { height: 100vh; overflow: hidden; } +
diff --git a/package.json b/package.json index 0c74a3ab8..3d846310e 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "ghostty-web": "next", "jsonc-parser": "^3.3.1", "lru-cache": "^11.2.2", + "lucide-react": "^0.553.0", "markdown-it": "^14.1.0", "minimist": "^1.2.8", "motion": "^12.23.24", diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 585a75550..eea416ab8 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -21,6 +21,7 @@ import { CommandRegistryProvider, useCommandRegistry } from "./contexts/CommandR import type { CommandAction } from "./contexts/CommandRegistryContext"; import { ModeProvider } from "./contexts/ModeContext"; import { ProviderOptionsProvider } from "./contexts/ProviderOptionsContext"; +import { ThemeProvider, useTheme, type ThemeMode } from "./contexts/ThemeContext"; import { ThinkingProvider } from "./contexts/ThinkingContext"; import { CommandPalette } from "./components/CommandPalette"; import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sources"; @@ -48,6 +49,13 @@ function AppInner() { beginWorkspaceCreation, clearPendingWorkspaceCreation, } = useWorkspaceContext(); + const { theme, setTheme, toggleTheme } = useTheme(); + const setThemePreference = useCallback( + (nextTheme: ThemeMode) => { + setTheme(nextTheme); + }, + [setTheme] + ); const { projects, removeProject, @@ -389,6 +397,7 @@ function AppInner() { projects, workspaceMetadata, selectedWorkspace, + theme, getThinkingLevel: getThinkingLevelForWorkspace, onSetThinkingLevel: setThinkingLevelFromPalette, onStartWorkspaceCreation: openNewWorkspaceFromPalette, @@ -401,6 +410,8 @@ function AppInner() { onToggleSidebar: toggleSidebarFromPalette, onNavigateWorkspace: navigateWorkspaceFromPalette, onOpenWorkspaceInTerminal: openWorkspaceInTerminal, + onToggleTheme: toggleTheme, + onSetTheme: setThemePreference, }; useEffect(() => { @@ -587,7 +598,7 @@ function AppInner() { })() ) : (
- - + + + + + ); } diff --git a/src/browser/components/CommandPalette.tsx b/src/browser/components/CommandPalette.tsx index 94ed22516..105b04fdb 100644 --- a/src/browser/components/CommandPalette.tsx +++ b/src/browser/components/CommandPalette.tsx @@ -378,7 +378,7 @@ export const CommandPalette: React.FC = ({ getSlashContext }} > e.stopPropagation()} shouldFilter={shouldUseCmdkFilter} filter={(value, search) => { @@ -395,7 +395,7 @@ export const CommandPalette: React.FC = ({ getSlashContext }} > = ({ getSlashContext {groupsWithItems.map((group) => ( + {group.name} +
+ } + className="px-1.5 py-2" > {group.items.map((item) => ( { if ("prompt" in item && item.prompt) { addRecent(item.id); @@ -459,12 +463,14 @@ export const CommandPalette: React.FC = ({ getSlashContext {"subtitle" in item && item.subtitle && ( <>
- {item.subtitle} + + {item.subtitle} + )} {"shortcutHint" in item && item.shortcutHint && ( - + {item.shortcutHint} )} @@ -473,7 +479,9 @@ export const CommandPalette: React.FC = ({ getSlashContext ))} {!hasAnyItems && ( -
{emptyText ?? "No results"}
+
+ {emptyText ?? "No results"} +
)} diff --git a/src/browser/components/KebabMenu.tsx b/src/browser/components/KebabMenu.tsx index 3b79182e8..18126ba53 100644 --- a/src/browser/components/KebabMenu.tsx +++ b/src/browser/components/KebabMenu.tsx @@ -115,8 +115,8 @@ export const KebabMenu: React.FC = ({ items, className }) => { item.disabled ? "bg-dark text-muted-light cursor-not-allowed opacity-50 hover:bg-dark hover:text-muted-light" : item.active - ? "bg-white/15 text-foreground cursor-pointer hover:bg-white/15 hover:text-white" - : "bg-dark text-foreground cursor-pointer hover:bg-white/15 hover:text-white" + ? "bg-white/15 text-foreground cursor-pointer hover:bg-white/15 hover:text-[var(--color-hover-foreground)]" + : "bg-dark text-foreground cursor-pointer hover:bg-white/15 hover:text-[var(--color-hover-foreground)]" )} > {item.emoji && ( diff --git a/src/browser/components/Messages/HistoryHiddenMessage.tsx b/src/browser/components/Messages/HistoryHiddenMessage.tsx index 6b3dfae50..2113ce294 100644 --- a/src/browser/components/Messages/HistoryHiddenMessage.tsx +++ b/src/browser/components/Messages/HistoryHiddenMessage.tsx @@ -14,8 +14,8 @@ export const HistoryHiddenMessage: React.FC = ({ return (
diff --git a/src/browser/components/Messages/MarkdownComponents.tsx b/src/browser/components/Messages/MarkdownComponents.tsx index 61f7b9e4f..9263cf592 100644 --- a/src/browser/components/Messages/MarkdownComponents.tsx +++ b/src/browser/components/Messages/MarkdownComponents.tsx @@ -4,8 +4,10 @@ import { Mermaid } from "./Mermaid"; import { getShikiHighlighter, mapToShikiLang, - SHIKI_THEME, + SHIKI_DARK_THEME, + SHIKI_LIGHT_THEME, } from "@/browser/utils/highlighting/shikiHighlighter"; +import { useTheme } from "@/browser/contexts/ThemeContext"; import { extractShikiLines } from "@/browser/utils/highlighting/shiki-shared"; import { CopyButton } from "@/browser/components/ui/CopyButton"; @@ -45,6 +47,7 @@ interface CodeBlockProps { */ const CodeBlock: React.FC = ({ code, language }) => { const [highlightedLines, setHighlightedLines] = useState(null); + const { theme: themeMode } = useTheme(); // Split code into lines, removing trailing empty line const plainLines = code @@ -53,6 +56,9 @@ const CodeBlock: React.FC = ({ code, language }) => { useEffect(() => { let cancelled = false; + const shikiTheme = themeMode === "light" ? SHIKI_LIGHT_THEME : SHIKI_DARK_THEME; + + setHighlightedLines(null); async function highlight() { try { @@ -79,7 +85,7 @@ const CodeBlock: React.FC = ({ code, language }) => { const html = highlighter.codeToHtml(code, { lang: shikiLang, - theme: SHIKI_THEME, + theme: shikiTheme, }); if (!cancelled) { @@ -100,7 +106,7 @@ const CodeBlock: React.FC = ({ code, language }) => { return () => { cancelled = true; }; - }, [code, language]); + }, [code, language, themeMode]); const lines = highlightedLines ?? plainLines; diff --git a/src/browser/components/Messages/MessageWindow.tsx b/src/browser/components/Messages/MessageWindow.tsx index cb95a5e5c..32f9742f0 100644 --- a/src/browser/components/Messages/MessageWindow.tsx +++ b/src/browser/components/Messages/MessageWindow.tsx @@ -66,7 +66,7 @@ export const MessageWindow: React.FC = ({ className={cn( "mt-4 mb-1 flex w-full flex-col relative isolate w-fit", variant === "user" && "ml-auto", - variant === "assistant" && "text-white", + variant === "assistant" && "text-foreground", isLastPartOfMessage && "mb-4" )} data-message-block @@ -74,7 +74,7 @@ export const MessageWindow: React.FC = ({
@@ -82,7 +82,7 @@ export const MessageWindow: React.FC = ({
{showJson ? ( -
+              
                 {JSON.stringify(message, null, 2)}
               
) : ( @@ -95,7 +95,7 @@ export const MessageWindow: React.FC = ({
diff --git a/src/browser/components/Messages/UserMessage.tsx b/src/browser/components/Messages/UserMessage.tsx index 41b9c8add..9f7ffdd6e 100644 --- a/src/browser/components/Messages/UserMessage.tsx +++ b/src/browser/components/Messages/UserMessage.tsx @@ -99,7 +99,7 @@ export const UserMessage: React.FC = ({ variant="user" > {content && ( -
+        
           {content}
         
)} @@ -110,7 +110,7 @@ export const UserMessage: React.FC = ({ key={idx} src={img.url} alt={`Attachment ${idx + 1}`} - className="max-h-[300px] max-w-72 rounded-xl border border-white/10 object-cover" + className="max-h-[300px] max-w-72 rounded-xl border border-[var(--color-attachment-border)] object-cover" /> ))}
diff --git a/src/browser/components/Modal.tsx b/src/browser/components/Modal.tsx index edd311a19..690922955 100644 --- a/src/browser/components/Modal.tsx +++ b/src/browser/components/Modal.tsx @@ -29,7 +29,7 @@ export const ModalContent: React.FC<
( "text-[11px] font-monospace py-1.5 px-2.5 cursor-pointer transition-colors duration-100", "first:rounded-t last:rounded-b", index === highlightedIndex - ? "text-white bg-hover" - : "text-light bg-transparent hover:bg-hover hover:text-white" + ? "text-foreground bg-hover" + : "text-light bg-transparent hover:bg-hover hover:text-foreground" )} onClick={() => handleSelectModel(model)} > diff --git a/src/browser/components/ProjectCreateModal.tsx b/src/browser/components/ProjectCreateModal.tsx index 6a7f51bec..a339d4d97 100644 --- a/src/browser/components/ProjectCreateModal.tsx +++ b/src/browser/components/ProjectCreateModal.tsx @@ -1,7 +1,5 @@ import React, { useState, useCallback } from "react"; import { Modal, ModalActions, CancelButton, PrimaryButton } from "./Modal"; -import type { IPCApi } from "@/common/types/ipc"; -import { DirectoryPickerModal } from "./DirectoryPickerModal"; import type { ProjectConfig } from "@/node/config"; interface ProjectCreateModalProps { @@ -23,13 +21,7 @@ export const ProjectCreateModal: React.FC = ({ }) => { const [path, setPath] = useState(""); const [error, setError] = useState(""); - // Detect desktop environment where native directory picker is available - const isDesktop = - window.api.platform !== "browser" && typeof window.api.projects.pickDirectory === "function"; - const api = window.api as unknown as IPCApi; - const hasWebFsPicker = window.api.platform === "browser" && !!api.fs?.listDirectory; const [isCreating, setIsCreating] = useState(false); - const [isDirPickerOpen, setIsDirPickerOpen] = useState(false); const handleCancel = useCallback(() => { setPath(""); @@ -37,23 +29,6 @@ export const ProjectCreateModal: React.FC = ({ onClose(); }, [onClose]); - const handleWebPickerPathSelected = useCallback((selected: string) => { - setPath(selected); - setError(""); - }, []); - - const handleBrowse = useCallback(async () => { - try { - const selectedPath = await window.api.projects.pickDirectory(); - if (selectedPath) { - setPath(selectedPath); - setError(""); - } - } catch (err) { - console.error("Failed to pick directory:", err); - } - }, []); - const handleSelect = useCallback(async () => { const trimmedPath = path.trim(); if (!trimmedPath) { @@ -103,14 +78,6 @@ export const ProjectCreateModal: React.FC = ({ } }, [path, onSuccess, onClose]); - const handleBrowseClick = useCallback(() => { - if (isDesktop) { - void handleBrowse(); - } else if (hasWebFsPicker) { - setIsDirPickerOpen(true); - } - }, [handleBrowse, hasWebFsPicker, isDesktop]); - const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter") { @@ -122,55 +89,35 @@ export const ProjectCreateModal: React.FC = ({ ); return ( - <> - -
- { - setPath(e.target.value); - setError(""); - }} - onKeyDown={handleKeyDown} - placeholder="/home/user/projects/my-project" - autoFocus - disabled={isCreating} - className="bg-modal-bg border-border-medium focus:border-accent placeholder:text-muted w-full flex-1 rounded border px-3 py-2 font-mono text-sm text-white focus:outline-none disabled:opacity-50" - /> - {(isDesktop || hasWebFsPicker) && ( - - )} -
- {error &&
{error}
} - - - Cancel - - void handleSelect()} disabled={isCreating}> - {isCreating ? "Adding..." : "Add Project"} - - -
- setIsDirPickerOpen(false)} - onSelectPath={handleWebPickerPathSelected} + + { + setPath(e.target.value); + setError(""); + }} + onKeyDown={handleKeyDown} + placeholder="/home/user/projects/my-project" + autoFocus + disabled={isCreating} + className="bg-modal-bg border-border-medium focus:border-accent placeholder:text-muted text-foreground mb-5 w-full rounded border px-3 py-2 font-mono text-sm focus:outline-none disabled:opacity-50" /> - + {error &&
{error}
} + + + Cancel + + void handleSelect()} disabled={isCreating}> + {isCreating ? "Adding..." : "Add Project"} + + +
); }; diff --git a/src/browser/components/RightSidebar.tsx b/src/browser/components/RightSidebar.tsx index 15134529d..fb032c785 100644 --- a/src/browser/components/RightSidebar.tsx +++ b/src/browser/components/RightSidebar.tsx @@ -226,7 +226,7 @@ const RightSidebarComponent: React.FC = ({ className={cn( "w-full py-2.5 px-[15px] border-none border-solid cursor-pointer font-primary text-[13px] font-medium transition-all duration-200", selectedTab === "costs" - ? "text-white bg-separator border-b-2 border-b-plan-mode" + ? "bg-separator border-b-2 border-b-plan-mode text-[var(--color-sidebar-tab-active)]" : "bg-transparent text-secondary border-b-2 border-b-transparent hover:bg-background-secondary hover:text-foreground" )} onClick={() => setSelectedTab("costs")} @@ -247,7 +247,7 @@ const RightSidebarComponent: React.FC = ({ className={cn( "w-full py-2.5 px-[15px] border-none border-solid cursor-pointer font-primary text-[13px] font-medium transition-all duration-200", selectedTab === "review" - ? "text-white bg-separator border-b-2 border-b-plan-mode" + ? "bg-separator border-b-2 border-b-plan-mode text-[var(--color-sidebar-tab-active)]" : "bg-transparent text-secondary border-b-2 border-b-transparent hover:bg-background-secondary hover:text-foreground" )} onClick={() => setSelectedTab("review")} diff --git a/src/browser/components/RightSidebar/CodeReview/ReviewControls.tsx b/src/browser/components/RightSidebar/CodeReview/ReviewControls.tsx index 10f19aa0a..6946a1332 100644 --- a/src/browser/components/RightSidebar/CodeReview/ReviewControls.tsx +++ b/src/browser/components/RightSidebar/CodeReview/ReviewControls.tsx @@ -115,7 +115,7 @@ export const ReviewControls: React.FC = ({ )} -
- -
{buildDate}
- Built at {extendedTimestamp} -
+
+ + +
{buildDate}
+ Built at {extendedTimestamp} +
+
); } diff --git a/src/browser/components/shared/DiffRenderer.tsx b/src/browser/components/shared/DiffRenderer.tsx index 44212612a..f82092730 100644 --- a/src/browser/components/shared/DiffRenderer.tsx +++ b/src/browser/components/shared/DiffRenderer.tsx @@ -9,6 +9,7 @@ import { cn } from "@/common/lib/utils"; import { getLanguageFromPath } from "@/common/utils/git/languageDetector"; import { Tooltip, TooltipWrapper } from "../Tooltip"; import { groupDiffLines } from "@/browser/utils/highlighting/diffChunking"; +import { useTheme, type ThemeMode } from "@/browser/contexts/ThemeContext"; import { highlightDiffChunk, type HighlightedChunk, @@ -176,7 +177,8 @@ function useHighlightedDiff( content: string, language: string, oldStart: number, - newStart: number + newStart: number, + themeMode: ThemeMode ): HighlightedChunk[] | null { const [chunks, setChunks] = useState(null); // Track if we've already highlighted with real syntax (to prevent downgrading) @@ -199,7 +201,7 @@ function useHighlightedDiff( // Highlight each chunk (without search decorations - those are applied later) const highlighted = await Promise.all( - diffChunks.map((chunk) => highlightDiffChunk(chunk, language)) + diffChunks.map((chunk) => highlightDiffChunk(chunk, language, themeMode)) ); if (!cancelled) { @@ -216,7 +218,7 @@ function useHighlightedDiff( return () => { cancelled = true; }; - }, [content, language, oldStart, newStart]); + }, [content, language, oldStart, newStart, themeMode]); return chunks; } @@ -240,12 +242,13 @@ export const DiffRenderer: React.FC = ({ maxHeight, }) => { // Detect language for syntax highlighting (memoized to prevent repeated detection) + const { theme } = useTheme(); const language = React.useMemo( () => (filePath ? getLanguageFromPath(filePath) : "text"), [filePath] ); - const highlightedChunks = useHighlightedDiff(content, language, oldStart, newStart); + const highlightedChunks = useHighlightedDiff(content, language, oldStart, newStart, theme); // Show loading state while highlighting if (!highlightedChunks) { @@ -446,6 +449,7 @@ export const SelectableDiffRenderer = React.memo( searchConfig, enableHighlighting = true, }) => { + const { theme } = useTheme(); const [selection, setSelection] = React.useState(null); // Detect language for syntax highlighting (memoized to prevent repeated detection) @@ -459,7 +463,8 @@ export const SelectableDiffRenderer = React.memo( content, enableHighlighting ? language : "text", oldStart, - newStart + newStart, + theme ); // Build lineData from highlighted chunks (memoized to prevent repeated parsing) diff --git a/src/browser/contexts/ThemeContext.tsx b/src/browser/contexts/ThemeContext.tsx new file mode 100644 index 000000000..1ead055a5 --- /dev/null +++ b/src/browser/contexts/ThemeContext.tsx @@ -0,0 +1,85 @@ +import React, { + createContext, + useCallback, + useContext, + useLayoutEffect, + useMemo, + type ReactNode, +} from "react"; +import { usePersistedState } from "@/browser/hooks/usePersistedState"; +import { UI_THEME_KEY } from "@/common/constants/storage"; + +export type ThemeMode = "light" | "dark"; + +interface ThemeContextValue { + theme: ThemeMode; + setTheme: React.Dispatch>; + toggleTheme: () => void; +} + +const ThemeContext = createContext(null); + +const DARK_THEME_COLOR = "#1e1e1e"; +const LIGHT_THEME_COLOR = "#f5f6f8"; + +function resolveSystemTheme(): ThemeMode { + if (typeof window === "undefined" || !window.matchMedia) { + return "dark"; + } + + return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"; +} + +function applyThemeToDocument(theme: ThemeMode) { + if (typeof document === "undefined") { + return; + } + + const root = document.documentElement; + root.dataset.theme = theme; + root.style.colorScheme = theme; + + const themeColor = theme === "light" ? LIGHT_THEME_COLOR : DARK_THEME_COLOR; + const meta = document.querySelector('meta[name="theme-color"]'); + if (meta) { + meta.setAttribute("content", themeColor); + } + + const body = document.body; + if (body) { + body.style.backgroundColor = "var(--color-background)"; + } +} + +export function ThemeProvider(props: { children: ReactNode }) { + const [theme, setTheme] = usePersistedState(UI_THEME_KEY, resolveSystemTheme(), { + listener: true, + }); + + useLayoutEffect(() => { + applyThemeToDocument(theme); + }, [theme]); + + const toggleTheme = useCallback(() => { + setTheme((current) => (current === "dark" ? "light" : "dark")); + }, [setTheme]); + + const value = useMemo( + () => ({ + theme, + setTheme, + toggleTheme, + }), + [setTheme, theme, toggleTheme] + ); + + return {props.children}; +} + +export function useTheme(): ThemeContextValue { + const context = useContext(ThemeContext); + if (!context) { + throw new Error("useTheme must be used within a ThemeProvider"); + } + return context; +} diff --git a/src/browser/styles/globals.css b/src/browser/styles/globals.css index c2473a18b..ee0cf3cd5 100644 --- a/src/browser/styles/globals.css +++ b/src/browser/styles/globals.css @@ -82,9 +82,27 @@ --color-button-hover: hsl(0 0% 29%); /* Messages */ - --color-user-border: hsl(0 0% 45%); - --color-user-border-hover: hsl(0 0% 44%); + --color-user-border: hsla(0 0% 100% / 0.1); + --color-user-surface: hsla(0 0% 100% / 0.06); + --color-user-border-hover: hsla(0 0% 100% / 0.16); + --color-user-text: #f1f5f9; + --color-message-debug-bg: rgba(0, 0, 0, 0.3); + --color-message-debug-border: rgba(255, 255, 255, 0.1); + --color-message-debug-text: rgba(255, 255, 255, 0.8); + --color-message-hidden-bg: rgba(255, 255, 255, 0.03); + --color-attachment-border: rgba(255, 255, 255, 0.1); + --color-line-number-bg: rgba(0, 0, 0, 0.2); + --color-line-number-text: rgba(255, 255, 255, 0.4); + --color-line-number-border: rgba(255, 255, 255, 0.1); --color-assistant-border: hsl(207 45% 40%); + --color-hover-foreground: hsl(0 0% 96%); + --color-command-surface: hsl(0 0% 15%); + --color-command-border: hsl(240 2% 25%); + --color-command-input: hsl(0 0% 9%); + --color-command-input-border: hsla(0 0% 100% / 0.08); + --color-command-foreground: hsl(0 0% 83%); + --color-command-subdued: hsl(0 0% 63%); + --color-sidebar-tab-active: hsl(0 0% 100%); --color-assistant-border-hover: hsl(207 45% 50%); --color-message-header: hsl(0 0% 80%); @@ -249,6 +267,208 @@ "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; } +:root[data-theme="light"] { + color-scheme: light; + + --color-plan-mode: hsl(210 70% 40%); + --color-plan-mode-hover: hsl(210 70% 52%); + --color-plan-mode-light: hsl(210 70% 68%); + --color-plan-mode-alpha: hsla(210 70% 40% / 0.08); + + --color-exec-mode: hsl(268.56 94.04% 55.19%); + --color-exec-mode-hover: hsl(268.56 94.04% 67%); + --color-exec-mode-light: hsl(268.56 94.04% 78%); + + --color-edit-mode: hsl(120 50% 35%); + --color-edit-mode-hover: hsl(120 50% 45%); + --color-edit-mode-light: hsl(120 50% 60%); + + --color-read: hsl(210 70% 40%); + --color-editing-mode: hsl(30 100% 50%); + --color-editing-mode-alpha: hsla(30 100% 50% / 0.08); + --color-pending: hsl(30 100% 64%); + + --color-debug-mode: hsl(214 100% 56%); + --color-debug-light: hsl(214 100% 68%); + --color-debug-text: hsl(214 100% 32%); + + --color-thinking-mode: hsl(271 70% 46%); + --color-thinking-mode-light: hsl(271 70% 62%); + --color-thinking-border: hsl(271 70% 46%); + + --color-background: hsl(210 33% 98%); + --color-background-secondary: hsl(210 36% 95%); + --color-border: hsl(210 24% 82%); + --color-foreground: hsl(210 18% 16%); + --color-text: hsl(210 18% 16%); + --color-text-light: hsl(210 15% 28%); + --color-text-secondary: hsl(210 12% 42%); + --color-muted-foreground: hsl(210 14% 48%); + --color-secondary: hsl(210 18% 60%); + + --color-code-bg: hsl(210 40% 96%); + + --color-button-bg: hsl(210 35% 94%); + --color-button-text: hsl(210 20% 20%); + --color-button-hover: hsl(210 32% 90%); + + --color-user-surface: hsl(0 0% 91%); + --color-user-border: hsl(210 20% 78%); + --color-user-border-hover: hsl(210 20% 68%); + --color-user-text: hsl(210 18% 20%); + --color-message-debug-bg: hsl(210 40% 95%); + --color-message-debug-border: hsl(210 26% 82%); + --color-message-debug-text: hsl(210 28% 32%); + --color-message-hidden-bg: hsl(210 36% 94%); + --color-attachment-border: hsl(210 24% 82%); + --color-line-number-bg: hsl(210 34% 93%); + --color-line-number-text: hsl(210 14% 46%); + --color-line-number-border: hsl(210 22% 84%); + --color-assistant-border: hsl(207 65% 50%); + --color-hover-foreground: hsl(210 18% 12%); + --color-command-surface: hsl(210 32% 97%); + --color-command-border: hsl(210 22% 82%); + --color-command-input: hsl(210 36% 95%); + --color-command-input-border: hsl(210 24% 84%); + --color-command-foreground: hsl(210 18% 18%); + --color-command-subdued: hsl(210 14% 46%); + --color-sidebar-tab-active: hsl(210 18% 18%); + --color-assistant-border-hover: hsl(207 65% 40%); + --color-message-header: hsl(210 18% 20%); + + --color-token-prompt: hsl(210 18% 32%); + --color-token-completion: hsl(207 90% 40%); + --color-token-variable: hsl(207 90% 40%); + --color-token-fixed: hsl(210 18% 32%); + --color-token-input: hsl(125 45% 36%); + --color-token-output: hsl(207 90% 40%); + --color-token-cached: hsl(210 16% 50%); + + --surface-plan-gradient: linear-gradient(135deg, color-mix(in srgb, var(--color-plan-mode), transparent 94%) 0%, color-mix(in srgb, var(--color-plan-mode), transparent 97%) 100%); + --surface-plan-border: color-mix(in srgb, var(--color-plan-mode), transparent 78%); + --surface-plan-border-subtle: color-mix(in srgb, var(--color-plan-mode), transparent 85%); + --surface-plan-border-strong: color-mix(in srgb, var(--color-plan-mode), transparent 70%); + --surface-plan-divider: color-mix(in srgb, var(--color-plan-mode), transparent 86%); + --surface-plan-chip-bg: color-mix(in srgb, var(--color-plan-mode), transparent 94%); + --surface-plan-chip-hover: color-mix(in srgb, var(--color-plan-mode), transparent 90%); + --surface-plan-chip-active: color-mix(in srgb, var(--color-plan-mode), transparent 72%); + --surface-plan-chip-border: color-mix(in srgb, var(--color-plan-mode), transparent 78%); + --surface-plan-chip-border-strong: color-mix(in srgb, var(--color-plan-mode), transparent 68%); + --surface-plan-neutral-border: hsl(210 16% 72% / 0.5); + + --surface-assistant-chip-bg: hsl(from var(--color-assistant-border) h s l / 0.15); + --surface-assistant-chip-hover: hsl(from var(--color-assistant-border) h s l / 0.45); + --surface-assistant-chip-border: hsl(from var(--color-assistant-border) h s l / 0.45); + --surface-assistant-chip-border-strong: hsl(from var(--color-assistant-border) h s l / 0.65); + + --border-warning-dashed: color-mix(in srgb, var(--color-warning), transparent 55%); + + --color-toggle-bg: hsl(210 34% 95%); + --color-toggle-active: hsl(210 48% 88%); + --color-toggle-hover: hsl(210 38% 92%); + --color-toggle-text: hsl(210 16% 32%); + --color-toggle-text-active: hsl(210 18% 18%); + --color-toggle-text-hover: hsl(210 20% 26%); + + --color-interrupted: hsl(38 92% 50%); + --color-review-accent: hsl(48 70% 52%); + --color-git-dirty: hsl(38 92% 50%); + --color-error: hsl(0 68% 46%); + --color-error-bg: hsl(0 82% 94%); + + --color-input-bg: hsl(0 0% 100%); + --color-input-text: hsl(210 18% 16%); + --color-input-border: hsl(207 75% 52%); + --color-input-border-focus: hsl(193 85% 56%); + + --color-scrollbar-track: hsl(210 38% 95%); + --color-scrollbar-thumb: hsl(210 18% 78%); + --color-scrollbar-thumb-hover: hsl(210 18% 70%); + + --color-muted: hsl(210 14% 52%); + --color-muted-light: hsl(210 20% 60%); + --color-muted-dark: hsl(210 12% 42%); + --color-placeholder: hsl(210 16% 52%); + --color-subtle: hsl(210 20% 64%); + --color-dim: hsl(210 16% 50%); + --color-light: hsl(210 22% 40%); + --color-lighter: hsl(210 24% 52%); + --color-bright: hsl(210 60% 48%); + --color-subdued: hsl(210 18% 60%); + --color-label: hsl(210 18% 46%); + --color-gray: hsl(210 14% 54%); + --color-medium: hsl(210 18% 58%); + + --color-border-light: hsl(210 24% 84%); + --color-border-medium: hsl(210 22% 78%); + --color-border-darker: hsl(210 20% 70%); + --color-border-subtle: hsl(210 18% 64%); + --color-border-gray: hsl(210 22% 76%); + + --color-dark: hsl(210 33% 98%); + --color-darker: hsl(210 30% 94%); + --color-hover: hsl(210 28% 92%); + --color-bg-medium: hsl(210 26% 88%); + --color-bg-light: hsl(210 24% 84%); + --color-bg-subtle: hsl(210 32% 92%); + + --color-separator: hsl(0 0% 91%); + --color-separator-light: hsl(0 0% 96%); + --color-modal-bg: hsl(210 35% 96%); + + --color-accent: hsl(207 90% 42%); + --color-accent-hover: hsl(207 90% 46%); + --color-accent-dark: hsl(207 90% 38%); + --color-accent-darker: hsl(204 92% 30%); + --color-accent-light: hsl(198 100% 70%); + + --color-success: hsl(122 39% 45%); + --color-success-light: hsl(123 46% 60%); + --color-on-success: hsl(0 0% 100%); + + --color-danger: hsl(4 78% 52%); + --color-danger-light: hsl(4 78% 70%); + --color-danger-soft: hsl(6 80% 76%); + --color-on-danger: hsl(0 0% 100%); + + --color-warning: hsl(45 100% 50%); + --color-warning-light: hsl(38 100% 70%); + + --color-code-type: hsl(200 84% 28%); + --color-code-keyword: hsl(210 88% 24%); + + --color-toast-success-bg: hsl(207 90% 46% / 0.18); + --color-toast-success-text: hsl(207 90% 34%); + --color-toast-error-bg: hsl(5 80% 55% / 0.18); + --color-toast-error-text: hsl(5 78% 46%); + --color-toast-error-border: hsl(5 78% 46%); + --color-toast-fatal-bg: hsl(0 72% 94%); + --color-toast-fatal-border: hsl(0 74% 82%); + + --color-danger-overlay: hsl(4 80% 52% / 0.12); + --color-warning-overlay: hsl(45 100% 50% / 0.12); + --color-gray-overlay: hsl(210 16% 30% / 0.08); + --color-white-overlay-light: hsl(210 14% 12% / 0.04); + --color-white-overlay: hsl(210 14% 12% / 0.08); + --color-selection: hsl(204 100% 45% / 0.3); + --color-vim-status: hsl(210 18% 32% / 0.6); + --color-code-keyword-overlay-light: hsl(210 88% 28% / 0.16); + --color-code-keyword-overlay: hsl(210 88% 28% / 0.32); + + --color-info-light: hsl(5 90% 70%); + --color-info-yellow: hsl(38 100% 60%); + + --color-review-bg-blue: hsl(212 57% 82%); + --color-review-bg-info: hsl(200 60% 80%); + --color-review-bg-warning: hsl(38 88% 82%); + --color-review-warning: hsl(34 86% 45%); + --color-review-warning-medium: hsl(34 86% 52%); + --color-review-warning-light: hsl(38 100% 88%); + + --color-error-bg-dark: hsl(0 65% 86%); + + --radius: 0.5rem; +} :root[data-theme="dark"] { color-scheme: dark; /* Theme override hook: redeclare tokens inside this block to swap palettes */ @@ -841,12 +1061,12 @@ span.search-highlight { } .line-number { - background: rgba(0, 0, 0, 0.2); + background: var(--color-line-number-bg); padding: 0 8px 0 6px; text-align: right; - color: rgba(255, 255, 255, 0.4); + color: var(--color-line-number-text); user-select: none; - border-right: 1px solid rgba(255, 255, 255, 0.1); + border-right: 1px solid var(--color-line-number-border); } .code-line { diff --git a/src/browser/utils/commandIds.ts b/src/browser/utils/commandIds.ts index e30ae8854..cce0e5ad2 100644 --- a/src/browser/utils/commandIds.ts +++ b/src/browser/utils/commandIds.ts @@ -50,6 +50,10 @@ export const CommandIds = { projectRemove: (projectPath: string) => `${COMMAND_ID_PREFIXES.PROJECT_REMOVE}${projectPath}` as const, + // Appearance commands + themeToggle: () => "appearance:theme:toggle" as const, + themeSet: (theme: "light" | "dark") => `appearance:theme:set:${theme}` as const, + // Help commands helpKeybinds: () => "help:keybinds" as const, } as const; diff --git a/src/browser/utils/commands/sources.test.ts b/src/browser/utils/commands/sources.test.ts index 8b6d613d7..c322ea63a 100644 --- a/src/browser/utils/commands/sources.test.ts +++ b/src/browser/utils/commands/sources.test.ts @@ -27,6 +27,7 @@ const mk = (over: Partial[0]> = {}) => { }); const params: Parameters[0] = { projects, + theme: "dark", workspaceMetadata, selectedWorkspace: { projectPath: "/repo/a", @@ -46,6 +47,8 @@ const mk = (over: Partial[0]> = {}) => { onToggleSidebar: () => undefined, onNavigateWorkspace: () => undefined, onOpenWorkspaceInTerminal: () => undefined, + onToggleTheme: () => undefined, + onSetTheme: () => undefined, getBranchesForProject: () => Promise.resolve({ branches: ["main"], diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index 2c3aa9348..bf64d38b9 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -1,3 +1,4 @@ +import type { ThemeMode } from "@/browser/contexts/ThemeContext"; import type { CommandAction } from "@/browser/contexts/CommandRegistryContext"; import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import type { ThinkingLevel } from "@/common/types/thinking"; @@ -12,6 +13,7 @@ export interface BuildSourcesParams { projects: Map; /** Map of workspace ID to workspace metadata (keyed by metadata.id, not path) */ workspaceMetadata: Map; + theme: ThemeMode; selectedWorkspace: { projectPath: string; projectName: string; @@ -41,6 +43,8 @@ export interface BuildSourcesParams { onToggleSidebar: () => void; onNavigateWorkspace: (dir: "next" | "prev") => void; onOpenWorkspaceInTerminal: (workspaceId: string) => void; + onToggleTheme: () => void; + onSetTheme: (theme: ThemeMode) => void; } const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"]; @@ -56,12 +60,14 @@ export const COMMAND_SECTIONS = { MODE: "Modes & Model", HELP: "Help", PROJECTS: "Projects", + APPEARANCE: "Appearance", } as const; const section = { workspaces: COMMAND_SECTIONS.WORKSPACES, navigation: COMMAND_SECTIONS.NAVIGATION, chat: COMMAND_SECTIONS.CHAT, + appearance: COMMAND_SECTIONS.APPEARANCE, mode: COMMAND_SECTIONS.MODE, help: COMMAND_SECTIONS.HELP, projects: COMMAND_SECTIONS.PROJECTS, @@ -305,6 +311,38 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi }, ]); + // Appearance + actions.push(() => { + const list: CommandAction[] = [ + { + id: CommandIds.themeToggle(), + title: `Switch to ${p.theme === "dark" ? "Light" : "Dark"} Theme`, + section: section.appearance, + run: () => p.onToggleTheme(), + }, + ]; + + if (p.theme !== "dark") { + list.push({ + id: CommandIds.themeSet("dark"), + title: "Use Dark Theme", + section: section.appearance, + run: () => p.onSetTheme("dark"), + }); + } + + if (p.theme !== "light") { + list.push({ + id: CommandIds.themeSet("light"), + title: "Use Light Theme", + section: section.appearance, + run: () => p.onSetTheme("light"), + }); + } + + return list; + }); + // Chat utilities actions.push(() => { const list: CommandAction[] = []; diff --git a/src/browser/utils/highlighting/highlightDiffChunk.ts b/src/browser/utils/highlighting/highlightDiffChunk.ts index ca41d361d..0444d4573 100644 --- a/src/browser/utils/highlighting/highlightDiffChunk.ts +++ b/src/browser/utils/highlighting/highlightDiffChunk.ts @@ -1,7 +1,8 @@ import { getShikiHighlighter, mapToShikiLang, - SHIKI_THEME, + SHIKI_DARK_THEME, + SHIKI_LIGHT_THEME, MAX_DIFF_SIZE_BYTES, } from "./shikiHighlighter"; import type { DiffChunk } from "./diffChunking"; @@ -25,6 +26,8 @@ export interface HighlightedLine { originalIndex: number; // Index in original diff } +type ThemeMode = "light" | "dark"; + export interface HighlightedChunk { type: DiffChunk["type"]; lines: HighlightedLine[]; @@ -37,7 +40,8 @@ export interface HighlightedChunk { */ export async function highlightDiffChunk( chunk: DiffChunk, - language: string + language: string, + themeMode: ThemeMode = "dark" ): Promise { // Fast path: no highlighting for text files if (language === "text" || language === "plaintext") { @@ -81,9 +85,10 @@ export async function highlightDiffChunk( } } + const shikiTheme = themeMode === "light" ? SHIKI_LIGHT_THEME : SHIKI_DARK_THEME; const html = highlighter.codeToHtml(code, { lang: shikiLang, - theme: SHIKI_THEME, + theme: shikiTheme, }); // 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 a07cd252b..45110e64c 100644 --- a/src/browser/utils/highlighting/shiki-shared.ts +++ b/src/browser/utils/highlighting/shiki-shared.ts @@ -3,8 +3,9 @@ * Used by both the main app and documentation theme */ -// Shiki theme used throughout the application -export const SHIKI_THEME = "min-dark"; +// Shiki themes used throughout the application +export const SHIKI_DARK_THEME = "min-dark"; +export const SHIKI_LIGHT_THEME = "min-light"; /** * 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 b98a54e60..7d725dd20 100644 --- a/src/browser/utils/highlighting/shikiHighlighter.ts +++ b/src/browser/utils/highlighting/shikiHighlighter.ts @@ -1,7 +1,7 @@ import { createHighlighter, type Highlighter } from "shiki"; +import { SHIKI_DARK_THEME, SHIKI_LIGHT_THEME } from "./shiki-shared"; -// Shiki theme used throughout the application -export const SHIKI_THEME = "min-dark"; +export { SHIKI_DARK_THEME, SHIKI_LIGHT_THEME } from "./shiki-shared"; // Maximum diff size to highlight (in bytes) // Diffs larger than this will fall back to plain text for performance @@ -21,7 +21,7 @@ export async function getShikiHighlighter(): Promise { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing if (!highlighterPromise) { highlighterPromise = createHighlighter({ - themes: [SHIKI_THEME], + themes: [SHIKI_DARK_THEME, SHIKI_LIGHT_THEME], langs: [], // Load languages on-demand via highlightDiffChunk }); } diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index e6e9e485e..bfaa80a16 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -30,6 +30,12 @@ export function getPendingScopeId(projectPath: string): string { */ export const GLOBAL_SCOPE_ID = "__global__"; +/** + * Get the localStorage key for the UI theme preference (global) + * Format: "uiTheme" + */ +export const UI_THEME_KEY = "uiTheme"; + /** * Helper to create a thinking level storage key for a workspace * Format: "thinkingLevel:{workspaceId}"