diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 8d76ea83a33e..d29f922bb339 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -18,6 +18,7 @@ const clientSettings: ClientSettings = { confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, + diffTheme: "pierre-dark", environmentIdentificationMode: "artwork", favorites: [], glassOpacity: 80, diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index d86fe39a77f3..4429bfc70c40 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -54,8 +54,9 @@ import { ScrollArea } from "./ui/scroll-area"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { useOpenInPreferredEditor } from "../editorPreferences"; -import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; +import { DIFF_HIGHLIGHTER_THEME_NAMES, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; +import { useDiffThemeName } from "../hooks/useDiffThemeName"; import { LRUCache } from "../lib/lruCache"; import { useTheme } from "../hooks/useTheme"; import { getClientSettings } from "../hooks/useSettings"; @@ -338,7 +339,7 @@ function getHighlighterPromise(language: string): Promise { if (cached) return cached; const promise = getSharedHighlighter({ - themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")], + themes: [...DIFF_HIGHLIGHTER_THEME_NAMES], langs: [language as SupportedLanguages], preferredHighlighter: "shiki-js", }).catch((err) => { @@ -1317,7 +1318,7 @@ function ChatMarkdown({ environmentId, serverConfig?.availableEditors ?? [], ); - const diffThemeName = resolveDiffThemeName(resolvedTheme); + const diffThemeName = useDiffThemeName(); const markdownFileLinkMetaByHref = useMemo(() => { const metaByHref = new Map< string, diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 62f187862235..52d2f3ae8934 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -33,9 +33,9 @@ import { getDiffCollapseIconClassName, getDiffLineStat, getRenderablePatch, - resolveDiffThemeName, resolveFileDiffPath, } from "../lib/diffRendering"; +import { useDiffThemeName } from "../hooks/useDiffThemeName"; import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useProject, useThread } from "../state/entities"; @@ -197,6 +197,7 @@ export default function DiffPanel({ initialGitScope: initialGitScopeProp, }: DiffPanelProps) { const { resolvedTheme } = useTheme(); + const diffThemeName = useDiffThemeName(); const settings = useClientSettings(); const [initialGitScope] = useState(initialGitScopeProp); const [diffRenderMode, setDiffRenderMode] = useState("stacked"); @@ -970,7 +971,7 @@ export default function DiffPanel({ diffStyle: diffRenderMode === "split" ? "split" : "unified", lineDiffType: "none", overflow: wordWrap ? "wrap" : "scroll", - theme: resolveDiffThemeName(resolvedTheme), + theme: diffThemeName, themeType: resolvedTheme as DiffThemeType, unsafeCSS: DIFF_PANEL_UNSAFE_CSS, stickyHeaders: true, diff --git a/apps/web/src/components/DiffWorkerPoolProvider.tsx b/apps/web/src/components/DiffWorkerPoolProvider.tsx index 3ec748c6bcb2..3c368365ebf0 100644 --- a/apps/web/src/components/DiffWorkerPoolProvider.tsx +++ b/apps/web/src/components/DiffWorkerPoolProvider.tsx @@ -2,12 +2,12 @@ import { WorkerPoolContextProvider, useWorkerPool } from "@pierre/diffs/react"; import DiffsWorker from "@pierre/diffs/worker/worker.js?worker"; import * as Schema from "effect/Schema"; import { useEffect, useMemo, type ReactNode } from "react"; -import { useTheme } from "../hooks/useTheme"; -import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; +import { useDiffThemeName } from "../hooks/useDiffThemeName"; +import { type DiffThemeName } from "../lib/diffRendering"; export class DiffWorkerError extends Schema.TaggedErrorClass()("DiffWorkerError", { operation: Schema.Literals(["create-worker", "get-render-options", "set-render-options"]), - themeName: Schema.Literals(["pierre-light", "pierre-dark"]), + themeName: Schema.String, cause: Schema.Defect(), }) { override get message(): string { @@ -46,8 +46,7 @@ function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) { } export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { - const { resolvedTheme } = useTheme(); - const diffThemeName = resolveDiffThemeName(resolvedTheme); + const diffThemeName = useDiffThemeName(); const workerPoolSize = useMemo(() => { const cores = typeof navigator === "undefined" ? 4 : Math.max(1, navigator.hardwareConcurrency || 4); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a429b54deaf1..6931403edc4d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -32,11 +32,8 @@ import { workLogEntryIsToolLike, } from "../../session-logic"; import { type TurnDiffSummary } from "../../types"; -import { - getRenderablePatch, - resolveDiffThemeName, - resolveFileDiffPath, -} from "../../lib/diffRendering"; +import { getRenderablePatch, resolveFileDiffPath } from "../../lib/diffRendering"; +import { useDiffThemeName } from "../../hooks/useDiffThemeName"; import ChatMarkdown from "../ChatMarkdown"; import { BotIcon, @@ -1647,6 +1644,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentContext }) { const ctx = use(TimelineRowCtx); + const diffThemeName = useDiffThemeName(); const fenceLanguage = comment.fenceLanguage ?? "diff"; const renderablePatch = getRenderablePatch( buildReviewCommentRenderablePatch(comment), @@ -1685,7 +1683,7 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte options={{ collapsed: false, diffStyle: "unified", - theme: resolveDiffThemeName(ctx.resolvedTheme), + theme: diffThemeName, }} /> ))} diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 6ddd38e9d253..ccd629f4d3c1 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -23,7 +23,7 @@ import { OpenInPicker } from "~/components/chat/OpenInPicker"; import { useClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage"; -import { resolveDiffThemeName } from "~/lib/diffRendering"; +import { useDiffThemeName } from "~/hooks/useDiffThemeName"; import { cn } from "~/lib/utils"; import { isPreviewSupportedInRuntime } from "~/previewStateStore"; import { resolvePathLinkTarget } from "~/terminal-links"; @@ -341,6 +341,7 @@ function EditableFileSurface({ onPostRender, onPendingChange, }: EditableFileSurfaceProps) { + const diffThemeName = useDiffThemeName(); const addReviewComment = useComposerDraftStore((store) => store.addReviewComment); const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment); const [lineAnnotations, setLineAnnotations] = useState([]); @@ -559,7 +560,7 @@ function EditableFileSurface({ onLineSelectionChange: setSelectedRange, onLineSelectionEnd: handleLineSelectionEnd, overflow: wordWrap ? "wrap" : "scroll", - theme: resolveDiffThemeName(resolvedTheme), + theme: diffThemeName, themeType: resolvedTheme, unsafeCSS: FILE_LINK_REVEAL_UNSAFE_CSS, onPostRender: handlePostRender, @@ -660,6 +661,7 @@ export default function FilePreviewPanel({ onPendingChange, }: FilePreviewPanelProps) { const { resolvedTheme } = useTheme(); + const diffThemeName = useDiffThemeName(); const wordWrap = useClientSettings((settings) => settings.wordWrap); const primaryEnvironmentId = usePrimaryEnvironmentId(); const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(environmentId); @@ -902,7 +904,7 @@ export default function FilePreviewPanel({ options={{ disableFileHeader: true, overflow: wordWrap ? "wrap" : "scroll", - theme: resolveDiffThemeName(resolvedTheme), + theme: diffThemeName, themeType: resolvedTheme, unsafeCSS: FILE_LINK_REVEAL_UNSAFE_CSS, onPostRender: onFilePostRender, diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index d656d004ff42..2b1d0f88216a 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -31,6 +31,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { + DEFAULT_DIFF_THEME, DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE, DEFAULT_UNIFIED_SETTINGS, type EnvironmentIdentificationMode, @@ -63,6 +64,7 @@ import { } from "../SidebarStageBackdrop"; import { isElectron } from "../../env"; import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing"; +import { DIFF_THEME_OPTIONS } from "../../lib/diffRendering"; import { useTheme } from "../../hooks/useTheme"; import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; import { useThreadActions } from "../../hooks/useThreadActions"; @@ -588,6 +590,7 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace ? ["Diff whitespace changes"] : []), + ...(settings.diffTheme !== DEFAULT_UNIFIED_SETTINGS.diffTheme ? ["Diff theme"] : []), ...(settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar ? ["Auto-open task panel"] : []), @@ -627,6 +630,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.defaultThreadEnvMode, settings.newWorktreesStartFromOrigin, settings.diffIgnoreWhitespace, + settings.diffTheme, settings.environmentIdentificationMode, settings.glassOpacity, settings.enableAssistantStreaming, @@ -654,6 +658,7 @@ export function useSettingsRestore(onRestored?: () => void) { timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, + diffTheme: DEFAULT_UNIFIED_SETTINGS.diffTheme, environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, @@ -997,6 +1002,44 @@ export function AppearanceSettingsPanel() { } /> + updateSettings({ diffTheme: DEFAULT_DIFF_THEME })} + /> + ) : null + } + control={ + + } + /> + settings.diffTheme); + return resolveDiffThemeName(resolvedTheme, diffTheme); +} diff --git a/apps/web/src/lib/diffRendering.ts b/apps/web/src/lib/diffRendering.ts index 493474d8aa25..be43f2c34b5f 100644 --- a/apps/web/src/lib/diffRendering.ts +++ b/apps/web/src/lib/diffRendering.ts @@ -1,15 +1,53 @@ import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; import type { FileDiffMetadata } from "@pierre/diffs/types"; +import { DEFAULT_DIFF_THEME, type DiffTheme } from "@t3tools/contracts/settings"; -export const DIFF_THEME_NAMES = { - light: "pierre-light", - dark: "pierre-dark", -} as const; +/** + * Light mode always uses Pierre's light theme; the user-selectable diff theme + * (`DiffTheme`) applies in dark mode. + */ +export const DIFF_LIGHT_THEME_NAME = "pierre-light"; -export type DiffThemeName = (typeof DIFF_THEME_NAMES)[keyof typeof DIFF_THEME_NAMES]; +export type DiffThemeName = DiffTheme | typeof DIFF_LIGHT_THEME_NAME; -export function resolveDiffThemeName(theme: "light" | "dark"): DiffThemeName { - return theme === "dark" ? DIFF_THEME_NAMES.dark : DIFF_THEME_NAMES.light; +export interface DiffThemeOption { + readonly value: DiffTheme; + readonly label: string; +} + +/** Curated dark diff themes shown in Settings → Appearance. */ +export const DIFF_THEME_OPTIONS: readonly DiffThemeOption[] = [ + { value: "pierre-dark", label: "Pierre Dark" }, + { value: "tokyo-night", label: "Tokyo Night" }, + { value: "one-dark-pro", label: "One Dark Pro" }, + { value: "ayu-dark", label: "Ayu Dark" }, + { value: "dracula", label: "Dracula" }, + { value: "catppuccin-mocha", label: "Catppuccin Mocha" }, + { value: "github-dark-default", label: "GitHub Dark" }, + { value: "github-dark-dimmed", label: "GitHub Dark Dimmed" }, + { value: "material-theme-palenight", label: "Material Palenight" }, + { value: "night-owl", label: "Night Owl" }, + { value: "nord", label: "Nord" }, + { value: "monokai", label: "Monokai" }, + { value: "poimandres", label: "Poimandres" }, + { value: "vesper", label: "Vesper" }, + { value: "synthwave-84", label: "SynthWave '84" }, +]; + +/** + * Every theme name the highlighter may need registered up front, so switching + * the setting at runtime never references a theme that was never attached. + */ +export const DIFF_HIGHLIGHTER_THEME_NAMES: readonly DiffThemeName[] = [ + ...DIFF_THEME_OPTIONS.map((option) => option.value), + DIFF_LIGHT_THEME_NAME, +]; + +export function resolveDiffThemeName( + resolvedTheme: "light" | "dark", + darkTheme: DiffTheme = DEFAULT_DIFF_THEME, +): DiffThemeName { + return resolvedTheme === "dark" ? darkTheme : DIFF_LIGHT_THEME_NAME; } const FNV_OFFSET_BASIS_32 = 0x811c9dc5; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7edda2e52e5c..74fa5a8bb849 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -62,6 +62,29 @@ export const EnvironmentIdentificationMode = Schema.Literals(["artwork", "pill", export type EnvironmentIdentificationMode = typeof EnvironmentIdentificationMode.Type; export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationMode = "artwork"; +// Syntax highlighting theme for code diffs. These names map to themes bundled +// by `@pierre/diffs` (Pierre's own themes plus the Shiki theme collection). +// The selected theme applies in dark mode; light mode always uses pierre-light. +export const DiffTheme = Schema.Literals([ + "pierre-dark", + "tokyo-night", + "one-dark-pro", + "ayu-dark", + "dracula", + "catppuccin-mocha", + "github-dark-default", + "github-dark-dimmed", + "material-theme-palenight", + "night-owl", + "nord", + "monokai", + "poimandres", + "vesper", + "synthwave-84", +]); +export type DiffTheme = typeof DiffTheme.Type; +export const DEFAULT_DIFF_THEME: DiffTheme = "pierre-dark"; + export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -70,6 +93,7 @@ export const ClientSettingsSchema = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed([])), ), diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + diffTheme: DiffTheme.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_DIFF_THEME))), environmentIdentificationMode: EnvironmentIdentificationMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE)), ), @@ -678,6 +702,7 @@ export const ClientSettingsPatch = Schema.Struct({ confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), + diffTheme: Schema.optionalKey(DiffTheme), environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode), glassOpacity: Schema.optionalKey(GlassOpacity), favorites: Schema.optionalKey(