From 0dc0cc76be4e3cccc5b68cbf0c6814ceedf2ca36 Mon Sep 17 00:00:00 2001 From: Ben Davis Date: Wed, 6 May 2026 01:09:14 -0700 Subject: [PATCH 1/7] Add VS Code theme integration plan - Document discovery, mapping, and persistence strategy - Outline desktop bridge, settings UI, and rollout phases --- THEME_PLAN.md | 1279 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1279 insertions(+) create mode 100644 THEME_PLAN.md diff --git a/THEME_PLAN.md b/THEME_PLAN.md new file mode 100644 index 00000000000..0b4fefc57e0 --- /dev/null +++ b/THEME_PLAN.md @@ -0,0 +1,1279 @@ +# T3 Code VS Code Compatible Themes Plan + +## Goal + +Add custom themes to T3 Code that are compatible with VS Code and Cursor color +themes. + +The target user experience: + +1. T3 Code auto-detects VS Code and Cursor themes installed on the local machine. +2. The user selects one from Settings. +3. T3 Code applies the selected theme across the whole app: shell, sidebar, + chat, settings, dialogs, terminal, code blocks, and diffs. +4. T3 Code can optionally detect and follow the currently configured VS Code or + Cursor theme. +5. Built-in `system`, `light`, and `dark` remain available and stable. + +This document is a read-only planning artifact. It intentionally does not +implement the feature yet. + +## Current Repo Context + +Relevant existing surfaces: + +- `apps/web/src/index.css` + - Defines the current Tailwind v4 CSS variable bridge via `@theme inline`. + - Current semantic app tokens are: + - `--background` + - `--app-chrome-background` + - `--foreground` + - `--card` + - `--card-foreground` + - `--popover` + - `--popover-foreground` + - `--primary` + - `--primary-foreground` + - `--secondary` + - `--secondary-foreground` + - `--muted` + - `--muted-foreground` + - `--accent` + - `--accent-foreground` + - `--destructive` + - `--destructive-foreground` + - `--border` + - `--input` + - `--ring` + - `--info` + - `--info-foreground` + - `--success` + - `--success-foreground` + - `--warning` + - `--warning-foreground` + +- `apps/web/src/hooks/useTheme.ts` + - Current type is only `Theme = "light" | "dark" | "system"`. + - Persists to `localStorage` key `t3code:theme`. + - Toggles `.dark` on `document.documentElement`. + - Calls `window.desktopBridge.setTheme(theme)` to sync Electron native theme. + +- `apps/web/src/components/settings/SettingsPanels.tsx` + - `THEME_OPTIONS` contains only `system`, `light`, `dark`. + - General settings row renders a single theme select. + +- `packages/contracts/src/ipc.ts` + - `DesktopTheme = "light" | "dark" | "system"`. + - `DesktopBridge` exposes `setTheme(theme)`, but no theme discovery/load API. + +- `apps/desktop/src/main.ts` + - Receives `desktop:set-theme`, validates with `getSafeTheme`, and sets + `nativeTheme.themeSource`. + - Window background and titlebar overlay still derive from + `nativeTheme.shouldUseDarkColors`, not from app theme colors. + +- `apps/desktop/src/preload.ts` + - Exposes the desktop bridge to the web app. + +- `packages/contracts/src/settings.ts` + - Client settings are persisted locally. + - Theme is not currently part of `ClientSettings`; it is separate localStorage + state in `useTheme.ts`. + +- `apps/web/src/components/ThreadTerminalDrawer.tsx` + - `terminalThemeFromApp` samples app background/foreground, then hardcodes + ANSI palettes for light/dark. + +- `apps/web/src/components/ChatMarkdown.tsx` + - Code blocks use `@pierre/diffs` shared highlighter. + - Theme selection is hardcoded through `resolveDiffThemeName("light"|"dark")`. + +- `apps/web/src/lib/diffRendering.ts` + - `DIFF_THEME_NAMES = { light: "pierre-light", dark: "pierre-dark" }`. + +- `apps/web/src/components/DiffWorkerPoolProvider.tsx` + - Worker highlighter options use only `pierre-light` / `pierre-dark`. + +## VS Code Theme Shape + +Official references: + +- Theme contribution point: + https://code.visualstudio.com/api/references/contribution-points#contributes.themes +- Color theme guide: + https://code.visualstudio.com/api/extension-guides/color-theme +- Workbench color IDs: + https://code.visualstudio.com/api/references/theme-color +- Semantic token colors: + https://code.visualstudio.com/api/language-extensions/semantic-highlight-guide + +VS Code themes are usually discovered through extension `package.json` files: + +```json +{ + "contributes": { + "themes": [ + { + "label": "GitHub Dark Default", + "uiTheme": "vs-dark", + "path": "./themes/dark-default.json" + } + ] + } +} +``` + +Theme JSON files commonly look like: + +```json +{ + "name": "GitHub Dark Default", + "type": "dark", + "colors": { + "editor.background": "#0d1117", + "editor.foreground": "#e6edf3", + "sideBar.background": "#010409", + "button.background": "#238636", + "terminal.ansiGreen": "#3fb950" + }, + "semanticHighlighting": true, + "tokenColors": [ + { + "scope": ["comment", "punctuation.definition.comment"], + "settings": { + "foreground": "#8b949e", + "fontStyle": "italic" + } + } + ], + "semanticTokenColors": { + "variable.readonly": "#79c0ff" + } +} +``` + +Important details: + +- `contributes.themes[].uiTheme` uses: + - `vs`: light + - `vs-dark`: dark + - `hc-light`: high contrast light + - `hc-black`: high contrast dark +- `colors` is the workbench/UI layer. +- `tokenColors` is TextMate syntax highlighting. +- `semanticHighlighting` and `semanticTokenColors` are semantic syntax rules. +- `tokenColors` can be an array or a path to a `.tmTheme` file. +- User settings files are JSONC, not strict JSON, so use a real JSONC parser. +- User overrides can include: + - `workbench.colorTheme` + - `workbench.preferredLightColorTheme` + - `workbench.preferredDarkColorTheme` + - `window.autoDetectColorScheme` + - `workbench.colorCustomizations` + - `editor.tokenColorCustomizations` + +Local discovery during research found GitHub Theme installed in both VS Code and +Cursor on this machine. Both apps currently point at `GitHub Dark Default`. + +## Design Decision + +Keep the T3 Code app contract semantic. + +Do not make every component directly consume hundreds of VS Code color IDs. +Instead: + +1. Discover and parse VS Code themes. +2. Resolve a selected VS Code theme into a normalized `ResolvedAppTheme`. +3. Apply the resolved app theme as CSS variables. +4. Keep existing Tailwind classes like `bg-background`, `text-foreground`, and + `border-border` working. +5. Add focused app tokens only where T3 has a first-class surface: + sidebar, terminal, diff, code blocks, chat, and browser/desktop chrome. + +This keeps T3 Code themable without binding every UI component to the VS Code +workbench taxonomy forever. + +## Proposed Data Model + +Add a new contracts file, likely `packages/contracts/src/theme.ts`, exported +from `packages/contracts/src/index.ts`. + +```ts +import * as Schema from "effect/Schema"; + +export const ThemeSource = Schema.Literals(["builtin", "vscode", "cursor", "vscode-insiders"]); +export type ThemeSource = typeof ThemeSource.Type; + +export const ThemeKind = Schema.Literals([ + "light", + "dark", + "high-contrast-light", + "high-contrast-dark", +]); +export type ThemeKind = typeof ThemeKind.Type; + +export const ThemeId = Schema.TemplateLiteral(Schema.String); +export type ThemeId = typeof ThemeId.Type; + +export const DiscoveredColorTheme = Schema.Struct({ + id: ThemeId, + source: ThemeSource, + extensionId: Schema.String, + extensionDisplayName: Schema.optional(Schema.String), + label: Schema.String, + kind: ThemeKind, + themePath: Schema.String, + packagePath: Schema.String, + publisher: Schema.optional(Schema.String), +}); +export type DiscoveredColorTheme = typeof DiscoveredColorTheme.Type; + +export const ResolvedColorTheme = Schema.Struct({ + id: ThemeId, + label: Schema.String, + source: ThemeSource, + kind: ThemeKind, + colors: Schema.Record(Schema.String, Schema.String), + tokenColors: Schema.Unknown, + semanticHighlighting: Schema.optional(Schema.Boolean), + semanticTokenColors: Schema.optional(Schema.Unknown), + appVariables: Schema.Record(Schema.String, Schema.String), +}); +export type ResolvedColorTheme = typeof ResolvedColorTheme.Type; + +export const ThemePreference = Schema.Union( + Schema.Struct({ mode: Schema.Literal("system") }), + Schema.Struct({ mode: Schema.Literal("builtin"), theme: Schema.Literals(["light", "dark"]) }), + Schema.Struct({ mode: Schema.Literal("external"), themeId: ThemeId }), + Schema.Struct({ + mode: Schema.Literal("follow-editor"), + source: Schema.Literals(["vscode", "cursor", "vscode-insiders"]), + }), +); +export type ThemePreference = typeof ThemePreference.Type; +``` + +Use simpler hand-written types if the Schema type ergonomics are not worth it, +but keep the bridge payloads runtime-validated at the process boundary. + +## Persistence Decision + +Move theme preference into `ClientSettings` eventually, but preserve the existing +`t3code:theme` localStorage key as a migration input. + +Why: + +- Client settings already support Electron-backed persistence via + `apps/desktop/src/clientPersistence.ts`. +- Browser fallback already persists client settings via + `apps/web/src/clientPersistenceStorage.ts`. +- Theme is a client preference, not server-authoritative state. +- A migration can read old `t3code:theme` and write the equivalent + `ThemePreference`. + +Suggested schema addition: + +```ts +export const DEFAULT_THEME_PREFERENCE = { + mode: "system", +} as const satisfies ThemePreference; + +export const ClientSettingsSchema = Schema.Struct({ + // existing settings... + themePreference: ThemePreference.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_THEME_PREFERENCE)), + ), +}); +``` + +## Desktop Bridge API + +Extend `DesktopBridge` in `packages/contracts/src/ipc.ts`: + +```ts +export interface DesktopBridge { + // existing methods... + discoverColorThemes: () => Promise; + loadColorTheme: (themeId: ThemeId) => Promise; + getEditorThemePreferences: () => Promise; + setTheme: (theme: DesktopTheme) => Promise; + setWindowThemeColors: (input: { + backgroundColor: string; + titleBarColor?: string; + titleBarSymbolColor?: string; + }) => Promise; +} +``` + +`setTheme` should remain for Electron native light/dark behavior. The new +`setWindowThemeColors` lets the renderer pass resolved actual colors for +window background/titlebar overlay. + +`preload.ts` adds matching IPC channels: + +```ts +const DISCOVER_COLOR_THEMES_CHANNEL = "desktop:discover-color-themes"; +const LOAD_COLOR_THEME_CHANNEL = "desktop:load-color-theme"; +const GET_EDITOR_THEME_PREFERENCES_CHANNEL = "desktop:get-editor-theme-preferences"; +const SET_WINDOW_THEME_COLORS_CHANNEL = "desktop:set-window-theme-colors"; + +contextBridge.exposeInMainWorld("desktopBridge", { + // existing bridge... + discoverColorThemes: () => ipcRenderer.invoke(DISCOVER_COLOR_THEMES_CHANNEL), + loadColorTheme: (themeId) => ipcRenderer.invoke(LOAD_COLOR_THEME_CHANNEL, themeId), + getEditorThemePreferences: () => ipcRenderer.invoke(GET_EDITOR_THEME_PREFERENCES_CHANNEL), + setWindowThemeColors: (input) => ipcRenderer.invoke(SET_WINDOW_THEME_COLORS_CHANNEL, input), +}); +``` + +## Dependency + +Use a real JSONC parser for VS Code/Cursor settings files. + +Add by command, not manual `package.json` edits: + +```sh +bun --cwd apps/desktop add jsonc-parser +``` + +If the package is needed in shared code instead of desktop-only code, add it to +the owning workspace with the same command style. + +## Theme Discovery + +Create `apps/desktop/src/vscodeThemeDiscovery.ts`. + +Responsibilities: + +- Know editor roots by platform: + - macOS: + - `~/.vscode/extensions` + - `~/.vscode-insiders/extensions` + - `~/.cursor/extensions` + - `~/Library/Application Support/Code/User/settings.json` + - `~/Library/Application Support/Code - Insiders/User/settings.json` + - `~/Library/Application Support/Cursor/User/settings.json` + - Linux: + - `~/.vscode/extensions` + - `~/.vscode-insiders/extensions` + - `~/.cursor/extensions` + - `~/.config/Code/User/settings.json` + - `~/.config/Code - Insiders/User/settings.json` + - `~/.config/Cursor/User/settings.json` + - Windows: + - `%USERPROFILE%\\.vscode\\extensions` + - `%USERPROFILE%\\.vscode-insiders\\extensions` + - `%USERPROFILE%\\.cursor\\extensions` + - `%APPDATA%\\Code\\User\\settings.json` + - `%APPDATA%\\Code - Insiders\\User\\settings.json` + - `%APPDATA%\\Cursor\\User\\settings.json` +- Scan direct child directories for `package.json`. +- Parse `contributes.themes`. +- Resolve relative paths safely under the extension directory. +- Ignore missing or malformed themes. +- Dedupe duplicate themes from VS Code/Cursor by source plus extension plus label, + or keep both if source matters to the user. + +Core scanner sketch: + +```ts +import * as FS from "node:fs"; +import * as Path from "node:path"; +import * as OS from "node:os"; +import { parse as parseJsonc } from "jsonc-parser"; +import type { DiscoveredColorTheme, ThemeKind, ThemeSource } from "@t3tools/contracts"; + +function kindFromUiTheme(uiTheme: unknown): ThemeKind { + if (uiTheme === "hc-light") return "high-contrast-light"; + if (uiTheme === "hc-black") return "high-contrast-dark"; + if (uiTheme === "vs") return "light"; + return "dark"; +} + +function readJsonFile(filePath: string): unknown | null { + try { + return JSON.parse(FS.readFileSync(filePath, "utf8")); + } catch { + return null; + } +} + +function safeResolveChild(root: string, relativePath: string): string | null { + const resolved = Path.resolve(root, relativePath); + const normalizedRoot = Path.resolve(root); + return resolved.startsWith(`${normalizedRoot}${Path.sep}`) ? resolved : null; +} + +export function discoverEditorColorThemes(): readonly DiscoveredColorTheme[] { + const roots = resolveExtensionRoots({ + platform: process.platform, + homedir: OS.homedir(), + appData: process.env.APPDATA, + }); + + const themes: DiscoveredColorTheme[] = []; + + for (const root of roots) { + if (!FS.existsSync(root.extensionsPath)) continue; + + for (const entry of FS.readdirSync(root.extensionsPath, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + + const extensionDir = Path.join(root.extensionsPath, entry.name); + const packagePath = Path.join(extensionDir, "package.json"); + const packageJson = readJsonFile(packagePath); + if (!isObject(packageJson)) continue; + + const contributedThemes = packageJson.contributes?.themes; + if (!Array.isArray(contributedThemes)) continue; + + for (const theme of contributedThemes) { + if (!isObject(theme) || typeof theme.label !== "string" || typeof theme.path !== "string") { + continue; + } + + const themePath = safeResolveChild(extensionDir, theme.path); + if (!themePath || !FS.existsSync(themePath)) continue; + + themes.push({ + id: `${root.source}:${entry.name}:${theme.label}`, + source: root.source, + extensionId: entry.name, + extensionDisplayName: + typeof packageJson.displayName === "string" ? packageJson.displayName : undefined, + label: theme.label, + kind: kindFromUiTheme(theme.uiTheme), + themePath, + packagePath, + publisher: typeof packageJson.publisher === "string" ? packageJson.publisher : undefined, + }); + } + } + } + + return themes; +} +``` + +Avoid `as any`. Use small type guards: + +```ts +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +``` + +## Settings JSONC Parsing + +Read active VS Code/Cursor theme preferences: + +```ts +import { parse as parseJsonc } from "jsonc-parser"; + +export interface EditorThemePreference { + readonly source: "vscode" | "cursor" | "vscode-insiders"; + readonly colorTheme: string | null; + readonly preferredLightColorTheme: string | null; + readonly preferredDarkColorTheme: string | null; + readonly autoDetectColorScheme: boolean; + readonly workbenchColorCustomizations: Record; + readonly editorTokenColorCustomizations: unknown; +} + +function readEditorSettings(settingsPath: string): Record { + try { + const errors: unknown[] = []; + const parsed = parseJsonc(FS.readFileSync(settingsPath, "utf8"), errors, { + allowTrailingComma: true, + disallowComments: false, + }); + return isObject(parsed) ? parsed : {}; + } catch { + return {}; + } +} +``` + +Do not fail the whole discovery call if settings are malformed. + +## Theme File Loading + +Load a theme by `themeId`: + +```ts +export function loadEditorColorTheme(themeId: string): ResolvedColorTheme | null { + const theme = discoverEditorColorThemes().find((entry) => entry.id === themeId); + if (!theme) return null; + + const rawTheme = readJsonOrJsoncFile(theme.themePath); + if (!isObject(rawTheme)) return null; + + const colors = normalizeThemeColors(rawTheme.colors); + const appVariables = mapVscodeColorsToAppVariables({ + kind: theme.kind, + colors, + }); + + return { + id: theme.id, + label: theme.label, + source: theme.source, + kind: theme.kind, + colors, + tokenColors: rawTheme.tokenColors ?? [], + semanticHighlighting: + typeof rawTheme.semanticHighlighting === "boolean" + ? rawTheme.semanticHighlighting + : undefined, + semanticTokenColors: rawTheme.semanticTokenColors, + appVariables, + }; +} +``` + +Support JSON theme files first. Treat `.tmTheme` and remote URL `tokenColors` +as phase 2 unless common installed themes require them. + +## Color Normalization + +VS Code supports: + +- `#RGB` +- `#RGBA` +- `#RRGGBB` +- `#RRGGBBAA` + +Normalize all hex values to browser-safe CSS strings. Preserve alpha when +present. + +```ts +const HEX_COLOR_PATTERN = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i; + +function normalizeCssColor(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!HEX_COLOR_PATTERN.test(trimmed)) return null; + return trimmed; +} + +function normalizeThemeColors(value: unknown): Record { + if (!isObject(value)) return {}; + + const colors: Record = {}; + for (const [key, rawColor] of Object.entries(value)) { + const color = normalizeCssColor(rawColor); + if (color) { + colors[key] = color; + } + } + return colors; +} +``` + +If support for `rgb(...)`, `rgba(...)`, or named colors appears in real themes, +add it deliberately with tests. + +## Mapping VS Code Colors To T3 Tokens + +Create a pure shared mapper, probably `packages/shared/src/themeMapping.ts`, if +both desktop and web need it. Otherwise keep it in web if desktop only loads +raw theme data. + +Priority mapping: + +| T3 variable | VS Code priority | +| -------------------------- | --------------------------------------------------------------------------------------- | +| `--background` | `editor.background`, `sideBar.background`, default built-in | +| `--app-chrome-background` | `titleBar.activeBackground`, `sideBar.background`, `--background` | +| `--foreground` | `foreground`, `editor.foreground` | +| `--card` | `editorGroupHeader.tabsBackground`, `sideBarSectionHeader.background`, mixed background | +| `--card-foreground` | `foreground`, `editor.foreground` | +| `--popover` | `quickInput.background`, `editorWidget.background`, `dropdown.background`, `--card` | +| `--popover-foreground` | `quickInput.foreground`, `editorWidget.foreground`, `foreground` | +| `--primary` | `button.background`, `activityBarBadge.background`, `focusBorder` | +| `--primary-foreground` | `button.foreground`, contrast of primary | +| `--secondary` | `button.secondaryBackground`, `badge.background`, transparent mix | +| `--secondary-foreground` | `button.secondaryForeground`, `foreground` | +| `--muted` | `editor.lineHighlightBackground`, `list.hoverBackground`, transparent mix | +| `--muted-foreground` | `descriptionForeground`, `disabledForeground`, mixed foreground | +| `--accent` | `list.activeSelectionBackground`, `list.hoverBackground`, `toolbar.hoverBackground` | +| `--accent-foreground` | `list.activeSelectionForeground`, `list.hoverForeground`, `foreground` | +| `--destructive` | `errorForeground`, `editorError.foreground`, `notificationsErrorIcon.foreground` | +| `--destructive-foreground` | `errorForeground`, `editorError.foreground` | +| `--border` | `widget.border`, `sideBar.border`, `editorGroup.border`, `panel.border` | +| `--input` | `input.background`, `dropdown.background`, `settings.textInputBackground` | +| `--ring` | `focusBorder`, `inputOption.activeBorder`, `button.background` | +| `--info` | `textLink.foreground`, `terminal.ansiBlue` | +| `--info-foreground` | `textLink.foreground`, `terminal.ansiBrightBlue`, `--info` | +| `--success` | `gitDecoration.addedResourceForeground`, `terminal.ansiGreen` | +| `--success-foreground` | `gitDecoration.addedResourceForeground`, `terminal.ansiBrightGreen` | +| `--warning` | `notificationsWarningIcon.foreground`, `terminal.ansiYellow` | +| `--warning-foreground` | `notificationsWarningIcon.foreground`, `terminal.ansiBrightYellow` | + +Additional app tokens to add: + +| T3 variable | VS Code priority | +| --------------------------------- | ----------------------------------------------------------------------------- | +| `--sidebar` | `sideBar.background`, `activityBar.background`, `--background` | +| `--sidebar-foreground` | `sideBar.foreground`, `foreground` | +| `--sidebar-accent` | `list.activeSelectionBackground`, `activityBar.activeBackground`, `--accent` | +| `--sidebar-accent-foreground` | `list.activeSelectionForeground`, `sideBar.foreground` | +| `--sidebar-border` | `sideBar.border`, `activityBar.border`, `--border` | +| `--terminal-background` | `terminal.background`, `panel.background`, `--background` | +| `--terminal-foreground` | `terminal.foreground`, `foreground` | +| `--terminal-cursor` | `terminalCursor.foreground`, `editorCursor.foreground`, `--ring` | +| `--terminal-selection-background` | `terminal.selectionBackground`, `editor.selectionBackground` | +| `--diff-inserted` | `diffEditor.insertedTextBackground`, `gitDecoration.addedResourceForeground` | +| `--diff-removed` | `diffEditor.removedTextBackground`, `gitDecoration.deletedResourceForeground` | +| `--chat-request-background` | `chat.requestBackground`, `--card` | +| `--chat-request-border` | `chat.requestBorder`, `--border` | + +Mapper sketch: + +```ts +function firstColor(colors: Record, keys: readonly string[]): string | null { + for (const key of keys) { + const color = colors[key]; + if (color) return color; + } + return null; +} + +function mapVscodeColorsToAppVariables(input: { + readonly kind: ThemeKind; + readonly colors: Record; +}): Record { + const fallback = input.kind.includes("dark") ? DARK_FALLBACKS : LIGHT_FALLBACKS; + const colors = input.colors; + + const background = + firstColor(colors, ["editor.background", "sideBar.background"]) ?? fallback.background; + const foreground = firstColor(colors, ["foreground", "editor.foreground"]) ?? fallback.foreground; + const primary = + firstColor(colors, ["button.background", "activityBarBadge.background", "focusBorder"]) ?? + fallback.primary; + const border = + firstColor(colors, ["widget.border", "sideBar.border", "editorGroup.border", "panel.border"]) ?? + fallback.border; + + return { + "--background": background, + "--app-chrome-background": + firstColor(colors, ["titleBar.activeBackground", "sideBar.background"]) ?? background, + "--foreground": foreground, + "--card": + firstColor(colors, ["editorGroupHeader.tabsBackground", "sideBarSectionHeader.background"]) ?? + `color-mix(in srgb, ${background} 94%, ${foreground})`, + "--card-foreground": foreground, + "--popover": + firstColor(colors, [ + "quickInput.background", + "editorWidget.background", + "dropdown.background", + ]) ?? background, + "--popover-foreground": + firstColor(colors, [ + "quickInput.foreground", + "editorWidget.foreground", + "dropdown.foreground", + ]) ?? foreground, + "--primary": primary, + "--primary-foreground": + firstColor(colors, ["button.foreground"]) ?? readableForeground(primary), + "--border": border, + "--ring": firstColor(colors, ["focusBorder", "inputOption.activeBorder"]) ?? primary, + "--sidebar": firstColor(colors, ["sideBar.background", "activityBar.background"]) ?? background, + "--sidebar-foreground": firstColor(colors, ["sideBar.foreground"]) ?? foreground, + "--sidebar-border": firstColor(colors, ["sideBar.border", "activityBar.border"]) ?? border, + }; +} +``` + +Use CSS `color-mix(...)` where reasonable so the browser can evaluate colors +without bundling a large color library. For contrast-dependent values, a tiny +hex luminance helper is enough. + +## CSS Changes + +Add the sidebar/app-specific variables to `@theme inline`: + +```css +@theme inline { + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); +} +``` + +Add defaults under `:root` and `@variant dark`: + +```css +:root { + --sidebar: var(--background); + --sidebar-foreground: var(--foreground); + --sidebar-accent: var(--accent); + --sidebar-accent-foreground: var(--accent-foreground); + --sidebar-border: var(--border); + + --terminal-background: var(--background); + --terminal-foreground: var(--foreground); + --terminal-cursor: var(--ring); + --terminal-selection-background: color-mix(in srgb, var(--ring) 24%, transparent); +} +``` + +This also fixes the current shadcn-style sidebar classes that reference +`bg-sidebar` and `text-sidebar-foreground` without explicit tokens in +`index.css`. + +## Web Theme Hook Refactor + +Replace `Theme = "light" | "dark" | "system"` with `ThemePreference` plus a +resolved theme snapshot. + +Suggested structure: + +```ts +type ThemeSnapshot = { + preference: ThemePreference; + resolvedKind: "light" | "dark"; + resolvedTheme: ResolvedColorTheme | null; + discoveredThemes: readonly DiscoveredColorTheme[]; + status: "idle" | "loading" | "ready" | "error"; +}; +``` + +Apply CSS variables: + +```ts +function applyResolvedTheme(theme: ResolvedColorTheme | null, suppressTransitions = false) { + if (typeof document === "undefined") return; + + if (suppressTransitions) { + document.documentElement.classList.add("no-transitions"); + } + + const root = document.documentElement; + + if (theme) { + for (const [name, value] of Object.entries(theme.appVariables)) { + root.style.setProperty(name, value); + } + } else { + clearExternalThemeVariables(root); + } + + const isDark = theme + ? theme.kind === "dark" || theme.kind === "high-contrast-dark" + : getBuiltInDark(); + root.classList.toggle("dark", isDark); + root.dataset.themeSource = theme?.source ?? "builtin"; + root.dataset.themeId = theme?.id ?? ""; + + syncBrowserChromeTheme(); + syncDesktopTheme(isDark ? "dark" : "light"); + syncDesktopWindowColors(theme); + + if (suppressTransitions) { + root.offsetHeight; + requestAnimationFrame(() => root.classList.remove("no-transitions")); + } +} +``` + +Keep a minimal synchronous bootstrap cache: + +```ts +const BOOTSTRAP_THEME_CACHE_KEY = "t3code:resolved-theme-cache:v1"; + +// On successful external theme load, persist only: +// - theme id +// - kind +// - appVariables +// This prevents first paint flash without needing filesystem access before React. +``` + +## Settings UI + +Replace the single theme select in General settings with a richer Appearance +section. Keep it compact and workmanlike. + +Suggested layout: + +- `Theme` + - Select: + - `System` + - `Light` + - `Dark` + - separator/group: `VS Code` + - discovered VS Code themes + - separator/group: `Cursor` + - discovered Cursor themes +- `Follow editor theme` + - Select or segmented control: + - Off + - VS Code + - Cursor +- `Refresh themes` + - Small icon button. +- Status text: + - `Detected 18 themes from VS Code and Cursor.` + - `Using GitHub Dark Default from Cursor.` + - Missing theme fallback: + - `Selected theme is unavailable. Falling back to System.` + +Pseudo-component: + +```tsx +function ThemeSettingsRow() { + const { preference, setThemePreference, discoveredThemes, refreshThemes } = useTheme(); + + return ( + setThemePreference(selectValueToThemePreference(value))} + > + + {themePreferenceLabel(preference, discoveredThemes)} + + + + System + + + Light + + + Dark + + {discoveredThemes.map((theme) => ( + + {theme.label} + + ))} + + + } + /> + ); +} +``` + +If `Select` does not support headings/separators, either add that to the UI +component or split into source-filtered rows. Do not jam labels like +`VS Code - GitHub Dark` into the only affordance if grouping can be done cleanly. + +## Terminal Theme + +Replace hardcoded ANSI palettes with VS Code terminal colors when available. + +Mapping: + +```ts +const TERMINAL_COLOR_KEYS = { + background: "terminal.background", + foreground: "terminal.foreground", + cursor: "terminalCursor.foreground", + selectionBackground: "terminal.selectionBackground", + black: "terminal.ansiBlack", + red: "terminal.ansiRed", + green: "terminal.ansiGreen", + yellow: "terminal.ansiYellow", + blue: "terminal.ansiBlue", + magenta: "terminal.ansiMagenta", + cyan: "terminal.ansiCyan", + white: "terminal.ansiWhite", + brightBlack: "terminal.ansiBrightBlack", + brightRed: "terminal.ansiBrightRed", + brightGreen: "terminal.ansiBrightGreen", + brightYellow: "terminal.ansiBrightYellow", + brightBlue: "terminal.ansiBrightBlue", + brightMagenta: "terminal.ansiBrightMagenta", + brightCyan: "terminal.ansiBrightCyan", + brightWhite: "terminal.ansiBrightWhite", +} as const; +``` + +Sketch: + +```ts +function terminalThemeFromResolvedTheme( + theme: ResolvedColorTheme | null, + mountElement?: HTMLElement | null, +): ITheme { + const appTheme = terminalThemeFromApp(mountElement); + const colors = theme?.colors ?? {}; + + return { + ...appTheme, + background: + colors["terminal.background"] ?? + theme?.appVariables["--terminal-background"] ?? + appTheme.background, + foreground: + colors["terminal.foreground"] ?? + theme?.appVariables["--terminal-foreground"] ?? + appTheme.foreground, + cursor: + colors["terminalCursor.foreground"] ?? + theme?.appVariables["--terminal-cursor"] ?? + appTheme.cursor, + selectionBackground: + colors["terminal.selectionBackground"] ?? + colors["editor.selectionBackground"] ?? + theme?.appVariables["--terminal-selection-background"] ?? + appTheme.selectionBackground, + green: colors["terminal.ansiGreen"] ?? appTheme.green, + brightGreen: colors["terminal.ansiBrightGreen"] ?? appTheme.brightGreen, + }; +} +``` + +Expose the resolved theme through a tiny store or hook so +`ThreadTerminalDrawer.tsx` can update existing terminals when the external theme +changes. The existing `MutationObserver` already watches `class` and `style`, +but if app variables move through a store, include direct subscription too. + +## Code Block And Diff Syntax Themes + +Phase 1 should apply app UI and terminal themes. Syntax parity can be included +if `@pierre/diffs` exposes a way to register custom Shiki themes cleanly. + +Investigate package capability before implementation: + +- Does `getSharedHighlighter({ themes })` accept a Shiki theme object or only + built-in theme names? +- Does `WorkerPoolContextProvider` accept custom theme objects? +- Can `@pierre/diffs` workers receive a custom theme definition? + +If custom Shiki themes are supported, convert VS Code theme rules: + +```ts +function toShikiTheme(theme: ResolvedColorTheme) { + return { + name: theme.id, + type: theme.kind.includes("dark") ? "dark" : "light", + colors: { + "editor.background": theme.colors["editor.background"] ?? theme.appVariables["--background"], + "editor.foreground": theme.colors["editor.foreground"] ?? theme.appVariables["--foreground"], + }, + tokenColors: Array.isArray(theme.tokenColors) ? theme.tokenColors : [], + semanticHighlighting: theme.semanticHighlighting, + semanticTokenColors: theme.semanticTokenColors, + }; +} +``` + +Then replace: + +```ts +themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")]; +``` + +with something like: + +```ts +themes: getHighlighterThemesForResolvedAppTheme(resolvedTheme); +``` + +If the diff package does not support custom themes, keep `pierre-light` / +`pierre-dark` for syntax during phase 1 and document syntax parity as phase 2. +The app chrome should still use VS Code theme colors immediately. + +## Desktop Window Appearance + +Current desktop window background: + +```ts +function getInitialWindowBackgroundColor(): string { + return nativeTheme.shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; +} +``` + +Add renderer-driven window colors: + +```ts +let windowThemeColors: { + backgroundColor: string; + titleBarColor?: string; + titleBarSymbolColor?: string; +} | null = null; + +function getInitialWindowBackgroundColor(): string { + return ( + windowThemeColors?.backgroundColor ?? (nativeTheme.shouldUseDarkColors ? "#0a0a0a" : "#ffffff") + ); +} + +function getWindowTitleBarOptions(): WindowTitleBarOptions { + // keep macOS hiddenInset behavior + return { + titleBarStyle: "hidden", + titleBarOverlay: { + color: windowThemeColors?.titleBarColor ?? TITLEBAR_COLOR, + height: TITLEBAR_HEIGHT, + symbolColor: + windowThemeColors?.titleBarSymbolColor ?? + (nativeTheme.shouldUseDarkColors + ? TITLEBAR_DARK_SYMBOL_COLOR + : TITLEBAR_LIGHT_SYMBOL_COLOR), + }, + }; +} +``` + +IPC handler: + +```ts +ipcMain.handle(SET_WINDOW_THEME_COLORS_CHANNEL, async (_event, rawInput: unknown) => { + const input = decodeWindowThemeColors(rawInput); + if (!input) return; + windowThemeColors = input; + syncAllWindowAppearance(); +}); +``` + +## User Overrides + +Support overrides in this order: + +1. Theme file `colors` +2. Editor user `workbench.colorCustomizations` +3. Theme-scoped editor user overrides, if present and matching the selected theme +4. T3 fallback values + +Example override shape: + +```jsonc +{ + "workbench.colorCustomizations": { + "titleBar.activeBackground": "#000000", + "[GitHub Dark Default]": { + "activityBar.background": "#111111", + }, + }, +} +``` + +Merge helper: + +```ts +function mergeWorkbenchOverrides(input: { + themeLabel: string; + themeColors: Record; + customizations: Record; +}): Record { + const next = { ...input.themeColors }; + + for (const [key, rawValue] of Object.entries(input.customizations)) { + if (key.startsWith("[") && key.endsWith("]")) continue; + const color = normalizeCssColor(rawValue); + if (color) next[key] = color; + } + + const scoped = input.customizations[`[${input.themeLabel}]`]; + if (isObject(scoped)) { + for (const [key, rawValue] of Object.entries(scoped)) { + const color = normalizeCssColor(rawValue); + if (color) next[key] = color; + } + } + + return next; +} +``` + +Token color customizations can be added later after code/diff syntax theme +support is proven. + +## Hosted/Browser Mode + +In regular browser mode there is no local filesystem access, so: + +- Built-in `system`, `light`, and `dark` must continue to work. +- External theme controls should hide or show an unavailable state when + `window.desktopBridge` is missing. +- If a cached external theme exists, it can be applied as a best-effort visual + cache, but it should be clear that refresh/discovery requires desktop mode. + +## Edge Cases + +- Missing selected theme: + - Fall back to `system`. + - Keep the missing id in preference only if UX benefits from showing + "unavailable"; otherwise clear it. +- Duplicate theme labels: + - Use source + extension id + label as id. + - Show source/publisher metadata in UI. +- Malformed extension package: + - Ignore silently in discovery. + - Optionally include diagnostics in a dev-only log. +- Malformed theme file: + - Theme appears only if file parses enough to load. +- `.tmTheme` token colors: + - Defer unless a popular installed theme needs it. +- Remote `tokenColors` URL: + - Do not fetch automatically. +- Security: + - Never expose arbitrary filesystem reads to the renderer. + - Renderer can request only discovered theme ids. + - Desktop validates theme ids against fresh discovery before loading. + +## Suggested Implementation Phases + +### Phase 1: Discovery And Contracts + +Files: + +- `packages/contracts/src/theme.ts` +- `packages/contracts/src/ipc.ts` +- `packages/contracts/src/index.ts` +- `apps/desktop/src/vscodeThemeDiscovery.ts` +- `apps/desktop/src/vscodeThemeDiscovery.test.ts` +- `apps/desktop/src/preload.ts` +- `apps/desktop/src/main.ts` + +Deliverables: + +- Desktop can list VS Code/Cursor themes. +- Desktop can load a selected theme by id. +- Tests cover package scanning, path safety, JSONC settings parsing, and bad + package/theme files. + +### Phase 2: Mapper And CSS Variables + +Files: + +- `packages/shared/src/themeMapping.ts` or `apps/web/src/themeMapping.ts` +- `apps/web/src/index.css` +- mapper tests + +Deliverables: + +- VS Code colors map to T3 app variables. +- Built-in app tokens remain unchanged. +- Sidebar tokens are explicit. +- Contrast fallback helpers are tested. + +### Phase 3: Web Theme State And Settings UI + +Files: + +- `packages/contracts/src/settings.ts` +- `apps/web/src/hooks/useTheme.ts` +- `apps/web/src/hooks/useSettings.ts` +- `apps/web/src/clientPersistenceStorage.ts` +- `apps/desktop/src/clientPersistence.ts` +- `apps/web/src/components/settings/SettingsPanels.tsx` +- browser tests around settings + +Deliverables: + +- Theme preference is persisted in client settings. +- Old `t3code:theme` localStorage values migrate. +- Settings UI lists detected themes and built-ins. +- Selected external theme applies CSS variables. + +### Phase 4: Terminal And Desktop Chrome + +Files: + +- `apps/web/src/components/ThreadTerminalDrawer.tsx` +- `apps/desktop/src/main.ts` +- `apps/desktop/src/preload.ts` +- `packages/contracts/src/ipc.ts` + +Deliverables: + +- Xterm uses VS Code `terminal.*` colors. +- Desktop background/titlebar colors match the selected theme. +- Existing terminal theme tests are updated. + +### Phase 5: Code And Diff Syntax Theme Parity + +Files: + +- `apps/web/src/components/ChatMarkdown.tsx` +- `apps/web/src/lib/diffRendering.ts` +- `apps/web/src/components/DiffWorkerPoolProvider.tsx` +- possibly a new syntax theme adapter module + +Deliverables: + +- Chat code blocks use theme `tokenColors` if supported. +- Diff worker uses the same syntax theme if supported. +- If unsupported, this phase documents the blocking package limitation and keeps + UI/terminal parity shipped. + +### Phase 6: Polish And Recovery + +Deliverables: + +- Missing theme recovery. +- Refresh themes action. +- Current VS Code/Cursor theme badges. +- Hosted/browser unavailable state. +- Docs note in README or settings help text if needed. + +## Test Plan + +Required repo checks after implementation: + +```sh +bun fmt +bun lint +bun typecheck +``` + +Use `bun run test`, never `bun test`. + +Targeted tests to add/run: + +```sh +bun run test --filter @t3tools/desktop -- vscodeThemeDiscovery +bun run test --filter @t3tools/web -- useTheme +bun run test --filter @t3tools/web -- SettingsPanels +bun run test --filter @t3tools/web -- ThreadTerminalDrawer +``` + +If root `turbo` filtering does not match these names, use package-local Vitest +commands with explicit files: + +```sh +bun --cwd apps/desktop run test src/vscodeThemeDiscovery.test.ts +bun --cwd apps/web run test src/hooks/useTheme.test.ts +bun --cwd apps/web run test src/components/settings/SettingsPanels.browser.tsx +bun --cwd apps/web run test src/components/ThreadTerminalDrawer.browser.tsx +``` + +Manual verification: + +1. Open desktop T3 Code. +2. Go to Settings -> General or Appearance. +3. Verify VS Code and Cursor themes appear. +4. Select `GitHub Dark Default`. +5. Confirm app shell, sidebar, settings cards, popovers, chat, terminal, and + diff panel all update. +6. Switch to a light theme and confirm `.dark` compatibility class changes. +7. Quit/reopen and verify no first-paint flash beyond the cached fallback. +8. Rename or remove a selected extension directory and verify graceful fallback. + +## First Implementation Slice + +The smallest useful slice: + +1. Add discovery/loading bridge in Electron. +2. Add mapper from VS Code `colors` to T3 variables. +3. Extend settings UI to list detected themes. +4. Apply selected external theme via CSS variables. +5. Leave syntax token parity for phase 2 if `@pierre/diffs` does not trivially + accept custom Shiki themes. + +That slice gives users the main "my T3 Code uses my VS Code theme" experience +without overfitting the first pass to every possible token color edge case. From 169e16803d15160eeb76893fa89f694b3aba5f35 Mon Sep 17 00:00:00 2001 From: Ben Davis Date: Wed, 6 May 2026 02:01:32 -0700 Subject: [PATCH 2/7] Add VS Code theme discovery and app theming - Discover editor themes from desktop installs - Map theme colors into shared app and syntax tokens - Wire theme state through desktop, web, and settings --- THEME_PLAN.md | 1279 ----------------- apps/desktop/package.json | 3 +- apps/desktop/src/clientPersistence.test.ts | 1 + apps/desktop/src/main.ts | 59 +- apps/desktop/src/preload.ts | 8 + apps/desktop/src/vscodeThemeDiscovery.test.ts | 104 ++ apps/desktop/src/vscodeThemeDiscovery.ts | 284 ++++ apps/web/src/components/ChatMarkdown.tsx | 34 +- apps/web/src/components/DiffPanel.tsx | 16 +- .../src/components/DiffWorkerPoolProvider.tsx | 12 +- .../src/components/ThreadTerminalDrawer.tsx | 33 +- .../settings/SettingsPanels.browser.tsx | 4 + .../components/settings/SettingsPanels.tsx | 107 +- apps/web/src/hooks/useTheme.ts | 352 ++++- apps/web/src/index.css | 31 + apps/web/src/lib/syntaxTheme.test.ts | 77 + apps/web/src/lib/syntaxTheme.ts | 159 ++ apps/web/src/localApi.test.ts | 6 + bun.lock | 15 +- packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 11 + packages/contracts/src/settings.ts | 4 + packages/contracts/src/theme.ts | 71 + packages/shared/package.json | 4 + packages/shared/src/themeMapping.test.ts | 38 + packages/shared/src/themeMapping.ts | 246 ++++ 26 files changed, 1545 insertions(+), 1414 deletions(-) delete mode 100644 THEME_PLAN.md create mode 100644 apps/desktop/src/vscodeThemeDiscovery.test.ts create mode 100644 apps/desktop/src/vscodeThemeDiscovery.ts create mode 100644 apps/web/src/lib/syntaxTheme.test.ts create mode 100644 apps/web/src/lib/syntaxTheme.ts create mode 100644 packages/contracts/src/theme.ts create mode 100644 packages/shared/src/themeMapping.test.ts create mode 100644 packages/shared/src/themeMapping.ts diff --git a/THEME_PLAN.md b/THEME_PLAN.md deleted file mode 100644 index 0b4fefc57e0..00000000000 --- a/THEME_PLAN.md +++ /dev/null @@ -1,1279 +0,0 @@ -# T3 Code VS Code Compatible Themes Plan - -## Goal - -Add custom themes to T3 Code that are compatible with VS Code and Cursor color -themes. - -The target user experience: - -1. T3 Code auto-detects VS Code and Cursor themes installed on the local machine. -2. The user selects one from Settings. -3. T3 Code applies the selected theme across the whole app: shell, sidebar, - chat, settings, dialogs, terminal, code blocks, and diffs. -4. T3 Code can optionally detect and follow the currently configured VS Code or - Cursor theme. -5. Built-in `system`, `light`, and `dark` remain available and stable. - -This document is a read-only planning artifact. It intentionally does not -implement the feature yet. - -## Current Repo Context - -Relevant existing surfaces: - -- `apps/web/src/index.css` - - Defines the current Tailwind v4 CSS variable bridge via `@theme inline`. - - Current semantic app tokens are: - - `--background` - - `--app-chrome-background` - - `--foreground` - - `--card` - - `--card-foreground` - - `--popover` - - `--popover-foreground` - - `--primary` - - `--primary-foreground` - - `--secondary` - - `--secondary-foreground` - - `--muted` - - `--muted-foreground` - - `--accent` - - `--accent-foreground` - - `--destructive` - - `--destructive-foreground` - - `--border` - - `--input` - - `--ring` - - `--info` - - `--info-foreground` - - `--success` - - `--success-foreground` - - `--warning` - - `--warning-foreground` - -- `apps/web/src/hooks/useTheme.ts` - - Current type is only `Theme = "light" | "dark" | "system"`. - - Persists to `localStorage` key `t3code:theme`. - - Toggles `.dark` on `document.documentElement`. - - Calls `window.desktopBridge.setTheme(theme)` to sync Electron native theme. - -- `apps/web/src/components/settings/SettingsPanels.tsx` - - `THEME_OPTIONS` contains only `system`, `light`, `dark`. - - General settings row renders a single theme select. - -- `packages/contracts/src/ipc.ts` - - `DesktopTheme = "light" | "dark" | "system"`. - - `DesktopBridge` exposes `setTheme(theme)`, but no theme discovery/load API. - -- `apps/desktop/src/main.ts` - - Receives `desktop:set-theme`, validates with `getSafeTheme`, and sets - `nativeTheme.themeSource`. - - Window background and titlebar overlay still derive from - `nativeTheme.shouldUseDarkColors`, not from app theme colors. - -- `apps/desktop/src/preload.ts` - - Exposes the desktop bridge to the web app. - -- `packages/contracts/src/settings.ts` - - Client settings are persisted locally. - - Theme is not currently part of `ClientSettings`; it is separate localStorage - state in `useTheme.ts`. - -- `apps/web/src/components/ThreadTerminalDrawer.tsx` - - `terminalThemeFromApp` samples app background/foreground, then hardcodes - ANSI palettes for light/dark. - -- `apps/web/src/components/ChatMarkdown.tsx` - - Code blocks use `@pierre/diffs` shared highlighter. - - Theme selection is hardcoded through `resolveDiffThemeName("light"|"dark")`. - -- `apps/web/src/lib/diffRendering.ts` - - `DIFF_THEME_NAMES = { light: "pierre-light", dark: "pierre-dark" }`. - -- `apps/web/src/components/DiffWorkerPoolProvider.tsx` - - Worker highlighter options use only `pierre-light` / `pierre-dark`. - -## VS Code Theme Shape - -Official references: - -- Theme contribution point: - https://code.visualstudio.com/api/references/contribution-points#contributes.themes -- Color theme guide: - https://code.visualstudio.com/api/extension-guides/color-theme -- Workbench color IDs: - https://code.visualstudio.com/api/references/theme-color -- Semantic token colors: - https://code.visualstudio.com/api/language-extensions/semantic-highlight-guide - -VS Code themes are usually discovered through extension `package.json` files: - -```json -{ - "contributes": { - "themes": [ - { - "label": "GitHub Dark Default", - "uiTheme": "vs-dark", - "path": "./themes/dark-default.json" - } - ] - } -} -``` - -Theme JSON files commonly look like: - -```json -{ - "name": "GitHub Dark Default", - "type": "dark", - "colors": { - "editor.background": "#0d1117", - "editor.foreground": "#e6edf3", - "sideBar.background": "#010409", - "button.background": "#238636", - "terminal.ansiGreen": "#3fb950" - }, - "semanticHighlighting": true, - "tokenColors": [ - { - "scope": ["comment", "punctuation.definition.comment"], - "settings": { - "foreground": "#8b949e", - "fontStyle": "italic" - } - } - ], - "semanticTokenColors": { - "variable.readonly": "#79c0ff" - } -} -``` - -Important details: - -- `contributes.themes[].uiTheme` uses: - - `vs`: light - - `vs-dark`: dark - - `hc-light`: high contrast light - - `hc-black`: high contrast dark -- `colors` is the workbench/UI layer. -- `tokenColors` is TextMate syntax highlighting. -- `semanticHighlighting` and `semanticTokenColors` are semantic syntax rules. -- `tokenColors` can be an array or a path to a `.tmTheme` file. -- User settings files are JSONC, not strict JSON, so use a real JSONC parser. -- User overrides can include: - - `workbench.colorTheme` - - `workbench.preferredLightColorTheme` - - `workbench.preferredDarkColorTheme` - - `window.autoDetectColorScheme` - - `workbench.colorCustomizations` - - `editor.tokenColorCustomizations` - -Local discovery during research found GitHub Theme installed in both VS Code and -Cursor on this machine. Both apps currently point at `GitHub Dark Default`. - -## Design Decision - -Keep the T3 Code app contract semantic. - -Do not make every component directly consume hundreds of VS Code color IDs. -Instead: - -1. Discover and parse VS Code themes. -2. Resolve a selected VS Code theme into a normalized `ResolvedAppTheme`. -3. Apply the resolved app theme as CSS variables. -4. Keep existing Tailwind classes like `bg-background`, `text-foreground`, and - `border-border` working. -5. Add focused app tokens only where T3 has a first-class surface: - sidebar, terminal, diff, code blocks, chat, and browser/desktop chrome. - -This keeps T3 Code themable without binding every UI component to the VS Code -workbench taxonomy forever. - -## Proposed Data Model - -Add a new contracts file, likely `packages/contracts/src/theme.ts`, exported -from `packages/contracts/src/index.ts`. - -```ts -import * as Schema from "effect/Schema"; - -export const ThemeSource = Schema.Literals(["builtin", "vscode", "cursor", "vscode-insiders"]); -export type ThemeSource = typeof ThemeSource.Type; - -export const ThemeKind = Schema.Literals([ - "light", - "dark", - "high-contrast-light", - "high-contrast-dark", -]); -export type ThemeKind = typeof ThemeKind.Type; - -export const ThemeId = Schema.TemplateLiteral(Schema.String); -export type ThemeId = typeof ThemeId.Type; - -export const DiscoveredColorTheme = Schema.Struct({ - id: ThemeId, - source: ThemeSource, - extensionId: Schema.String, - extensionDisplayName: Schema.optional(Schema.String), - label: Schema.String, - kind: ThemeKind, - themePath: Schema.String, - packagePath: Schema.String, - publisher: Schema.optional(Schema.String), -}); -export type DiscoveredColorTheme = typeof DiscoveredColorTheme.Type; - -export const ResolvedColorTheme = Schema.Struct({ - id: ThemeId, - label: Schema.String, - source: ThemeSource, - kind: ThemeKind, - colors: Schema.Record(Schema.String, Schema.String), - tokenColors: Schema.Unknown, - semanticHighlighting: Schema.optional(Schema.Boolean), - semanticTokenColors: Schema.optional(Schema.Unknown), - appVariables: Schema.Record(Schema.String, Schema.String), -}); -export type ResolvedColorTheme = typeof ResolvedColorTheme.Type; - -export const ThemePreference = Schema.Union( - Schema.Struct({ mode: Schema.Literal("system") }), - Schema.Struct({ mode: Schema.Literal("builtin"), theme: Schema.Literals(["light", "dark"]) }), - Schema.Struct({ mode: Schema.Literal("external"), themeId: ThemeId }), - Schema.Struct({ - mode: Schema.Literal("follow-editor"), - source: Schema.Literals(["vscode", "cursor", "vscode-insiders"]), - }), -); -export type ThemePreference = typeof ThemePreference.Type; -``` - -Use simpler hand-written types if the Schema type ergonomics are not worth it, -but keep the bridge payloads runtime-validated at the process boundary. - -## Persistence Decision - -Move theme preference into `ClientSettings` eventually, but preserve the existing -`t3code:theme` localStorage key as a migration input. - -Why: - -- Client settings already support Electron-backed persistence via - `apps/desktop/src/clientPersistence.ts`. -- Browser fallback already persists client settings via - `apps/web/src/clientPersistenceStorage.ts`. -- Theme is a client preference, not server-authoritative state. -- A migration can read old `t3code:theme` and write the equivalent - `ThemePreference`. - -Suggested schema addition: - -```ts -export const DEFAULT_THEME_PREFERENCE = { - mode: "system", -} as const satisfies ThemePreference; - -export const ClientSettingsSchema = Schema.Struct({ - // existing settings... - themePreference: ThemePreference.pipe( - Schema.withDecodingDefault(Effect.succeed(DEFAULT_THEME_PREFERENCE)), - ), -}); -``` - -## Desktop Bridge API - -Extend `DesktopBridge` in `packages/contracts/src/ipc.ts`: - -```ts -export interface DesktopBridge { - // existing methods... - discoverColorThemes: () => Promise; - loadColorTheme: (themeId: ThemeId) => Promise; - getEditorThemePreferences: () => Promise; - setTheme: (theme: DesktopTheme) => Promise; - setWindowThemeColors: (input: { - backgroundColor: string; - titleBarColor?: string; - titleBarSymbolColor?: string; - }) => Promise; -} -``` - -`setTheme` should remain for Electron native light/dark behavior. The new -`setWindowThemeColors` lets the renderer pass resolved actual colors for -window background/titlebar overlay. - -`preload.ts` adds matching IPC channels: - -```ts -const DISCOVER_COLOR_THEMES_CHANNEL = "desktop:discover-color-themes"; -const LOAD_COLOR_THEME_CHANNEL = "desktop:load-color-theme"; -const GET_EDITOR_THEME_PREFERENCES_CHANNEL = "desktop:get-editor-theme-preferences"; -const SET_WINDOW_THEME_COLORS_CHANNEL = "desktop:set-window-theme-colors"; - -contextBridge.exposeInMainWorld("desktopBridge", { - // existing bridge... - discoverColorThemes: () => ipcRenderer.invoke(DISCOVER_COLOR_THEMES_CHANNEL), - loadColorTheme: (themeId) => ipcRenderer.invoke(LOAD_COLOR_THEME_CHANNEL, themeId), - getEditorThemePreferences: () => ipcRenderer.invoke(GET_EDITOR_THEME_PREFERENCES_CHANNEL), - setWindowThemeColors: (input) => ipcRenderer.invoke(SET_WINDOW_THEME_COLORS_CHANNEL, input), -}); -``` - -## Dependency - -Use a real JSONC parser for VS Code/Cursor settings files. - -Add by command, not manual `package.json` edits: - -```sh -bun --cwd apps/desktop add jsonc-parser -``` - -If the package is needed in shared code instead of desktop-only code, add it to -the owning workspace with the same command style. - -## Theme Discovery - -Create `apps/desktop/src/vscodeThemeDiscovery.ts`. - -Responsibilities: - -- Know editor roots by platform: - - macOS: - - `~/.vscode/extensions` - - `~/.vscode-insiders/extensions` - - `~/.cursor/extensions` - - `~/Library/Application Support/Code/User/settings.json` - - `~/Library/Application Support/Code - Insiders/User/settings.json` - - `~/Library/Application Support/Cursor/User/settings.json` - - Linux: - - `~/.vscode/extensions` - - `~/.vscode-insiders/extensions` - - `~/.cursor/extensions` - - `~/.config/Code/User/settings.json` - - `~/.config/Code - Insiders/User/settings.json` - - `~/.config/Cursor/User/settings.json` - - Windows: - - `%USERPROFILE%\\.vscode\\extensions` - - `%USERPROFILE%\\.vscode-insiders\\extensions` - - `%USERPROFILE%\\.cursor\\extensions` - - `%APPDATA%\\Code\\User\\settings.json` - - `%APPDATA%\\Code - Insiders\\User\\settings.json` - - `%APPDATA%\\Cursor\\User\\settings.json` -- Scan direct child directories for `package.json`. -- Parse `contributes.themes`. -- Resolve relative paths safely under the extension directory. -- Ignore missing or malformed themes. -- Dedupe duplicate themes from VS Code/Cursor by source plus extension plus label, - or keep both if source matters to the user. - -Core scanner sketch: - -```ts -import * as FS from "node:fs"; -import * as Path from "node:path"; -import * as OS from "node:os"; -import { parse as parseJsonc } from "jsonc-parser"; -import type { DiscoveredColorTheme, ThemeKind, ThemeSource } from "@t3tools/contracts"; - -function kindFromUiTheme(uiTheme: unknown): ThemeKind { - if (uiTheme === "hc-light") return "high-contrast-light"; - if (uiTheme === "hc-black") return "high-contrast-dark"; - if (uiTheme === "vs") return "light"; - return "dark"; -} - -function readJsonFile(filePath: string): unknown | null { - try { - return JSON.parse(FS.readFileSync(filePath, "utf8")); - } catch { - return null; - } -} - -function safeResolveChild(root: string, relativePath: string): string | null { - const resolved = Path.resolve(root, relativePath); - const normalizedRoot = Path.resolve(root); - return resolved.startsWith(`${normalizedRoot}${Path.sep}`) ? resolved : null; -} - -export function discoverEditorColorThemes(): readonly DiscoveredColorTheme[] { - const roots = resolveExtensionRoots({ - platform: process.platform, - homedir: OS.homedir(), - appData: process.env.APPDATA, - }); - - const themes: DiscoveredColorTheme[] = []; - - for (const root of roots) { - if (!FS.existsSync(root.extensionsPath)) continue; - - for (const entry of FS.readdirSync(root.extensionsPath, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - - const extensionDir = Path.join(root.extensionsPath, entry.name); - const packagePath = Path.join(extensionDir, "package.json"); - const packageJson = readJsonFile(packagePath); - if (!isObject(packageJson)) continue; - - const contributedThemes = packageJson.contributes?.themes; - if (!Array.isArray(contributedThemes)) continue; - - for (const theme of contributedThemes) { - if (!isObject(theme) || typeof theme.label !== "string" || typeof theme.path !== "string") { - continue; - } - - const themePath = safeResolveChild(extensionDir, theme.path); - if (!themePath || !FS.existsSync(themePath)) continue; - - themes.push({ - id: `${root.source}:${entry.name}:${theme.label}`, - source: root.source, - extensionId: entry.name, - extensionDisplayName: - typeof packageJson.displayName === "string" ? packageJson.displayName : undefined, - label: theme.label, - kind: kindFromUiTheme(theme.uiTheme), - themePath, - packagePath, - publisher: typeof packageJson.publisher === "string" ? packageJson.publisher : undefined, - }); - } - } - } - - return themes; -} -``` - -Avoid `as any`. Use small type guards: - -```ts -function isObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} -``` - -## Settings JSONC Parsing - -Read active VS Code/Cursor theme preferences: - -```ts -import { parse as parseJsonc } from "jsonc-parser"; - -export interface EditorThemePreference { - readonly source: "vscode" | "cursor" | "vscode-insiders"; - readonly colorTheme: string | null; - readonly preferredLightColorTheme: string | null; - readonly preferredDarkColorTheme: string | null; - readonly autoDetectColorScheme: boolean; - readonly workbenchColorCustomizations: Record; - readonly editorTokenColorCustomizations: unknown; -} - -function readEditorSettings(settingsPath: string): Record { - try { - const errors: unknown[] = []; - const parsed = parseJsonc(FS.readFileSync(settingsPath, "utf8"), errors, { - allowTrailingComma: true, - disallowComments: false, - }); - return isObject(parsed) ? parsed : {}; - } catch { - return {}; - } -} -``` - -Do not fail the whole discovery call if settings are malformed. - -## Theme File Loading - -Load a theme by `themeId`: - -```ts -export function loadEditorColorTheme(themeId: string): ResolvedColorTheme | null { - const theme = discoverEditorColorThemes().find((entry) => entry.id === themeId); - if (!theme) return null; - - const rawTheme = readJsonOrJsoncFile(theme.themePath); - if (!isObject(rawTheme)) return null; - - const colors = normalizeThemeColors(rawTheme.colors); - const appVariables = mapVscodeColorsToAppVariables({ - kind: theme.kind, - colors, - }); - - return { - id: theme.id, - label: theme.label, - source: theme.source, - kind: theme.kind, - colors, - tokenColors: rawTheme.tokenColors ?? [], - semanticHighlighting: - typeof rawTheme.semanticHighlighting === "boolean" - ? rawTheme.semanticHighlighting - : undefined, - semanticTokenColors: rawTheme.semanticTokenColors, - appVariables, - }; -} -``` - -Support JSON theme files first. Treat `.tmTheme` and remote URL `tokenColors` -as phase 2 unless common installed themes require them. - -## Color Normalization - -VS Code supports: - -- `#RGB` -- `#RGBA` -- `#RRGGBB` -- `#RRGGBBAA` - -Normalize all hex values to browser-safe CSS strings. Preserve alpha when -present. - -```ts -const HEX_COLOR_PATTERN = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i; - -function normalizeCssColor(value: unknown): string | null { - if (typeof value !== "string") return null; - const trimmed = value.trim(); - if (!HEX_COLOR_PATTERN.test(trimmed)) return null; - return trimmed; -} - -function normalizeThemeColors(value: unknown): Record { - if (!isObject(value)) return {}; - - const colors: Record = {}; - for (const [key, rawColor] of Object.entries(value)) { - const color = normalizeCssColor(rawColor); - if (color) { - colors[key] = color; - } - } - return colors; -} -``` - -If support for `rgb(...)`, `rgba(...)`, or named colors appears in real themes, -add it deliberately with tests. - -## Mapping VS Code Colors To T3 Tokens - -Create a pure shared mapper, probably `packages/shared/src/themeMapping.ts`, if -both desktop and web need it. Otherwise keep it in web if desktop only loads -raw theme data. - -Priority mapping: - -| T3 variable | VS Code priority | -| -------------------------- | --------------------------------------------------------------------------------------- | -| `--background` | `editor.background`, `sideBar.background`, default built-in | -| `--app-chrome-background` | `titleBar.activeBackground`, `sideBar.background`, `--background` | -| `--foreground` | `foreground`, `editor.foreground` | -| `--card` | `editorGroupHeader.tabsBackground`, `sideBarSectionHeader.background`, mixed background | -| `--card-foreground` | `foreground`, `editor.foreground` | -| `--popover` | `quickInput.background`, `editorWidget.background`, `dropdown.background`, `--card` | -| `--popover-foreground` | `quickInput.foreground`, `editorWidget.foreground`, `foreground` | -| `--primary` | `button.background`, `activityBarBadge.background`, `focusBorder` | -| `--primary-foreground` | `button.foreground`, contrast of primary | -| `--secondary` | `button.secondaryBackground`, `badge.background`, transparent mix | -| `--secondary-foreground` | `button.secondaryForeground`, `foreground` | -| `--muted` | `editor.lineHighlightBackground`, `list.hoverBackground`, transparent mix | -| `--muted-foreground` | `descriptionForeground`, `disabledForeground`, mixed foreground | -| `--accent` | `list.activeSelectionBackground`, `list.hoverBackground`, `toolbar.hoverBackground` | -| `--accent-foreground` | `list.activeSelectionForeground`, `list.hoverForeground`, `foreground` | -| `--destructive` | `errorForeground`, `editorError.foreground`, `notificationsErrorIcon.foreground` | -| `--destructive-foreground` | `errorForeground`, `editorError.foreground` | -| `--border` | `widget.border`, `sideBar.border`, `editorGroup.border`, `panel.border` | -| `--input` | `input.background`, `dropdown.background`, `settings.textInputBackground` | -| `--ring` | `focusBorder`, `inputOption.activeBorder`, `button.background` | -| `--info` | `textLink.foreground`, `terminal.ansiBlue` | -| `--info-foreground` | `textLink.foreground`, `terminal.ansiBrightBlue`, `--info` | -| `--success` | `gitDecoration.addedResourceForeground`, `terminal.ansiGreen` | -| `--success-foreground` | `gitDecoration.addedResourceForeground`, `terminal.ansiBrightGreen` | -| `--warning` | `notificationsWarningIcon.foreground`, `terminal.ansiYellow` | -| `--warning-foreground` | `notificationsWarningIcon.foreground`, `terminal.ansiBrightYellow` | - -Additional app tokens to add: - -| T3 variable | VS Code priority | -| --------------------------------- | ----------------------------------------------------------------------------- | -| `--sidebar` | `sideBar.background`, `activityBar.background`, `--background` | -| `--sidebar-foreground` | `sideBar.foreground`, `foreground` | -| `--sidebar-accent` | `list.activeSelectionBackground`, `activityBar.activeBackground`, `--accent` | -| `--sidebar-accent-foreground` | `list.activeSelectionForeground`, `sideBar.foreground` | -| `--sidebar-border` | `sideBar.border`, `activityBar.border`, `--border` | -| `--terminal-background` | `terminal.background`, `panel.background`, `--background` | -| `--terminal-foreground` | `terminal.foreground`, `foreground` | -| `--terminal-cursor` | `terminalCursor.foreground`, `editorCursor.foreground`, `--ring` | -| `--terminal-selection-background` | `terminal.selectionBackground`, `editor.selectionBackground` | -| `--diff-inserted` | `diffEditor.insertedTextBackground`, `gitDecoration.addedResourceForeground` | -| `--diff-removed` | `diffEditor.removedTextBackground`, `gitDecoration.deletedResourceForeground` | -| `--chat-request-background` | `chat.requestBackground`, `--card` | -| `--chat-request-border` | `chat.requestBorder`, `--border` | - -Mapper sketch: - -```ts -function firstColor(colors: Record, keys: readonly string[]): string | null { - for (const key of keys) { - const color = colors[key]; - if (color) return color; - } - return null; -} - -function mapVscodeColorsToAppVariables(input: { - readonly kind: ThemeKind; - readonly colors: Record; -}): Record { - const fallback = input.kind.includes("dark") ? DARK_FALLBACKS : LIGHT_FALLBACKS; - const colors = input.colors; - - const background = - firstColor(colors, ["editor.background", "sideBar.background"]) ?? fallback.background; - const foreground = firstColor(colors, ["foreground", "editor.foreground"]) ?? fallback.foreground; - const primary = - firstColor(colors, ["button.background", "activityBarBadge.background", "focusBorder"]) ?? - fallback.primary; - const border = - firstColor(colors, ["widget.border", "sideBar.border", "editorGroup.border", "panel.border"]) ?? - fallback.border; - - return { - "--background": background, - "--app-chrome-background": - firstColor(colors, ["titleBar.activeBackground", "sideBar.background"]) ?? background, - "--foreground": foreground, - "--card": - firstColor(colors, ["editorGroupHeader.tabsBackground", "sideBarSectionHeader.background"]) ?? - `color-mix(in srgb, ${background} 94%, ${foreground})`, - "--card-foreground": foreground, - "--popover": - firstColor(colors, [ - "quickInput.background", - "editorWidget.background", - "dropdown.background", - ]) ?? background, - "--popover-foreground": - firstColor(colors, [ - "quickInput.foreground", - "editorWidget.foreground", - "dropdown.foreground", - ]) ?? foreground, - "--primary": primary, - "--primary-foreground": - firstColor(colors, ["button.foreground"]) ?? readableForeground(primary), - "--border": border, - "--ring": firstColor(colors, ["focusBorder", "inputOption.activeBorder"]) ?? primary, - "--sidebar": firstColor(colors, ["sideBar.background", "activityBar.background"]) ?? background, - "--sidebar-foreground": firstColor(colors, ["sideBar.foreground"]) ?? foreground, - "--sidebar-border": firstColor(colors, ["sideBar.border", "activityBar.border"]) ?? border, - }; -} -``` - -Use CSS `color-mix(...)` where reasonable so the browser can evaluate colors -without bundling a large color library. For contrast-dependent values, a tiny -hex luminance helper is enough. - -## CSS Changes - -Add the sidebar/app-specific variables to `@theme inline`: - -```css -@theme inline { - --color-sidebar: var(--sidebar); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-border: var(--sidebar-border); -} -``` - -Add defaults under `:root` and `@variant dark`: - -```css -:root { - --sidebar: var(--background); - --sidebar-foreground: var(--foreground); - --sidebar-accent: var(--accent); - --sidebar-accent-foreground: var(--accent-foreground); - --sidebar-border: var(--border); - - --terminal-background: var(--background); - --terminal-foreground: var(--foreground); - --terminal-cursor: var(--ring); - --terminal-selection-background: color-mix(in srgb, var(--ring) 24%, transparent); -} -``` - -This also fixes the current shadcn-style sidebar classes that reference -`bg-sidebar` and `text-sidebar-foreground` without explicit tokens in -`index.css`. - -## Web Theme Hook Refactor - -Replace `Theme = "light" | "dark" | "system"` with `ThemePreference` plus a -resolved theme snapshot. - -Suggested structure: - -```ts -type ThemeSnapshot = { - preference: ThemePreference; - resolvedKind: "light" | "dark"; - resolvedTheme: ResolvedColorTheme | null; - discoveredThemes: readonly DiscoveredColorTheme[]; - status: "idle" | "loading" | "ready" | "error"; -}; -``` - -Apply CSS variables: - -```ts -function applyResolvedTheme(theme: ResolvedColorTheme | null, suppressTransitions = false) { - if (typeof document === "undefined") return; - - if (suppressTransitions) { - document.documentElement.classList.add("no-transitions"); - } - - const root = document.documentElement; - - if (theme) { - for (const [name, value] of Object.entries(theme.appVariables)) { - root.style.setProperty(name, value); - } - } else { - clearExternalThemeVariables(root); - } - - const isDark = theme - ? theme.kind === "dark" || theme.kind === "high-contrast-dark" - : getBuiltInDark(); - root.classList.toggle("dark", isDark); - root.dataset.themeSource = theme?.source ?? "builtin"; - root.dataset.themeId = theme?.id ?? ""; - - syncBrowserChromeTheme(); - syncDesktopTheme(isDark ? "dark" : "light"); - syncDesktopWindowColors(theme); - - if (suppressTransitions) { - root.offsetHeight; - requestAnimationFrame(() => root.classList.remove("no-transitions")); - } -} -``` - -Keep a minimal synchronous bootstrap cache: - -```ts -const BOOTSTRAP_THEME_CACHE_KEY = "t3code:resolved-theme-cache:v1"; - -// On successful external theme load, persist only: -// - theme id -// - kind -// - appVariables -// This prevents first paint flash without needing filesystem access before React. -``` - -## Settings UI - -Replace the single theme select in General settings with a richer Appearance -section. Keep it compact and workmanlike. - -Suggested layout: - -- `Theme` - - Select: - - `System` - - `Light` - - `Dark` - - separator/group: `VS Code` - - discovered VS Code themes - - separator/group: `Cursor` - - discovered Cursor themes -- `Follow editor theme` - - Select or segmented control: - - Off - - VS Code - - Cursor -- `Refresh themes` - - Small icon button. -- Status text: - - `Detected 18 themes from VS Code and Cursor.` - - `Using GitHub Dark Default from Cursor.` - - Missing theme fallback: - - `Selected theme is unavailable. Falling back to System.` - -Pseudo-component: - -```tsx -function ThemeSettingsRow() { - const { preference, setThemePreference, discoveredThemes, refreshThemes } = useTheme(); - - return ( - setThemePreference(selectValueToThemePreference(value))} - > - - {themePreferenceLabel(preference, discoveredThemes)} - - - - System - - - Light - - - Dark - - {discoveredThemes.map((theme) => ( - - {theme.label} - - ))} - - - } - /> - ); -} -``` - -If `Select` does not support headings/separators, either add that to the UI -component or split into source-filtered rows. Do not jam labels like -`VS Code - GitHub Dark` into the only affordance if grouping can be done cleanly. - -## Terminal Theme - -Replace hardcoded ANSI palettes with VS Code terminal colors when available. - -Mapping: - -```ts -const TERMINAL_COLOR_KEYS = { - background: "terminal.background", - foreground: "terminal.foreground", - cursor: "terminalCursor.foreground", - selectionBackground: "terminal.selectionBackground", - black: "terminal.ansiBlack", - red: "terminal.ansiRed", - green: "terminal.ansiGreen", - yellow: "terminal.ansiYellow", - blue: "terminal.ansiBlue", - magenta: "terminal.ansiMagenta", - cyan: "terminal.ansiCyan", - white: "terminal.ansiWhite", - brightBlack: "terminal.ansiBrightBlack", - brightRed: "terminal.ansiBrightRed", - brightGreen: "terminal.ansiBrightGreen", - brightYellow: "terminal.ansiBrightYellow", - brightBlue: "terminal.ansiBrightBlue", - brightMagenta: "terminal.ansiBrightMagenta", - brightCyan: "terminal.ansiBrightCyan", - brightWhite: "terminal.ansiBrightWhite", -} as const; -``` - -Sketch: - -```ts -function terminalThemeFromResolvedTheme( - theme: ResolvedColorTheme | null, - mountElement?: HTMLElement | null, -): ITheme { - const appTheme = terminalThemeFromApp(mountElement); - const colors = theme?.colors ?? {}; - - return { - ...appTheme, - background: - colors["terminal.background"] ?? - theme?.appVariables["--terminal-background"] ?? - appTheme.background, - foreground: - colors["terminal.foreground"] ?? - theme?.appVariables["--terminal-foreground"] ?? - appTheme.foreground, - cursor: - colors["terminalCursor.foreground"] ?? - theme?.appVariables["--terminal-cursor"] ?? - appTheme.cursor, - selectionBackground: - colors["terminal.selectionBackground"] ?? - colors["editor.selectionBackground"] ?? - theme?.appVariables["--terminal-selection-background"] ?? - appTheme.selectionBackground, - green: colors["terminal.ansiGreen"] ?? appTheme.green, - brightGreen: colors["terminal.ansiBrightGreen"] ?? appTheme.brightGreen, - }; -} -``` - -Expose the resolved theme through a tiny store or hook so -`ThreadTerminalDrawer.tsx` can update existing terminals when the external theme -changes. The existing `MutationObserver` already watches `class` and `style`, -but if app variables move through a store, include direct subscription too. - -## Code Block And Diff Syntax Themes - -Phase 1 should apply app UI and terminal themes. Syntax parity can be included -if `@pierre/diffs` exposes a way to register custom Shiki themes cleanly. - -Investigate package capability before implementation: - -- Does `getSharedHighlighter({ themes })` accept a Shiki theme object or only - built-in theme names? -- Does `WorkerPoolContextProvider` accept custom theme objects? -- Can `@pierre/diffs` workers receive a custom theme definition? - -If custom Shiki themes are supported, convert VS Code theme rules: - -```ts -function toShikiTheme(theme: ResolvedColorTheme) { - return { - name: theme.id, - type: theme.kind.includes("dark") ? "dark" : "light", - colors: { - "editor.background": theme.colors["editor.background"] ?? theme.appVariables["--background"], - "editor.foreground": theme.colors["editor.foreground"] ?? theme.appVariables["--foreground"], - }, - tokenColors: Array.isArray(theme.tokenColors) ? theme.tokenColors : [], - semanticHighlighting: theme.semanticHighlighting, - semanticTokenColors: theme.semanticTokenColors, - }; -} -``` - -Then replace: - -```ts -themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")]; -``` - -with something like: - -```ts -themes: getHighlighterThemesForResolvedAppTheme(resolvedTheme); -``` - -If the diff package does not support custom themes, keep `pierre-light` / -`pierre-dark` for syntax during phase 1 and document syntax parity as phase 2. -The app chrome should still use VS Code theme colors immediately. - -## Desktop Window Appearance - -Current desktop window background: - -```ts -function getInitialWindowBackgroundColor(): string { - return nativeTheme.shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; -} -``` - -Add renderer-driven window colors: - -```ts -let windowThemeColors: { - backgroundColor: string; - titleBarColor?: string; - titleBarSymbolColor?: string; -} | null = null; - -function getInitialWindowBackgroundColor(): string { - return ( - windowThemeColors?.backgroundColor ?? (nativeTheme.shouldUseDarkColors ? "#0a0a0a" : "#ffffff") - ); -} - -function getWindowTitleBarOptions(): WindowTitleBarOptions { - // keep macOS hiddenInset behavior - return { - titleBarStyle: "hidden", - titleBarOverlay: { - color: windowThemeColors?.titleBarColor ?? TITLEBAR_COLOR, - height: TITLEBAR_HEIGHT, - symbolColor: - windowThemeColors?.titleBarSymbolColor ?? - (nativeTheme.shouldUseDarkColors - ? TITLEBAR_DARK_SYMBOL_COLOR - : TITLEBAR_LIGHT_SYMBOL_COLOR), - }, - }; -} -``` - -IPC handler: - -```ts -ipcMain.handle(SET_WINDOW_THEME_COLORS_CHANNEL, async (_event, rawInput: unknown) => { - const input = decodeWindowThemeColors(rawInput); - if (!input) return; - windowThemeColors = input; - syncAllWindowAppearance(); -}); -``` - -## User Overrides - -Support overrides in this order: - -1. Theme file `colors` -2. Editor user `workbench.colorCustomizations` -3. Theme-scoped editor user overrides, if present and matching the selected theme -4. T3 fallback values - -Example override shape: - -```jsonc -{ - "workbench.colorCustomizations": { - "titleBar.activeBackground": "#000000", - "[GitHub Dark Default]": { - "activityBar.background": "#111111", - }, - }, -} -``` - -Merge helper: - -```ts -function mergeWorkbenchOverrides(input: { - themeLabel: string; - themeColors: Record; - customizations: Record; -}): Record { - const next = { ...input.themeColors }; - - for (const [key, rawValue] of Object.entries(input.customizations)) { - if (key.startsWith("[") && key.endsWith("]")) continue; - const color = normalizeCssColor(rawValue); - if (color) next[key] = color; - } - - const scoped = input.customizations[`[${input.themeLabel}]`]; - if (isObject(scoped)) { - for (const [key, rawValue] of Object.entries(scoped)) { - const color = normalizeCssColor(rawValue); - if (color) next[key] = color; - } - } - - return next; -} -``` - -Token color customizations can be added later after code/diff syntax theme -support is proven. - -## Hosted/Browser Mode - -In regular browser mode there is no local filesystem access, so: - -- Built-in `system`, `light`, and `dark` must continue to work. -- External theme controls should hide or show an unavailable state when - `window.desktopBridge` is missing. -- If a cached external theme exists, it can be applied as a best-effort visual - cache, but it should be clear that refresh/discovery requires desktop mode. - -## Edge Cases - -- Missing selected theme: - - Fall back to `system`. - - Keep the missing id in preference only if UX benefits from showing - "unavailable"; otherwise clear it. -- Duplicate theme labels: - - Use source + extension id + label as id. - - Show source/publisher metadata in UI. -- Malformed extension package: - - Ignore silently in discovery. - - Optionally include diagnostics in a dev-only log. -- Malformed theme file: - - Theme appears only if file parses enough to load. -- `.tmTheme` token colors: - - Defer unless a popular installed theme needs it. -- Remote `tokenColors` URL: - - Do not fetch automatically. -- Security: - - Never expose arbitrary filesystem reads to the renderer. - - Renderer can request only discovered theme ids. - - Desktop validates theme ids against fresh discovery before loading. - -## Suggested Implementation Phases - -### Phase 1: Discovery And Contracts - -Files: - -- `packages/contracts/src/theme.ts` -- `packages/contracts/src/ipc.ts` -- `packages/contracts/src/index.ts` -- `apps/desktop/src/vscodeThemeDiscovery.ts` -- `apps/desktop/src/vscodeThemeDiscovery.test.ts` -- `apps/desktop/src/preload.ts` -- `apps/desktop/src/main.ts` - -Deliverables: - -- Desktop can list VS Code/Cursor themes. -- Desktop can load a selected theme by id. -- Tests cover package scanning, path safety, JSONC settings parsing, and bad - package/theme files. - -### Phase 2: Mapper And CSS Variables - -Files: - -- `packages/shared/src/themeMapping.ts` or `apps/web/src/themeMapping.ts` -- `apps/web/src/index.css` -- mapper tests - -Deliverables: - -- VS Code colors map to T3 app variables. -- Built-in app tokens remain unchanged. -- Sidebar tokens are explicit. -- Contrast fallback helpers are tested. - -### Phase 3: Web Theme State And Settings UI - -Files: - -- `packages/contracts/src/settings.ts` -- `apps/web/src/hooks/useTheme.ts` -- `apps/web/src/hooks/useSettings.ts` -- `apps/web/src/clientPersistenceStorage.ts` -- `apps/desktop/src/clientPersistence.ts` -- `apps/web/src/components/settings/SettingsPanels.tsx` -- browser tests around settings - -Deliverables: - -- Theme preference is persisted in client settings. -- Old `t3code:theme` localStorage values migrate. -- Settings UI lists detected themes and built-ins. -- Selected external theme applies CSS variables. - -### Phase 4: Terminal And Desktop Chrome - -Files: - -- `apps/web/src/components/ThreadTerminalDrawer.tsx` -- `apps/desktop/src/main.ts` -- `apps/desktop/src/preload.ts` -- `packages/contracts/src/ipc.ts` - -Deliverables: - -- Xterm uses VS Code `terminal.*` colors. -- Desktop background/titlebar colors match the selected theme. -- Existing terminal theme tests are updated. - -### Phase 5: Code And Diff Syntax Theme Parity - -Files: - -- `apps/web/src/components/ChatMarkdown.tsx` -- `apps/web/src/lib/diffRendering.ts` -- `apps/web/src/components/DiffWorkerPoolProvider.tsx` -- possibly a new syntax theme adapter module - -Deliverables: - -- Chat code blocks use theme `tokenColors` if supported. -- Diff worker uses the same syntax theme if supported. -- If unsupported, this phase documents the blocking package limitation and keeps - UI/terminal parity shipped. - -### Phase 6: Polish And Recovery - -Deliverables: - -- Missing theme recovery. -- Refresh themes action. -- Current VS Code/Cursor theme badges. -- Hosted/browser unavailable state. -- Docs note in README or settings help text if needed. - -## Test Plan - -Required repo checks after implementation: - -```sh -bun fmt -bun lint -bun typecheck -``` - -Use `bun run test`, never `bun test`. - -Targeted tests to add/run: - -```sh -bun run test --filter @t3tools/desktop -- vscodeThemeDiscovery -bun run test --filter @t3tools/web -- useTheme -bun run test --filter @t3tools/web -- SettingsPanels -bun run test --filter @t3tools/web -- ThreadTerminalDrawer -``` - -If root `turbo` filtering does not match these names, use package-local Vitest -commands with explicit files: - -```sh -bun --cwd apps/desktop run test src/vscodeThemeDiscovery.test.ts -bun --cwd apps/web run test src/hooks/useTheme.test.ts -bun --cwd apps/web run test src/components/settings/SettingsPanels.browser.tsx -bun --cwd apps/web run test src/components/ThreadTerminalDrawer.browser.tsx -``` - -Manual verification: - -1. Open desktop T3 Code. -2. Go to Settings -> General or Appearance. -3. Verify VS Code and Cursor themes appear. -4. Select `GitHub Dark Default`. -5. Confirm app shell, sidebar, settings cards, popovers, chat, terminal, and - diff panel all update. -6. Switch to a light theme and confirm `.dark` compatibility class changes. -7. Quit/reopen and verify no first-paint flash beyond the cached fallback. -8. Rename or remove a selected extension directory and verify graceful fallback. - -## First Implementation Slice - -The smallest useful slice: - -1. Add discovery/loading bridge in Electron. -2. Add mapper from VS Code `colors` to T3 variables. -3. Extend settings UI to list detected themes. -4. Apply selected external theme via CSS variables. -5. Leave syntax token parity for phase 2 if `@pierre/diffs` does not trivially - accept custom Shiki themes. - -That slice gives users the main "my T3 Code uses my VS Code theme" experience -without overfitting the first pass to every possible token color edge case. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index cd20211bfef..52c4853811a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -18,7 +18,8 @@ "@effect/platform-node": "catalog:", "effect": "catalog:", "electron": "40.9.3", - "electron-updater": "^6.6.2" + "electron-updater": "^6.6.2", + "jsonc-parser": "^3.3.1" }, "devDependencies": { "@t3tools/client-runtime": "workspace:*", diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts index f0c7c30e202..d1b646a17e2 100644 --- a/apps/desktop/src/clientPersistence.test.ts +++ b/apps/desktop/src/clientPersistence.test.ts @@ -64,6 +64,7 @@ const clientSettings: ClientSettings = { sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", timestampFormat: "24-hour", + themePreference: { mode: "system" }, }; const savedRegistryRecord: PersistedSavedEnvironmentRecord = { diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9c097fc9bd1..d6af676d5a5 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -30,6 +30,7 @@ import type { DesktopUpdateActionResult, DesktopUpdateCheckResult, DesktopUpdateState, + DesktopWindowThemeColors, } from "@t3tools/contracts"; import { autoUpdater } from "electron-updater"; @@ -84,12 +85,21 @@ import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runti import { resolveDesktopAppBranding } from "./appBranding.ts"; import { bindFirstRevealTrigger, type RevealSubscription } from "./windowReveal.ts"; import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; +import { + discoverEditorColorThemes, + getEditorThemePreferences, + loadEditorColorTheme, +} from "./vscodeThemeDiscovery.ts"; syncShellEnvironment(); const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const CONFIRM_CHANNEL = "desktop:confirm"; const SET_THEME_CHANNEL = "desktop:set-theme"; +const DISCOVER_COLOR_THEMES_CHANNEL = "desktop:discover-color-themes"; +const LOAD_COLOR_THEME_CHANNEL = "desktop:load-color-theme"; +const GET_EDITOR_THEME_PREFERENCES_CHANNEL = "desktop:get-editor-theme-preferences"; +const SET_WINDOW_THEME_COLORS_CHANNEL = "desktop:set-window-theme-colors"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; @@ -118,6 +128,7 @@ const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json"); const CLIENT_SETTINGS_PATH = Path.join(STATE_DIR, "client-settings.json"); const SAVED_ENVIRONMENT_REGISTRY_PATH = Path.join(STATE_DIR, "saved-environments.json"); const DESKTOP_SCHEME = "t3"; +let windowThemeColors: DesktopWindowThemeColors | null = null; const ROOT_DIR = Path.resolve(__dirname, "../../.."); const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); // Dev-only SSH launcher override. Set this to an absolute path on the SSH host @@ -498,6 +509,20 @@ function getSafeTheme(rawTheme: unknown): DesktopTheme | null { return null; } +function getSafeWindowThemeColors(rawInput: unknown): DesktopWindowThemeColors | null { + if (typeof rawInput !== "object" || rawInput === null || Array.isArray(rawInput)) return null; + const input = rawInput as Record; + if (typeof input.backgroundColor !== "string" || input.backgroundColor.trim().length === 0) { + return null; + } + return { + backgroundColor: input.backgroundColor, + titleBarColor: typeof input.titleBarColor === "string" ? input.titleBarColor : undefined, + titleBarSymbolColor: + typeof input.titleBarSymbolColor === "string" ? input.titleBarSymbolColor : undefined, + }; +} + async function waitForBackendHttpReady( baseUrl: string, options?: Parameters[1], @@ -1818,6 +1843,26 @@ function registerIpcHandlers(): void { nativeTheme.themeSource = theme; }); + ipcMain.removeHandler(DISCOVER_COLOR_THEMES_CHANNEL); + ipcMain.handle(DISCOVER_COLOR_THEMES_CHANNEL, async () => discoverEditorColorThemes()); + + ipcMain.removeHandler(LOAD_COLOR_THEME_CHANNEL); + ipcMain.handle(LOAD_COLOR_THEME_CHANNEL, async (_event, rawThemeId: unknown) => { + if (typeof rawThemeId !== "string") return null; + return loadEditorColorTheme(rawThemeId); + }); + + ipcMain.removeHandler(GET_EDITOR_THEME_PREFERENCES_CHANNEL); + ipcMain.handle(GET_EDITOR_THEME_PREFERENCES_CHANNEL, async () => getEditorThemePreferences()); + + ipcMain.removeHandler(SET_WINDOW_THEME_COLORS_CHANNEL); + ipcMain.handle(SET_WINDOW_THEME_COLORS_CHANNEL, async (_event, rawInput: unknown) => { + const input = getSafeWindowThemeColors(rawInput); + if (!input) return; + windowThemeColors = input; + syncAllWindowAppearance(); + }); + ipcMain.removeHandler(CONTEXT_MENU_CHANNEL); ipcMain.handle( CONTEXT_MENU_CHANNEL, @@ -1989,7 +2034,9 @@ function getIconOption(): { icon: string } | Record { } function getInitialWindowBackgroundColor(): string { - return nativeTheme.shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; + return ( + windowThemeColors?.backgroundColor ?? (nativeTheme.shouldUseDarkColors ? "#0a0a0a" : "#ffffff") + ); } function getWindowTitleBarOptions(): WindowTitleBarOptions { @@ -2003,11 +2050,13 @@ function getWindowTitleBarOptions(): WindowTitleBarOptions { return { titleBarStyle: "hidden", titleBarOverlay: { - color: TITLEBAR_COLOR, + color: windowThemeColors?.titleBarColor ?? TITLEBAR_COLOR, height: TITLEBAR_HEIGHT, - symbolColor: nativeTheme.shouldUseDarkColors - ? TITLEBAR_DARK_SYMBOL_COLOR - : TITLEBAR_LIGHT_SYMBOL_COLOR, + symbolColor: + windowThemeColors?.titleBarSymbolColor ?? + (nativeTheme.shouldUseDarkColors + ? TITLEBAR_DARK_SYMBOL_COLOR + : TITLEBAR_LIGHT_SYMBOL_COLOR), }, }; } diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index b3b553fe214..09f26956a98 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -4,6 +4,10 @@ import type { DesktopBridge } from "@t3tools/contracts"; const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const CONFIRM_CHANNEL = "desktop:confirm"; const SET_THEME_CHANNEL = "desktop:set-theme"; +const DISCOVER_COLOR_THEMES_CHANNEL = "desktop:discover-color-themes"; +const LOAD_COLOR_THEME_CHANNEL = "desktop:load-color-theme"; +const GET_EDITOR_THEME_PREFERENCES_CHANNEL = "desktop:get-editor-theme-preferences"; +const SET_WINDOW_THEME_COLORS_CHANNEL = "desktop:set-window-theme-colors"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; @@ -114,7 +118,11 @@ contextBridge.exposeInMainWorld("desktopBridge", { getAdvertisedEndpoints: () => ipcRenderer.invoke(GET_ADVERTISED_ENDPOINTS_CHANNEL), pickFolder: (options) => ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options), confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), + discoverColorThemes: () => ipcRenderer.invoke(DISCOVER_COLOR_THEMES_CHANNEL), + loadColorTheme: (themeId) => ipcRenderer.invoke(LOAD_COLOR_THEME_CHANNEL, themeId), + getEditorThemePreferences: () => ipcRenderer.invoke(GET_EDITOR_THEME_PREFERENCES_CHANNEL), setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), + setWindowThemeColors: (input) => ipcRenderer.invoke(SET_WINDOW_THEME_COLORS_CHANNEL, input), showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position), openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url), onMenuAction: (listener) => { diff --git a/apps/desktop/src/vscodeThemeDiscovery.test.ts b/apps/desktop/src/vscodeThemeDiscovery.test.ts new file mode 100644 index 00000000000..846c40c762f --- /dev/null +++ b/apps/desktop/src/vscodeThemeDiscovery.test.ts @@ -0,0 +1,104 @@ +import * as FS from "node:fs"; +import * as OS from "node:os"; +import * as Path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + discoverEditorColorThemes, + getEditorThemePreferences, + loadEditorColorTheme, + resolveEditorRoots, +} from "./vscodeThemeDiscovery.ts"; + +let tempDir = ""; + +function writeJson(filePath: string, value: unknown) { + FS.mkdirSync(Path.dirname(filePath), { recursive: true }); + FS.writeFileSync(filePath, JSON.stringify(value, null, 2)); +} + +describe("vscodeThemeDiscovery", () => { + beforeEach(() => { + tempDir = FS.mkdtempSync(Path.join(OS.tmpdir(), "t3-vscode-themes-")); + }); + + afterEach(() => { + FS.rmSync(tempDir, { recursive: true, force: true }); + }); + + it("discovers contributed themes and ignores unsafe paths", () => { + const roots = resolveEditorRoots({ platform: "darwin", homedir: tempDir }); + const vscodeRoot = roots.find((root) => root.source === "vscode"); + expect(vscodeRoot).toBeDefined(); + if (!vscodeRoot) return; + + const extensionDir = Path.join(vscodeRoot.extensionsPath, "github.github-vscode-theme"); + writeJson(Path.join(extensionDir, "package.json"), { + publisher: "GitHub", + displayName: "GitHub Theme", + contributes: { + themes: [ + { label: "GitHub Dark Default", uiTheme: "vs-dark", path: "./themes/dark.json" }, + { label: "Escaped", uiTheme: "vs", path: "../escaped.json" }, + ], + }, + }); + writeJson(Path.join(extensionDir, "themes", "dark.json"), { + colors: { "editor.background": "#0d1117" }, + }); + + expect(discoverEditorColorThemes(roots)).toMatchObject([ + { + source: "vscode", + label: "GitHub Dark Default", + kind: "dark", + publisher: "GitHub", + }, + ]); + }); + + it("loads JSONC settings overrides into the resolved app theme", () => { + const roots = resolveEditorRoots({ platform: "darwin", homedir: tempDir }); + const vscodeRoot = roots.find((root) => root.source === "vscode"); + expect(vscodeRoot).toBeDefined(); + if (!vscodeRoot) return; + + const extensionDir = Path.join(vscodeRoot.extensionsPath, "github.github-vscode-theme"); + writeJson(Path.join(extensionDir, "package.json"), { + contributes: { + themes: [{ label: "GitHub Dark Default", uiTheme: "vs-dark", path: "./themes/dark.json" }], + }, + }); + writeJson(Path.join(extensionDir, "themes", "dark.json"), { + semanticHighlighting: true, + colors: { + "editor.background": "#0d1117", + "editor.foreground": "#e6edf3", + }, + tokenColors: [], + }); + FS.mkdirSync(Path.dirname(vscodeRoot.settingsPath), { recursive: true }); + FS.writeFileSync( + vscodeRoot.settingsPath, + `{ + "workbench.colorTheme": "GitHub Dark Default", + "workbench.colorCustomizations": { + "button.background": "#238636", + "[GitHub Dark Default]": { + "sideBar.background": "#010409" + } + } + }`, + ); + + const preferences = getEditorThemePreferences(roots); + expect(preferences[0]?.colorTheme).toBe("GitHub Dark Default"); + + const themeId = discoverEditorColorThemes(roots)[0]?.id; + expect(themeId).toBeTruthy(); + const resolved = themeId ? loadEditorColorTheme(themeId, roots) : null; + + expect(resolved?.colors["button.background"]).toBe("#238636"); + expect(resolved?.colors["sideBar.background"]).toBe("#010409"); + expect(resolved?.appVariables["--primary"]).toBe("#238636"); + }); +}); diff --git a/apps/desktop/src/vscodeThemeDiscovery.ts b/apps/desktop/src/vscodeThemeDiscovery.ts new file mode 100644 index 00000000000..03c2d23bd29 --- /dev/null +++ b/apps/desktop/src/vscodeThemeDiscovery.ts @@ -0,0 +1,284 @@ +import * as FS from "node:fs"; +import * as OS from "node:os"; +import * as Path from "node:path"; +import { parse as parseJsonc } from "jsonc-parser"; +import type { + DiscoveredColorTheme, + EditorThemePreference, + EditorThemeSource, + ResolvedColorTheme, + ThemeKind, +} from "@t3tools/contracts"; +import { + mapVscodeColorsToAppVariables, + normalizeCssColor, + normalizeThemeColors, +} from "@t3tools/shared/themeMapping"; + +interface EditorRoot { + readonly source: EditorThemeSource; + readonly extensionsPath: string; + readonly settingsPath: string; +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function kindFromUiTheme(uiTheme: unknown): ThemeKind { + if (uiTheme === "hc-light") return "high-contrast-light"; + if (uiTheme === "hc-black") return "high-contrast-dark"; + if (uiTheme === "vs") return "light"; + return "dark"; +} + +function readJsonOrJsoncFile(filePath: string): unknown | null { + try { + return parseJsonc(FS.readFileSync(filePath, "utf8")); + } catch { + return null; + } +} + +function safeResolveChild(root: string, relativePath: string): string | null { + const resolved = Path.resolve(root, relativePath); + const normalizedRoot = Path.resolve(root); + if (resolved === normalizedRoot || resolved.startsWith(`${normalizedRoot}${Path.sep}`)) { + return resolved; + } + return null; +} + +export function resolveEditorRoots(input: { + readonly platform: NodeJS.Platform; + readonly homedir: string; + readonly appData?: string; +}): readonly EditorRoot[] { + const home = input.homedir; + if (input.platform === "win32") { + const appData = input.appData ?? Path.join(home, "AppData", "Roaming"); + return [ + { + source: "vscode", + extensionsPath: Path.join(home, ".vscode", "extensions"), + settingsPath: Path.join(appData, "Code", "User", "settings.json"), + }, + { + source: "vscode-insiders", + extensionsPath: Path.join(home, ".vscode-insiders", "extensions"), + settingsPath: Path.join(appData, "Code - Insiders", "User", "settings.json"), + }, + { + source: "cursor", + extensionsPath: Path.join(home, ".cursor", "extensions"), + settingsPath: Path.join(appData, "Cursor", "User", "settings.json"), + }, + ]; + } + + if (input.platform === "darwin") { + return [ + { + source: "vscode", + extensionsPath: Path.join(home, ".vscode", "extensions"), + settingsPath: Path.join( + home, + "Library", + "Application Support", + "Code", + "User", + "settings.json", + ), + }, + { + source: "vscode-insiders", + extensionsPath: Path.join(home, ".vscode-insiders", "extensions"), + settingsPath: Path.join( + home, + "Library", + "Application Support", + "Code - Insiders", + "User", + "settings.json", + ), + }, + { + source: "cursor", + extensionsPath: Path.join(home, ".cursor", "extensions"), + settingsPath: Path.join( + home, + "Library", + "Application Support", + "Cursor", + "User", + "settings.json", + ), + }, + ]; + } + + return [ + { + source: "vscode", + extensionsPath: Path.join(home, ".vscode", "extensions"), + settingsPath: Path.join(home, ".config", "Code", "User", "settings.json"), + }, + { + source: "vscode-insiders", + extensionsPath: Path.join(home, ".vscode-insiders", "extensions"), + settingsPath: Path.join(home, ".config", "Code - Insiders", "User", "settings.json"), + }, + { + source: "cursor", + extensionsPath: Path.join(home, ".cursor", "extensions"), + settingsPath: Path.join(home, ".config", "Cursor", "User", "settings.json"), + }, + ]; +} + +function resolveDefaultEditorRoots() { + const input: { platform: NodeJS.Platform; homedir: string; appData?: string } = { + platform: process.platform, + homedir: OS.homedir(), + }; + if (process.env.APPDATA) input.appData = process.env.APPDATA; + return resolveEditorRoots(input); +} + +function readEditorSettings(settingsPath: string): Record { + const parsed = readJsonOrJsoncFile(settingsPath); + return isObject(parsed) ? parsed : {}; +} + +export function getEditorThemePreferences( + roots = resolveDefaultEditorRoots(), +): readonly EditorThemePreference[] { + return roots.map((root) => { + const settings = readEditorSettings(root.settingsPath); + const customizations = settings["workbench.colorCustomizations"]; + return { + source: root.source, + colorTheme: + typeof settings["workbench.colorTheme"] === "string" + ? settings["workbench.colorTheme"] + : null, + preferredLightColorTheme: + typeof settings["workbench.preferredLightColorTheme"] === "string" + ? settings["workbench.preferredLightColorTheme"] + : null, + preferredDarkColorTheme: + typeof settings["workbench.preferredDarkColorTheme"] === "string" + ? settings["workbench.preferredDarkColorTheme"] + : null, + autoDetectColorScheme: settings["window.autoDetectColorScheme"] === true, + workbenchColorCustomizations: isObject(customizations) ? customizations : {}, + editorTokenColorCustomizations: settings["editor.tokenColorCustomizations"], + }; + }); +} + +export function discoverEditorColorThemes( + roots = resolveDefaultEditorRoots(), +): readonly DiscoveredColorTheme[] { + const themes: DiscoveredColorTheme[] = []; + + for (const root of roots) { + if (!FS.existsSync(root.extensionsPath)) continue; + + for (const entry of FS.readdirSync(root.extensionsPath, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + + const extensionDir = Path.join(root.extensionsPath, entry.name); + const packagePath = Path.join(extensionDir, "package.json"); + const packageJson = readJsonOrJsoncFile(packagePath); + if (!isObject(packageJson)) continue; + + const contributes = packageJson.contributes; + const contributedThemes = isObject(contributes) ? contributes.themes : null; + if (!Array.isArray(contributedThemes)) continue; + + for (const theme of contributedThemes) { + if (!isObject(theme) || typeof theme.label !== "string" || typeof theme.path !== "string") { + continue; + } + + const themePath = safeResolveChild(extensionDir, theme.path); + if (!themePath || !FS.existsSync(themePath)) continue; + + themes.push({ + id: `${root.source}:${entry.name}:${theme.label}`, + source: root.source, + extensionId: entry.name, + extensionDisplayName: + typeof packageJson.displayName === "string" ? packageJson.displayName : undefined, + label: theme.label, + kind: kindFromUiTheme(theme.uiTheme), + themePath, + packagePath, + publisher: typeof packageJson.publisher === "string" ? packageJson.publisher : undefined, + }); + } + } + } + + return themes.toSorted((a, b) => a.label.localeCompare(b.label)); +} + +function mergeWorkbenchOverrides(input: { + readonly themeLabel: string; + readonly themeColors: Record; + readonly customizations: Record; +}) { + const next = { ...input.themeColors }; + + for (const [key, rawValue] of Object.entries(input.customizations)) { + if (key.startsWith("[") && key.endsWith("]")) continue; + const color = normalizeCssColor(rawValue); + if (color) next[key] = color; + } + + const scoped = input.customizations[`[${input.themeLabel}]`]; + if (isObject(scoped)) { + for (const [key, rawValue] of Object.entries(scoped)) { + const color = normalizeCssColor(rawValue); + if (color) next[key] = color; + } + } + + return next; +} + +export function loadEditorColorTheme( + themeId: string, + roots = resolveDefaultEditorRoots(), +): ResolvedColorTheme | null { + const themes = discoverEditorColorThemes(roots); + const theme = themes.find((entry) => entry.id === themeId); + if (!theme) return null; + + const rawTheme = readJsonOrJsoncFile(theme.themePath); + if (!isObject(rawTheme)) return null; + + const preferences = getEditorThemePreferences(roots); + const preference = preferences.find((entry) => entry.source === theme.source); + const colors = mergeWorkbenchOverrides({ + themeLabel: theme.label, + themeColors: normalizeThemeColors(rawTheme.colors), + customizations: preference?.workbenchColorCustomizations ?? {}, + }); + + return { + id: theme.id, + label: theme.label, + source: theme.source, + kind: theme.kind, + colors, + tokenColors: rawTheme.tokenColors ?? [], + semanticHighlighting: + typeof rawTheme.semanticHighlighting === "boolean" + ? rawTheme.semanticHighlighting + : undefined, + semanticTokenColors: rawTheme.semanticTokenColors, + appVariables: mapVscodeColorsToAppVariables({ kind: theme.kind, colors }), + }; +} diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index acbaba99fa8..6ce88d635bd 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -22,8 +22,8 @@ import { VscodeEntryIcon } from "./chat/VscodeEntryIcon"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { openInPreferredEditor } from "../editorPreferences"; -import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; +import { resolveSyntaxThemeName } from "../lib/syntaxTheme"; import { LRUCache } from "../lib/lruCache"; import { useTheme } from "../hooks/useTheme"; import { @@ -112,7 +112,7 @@ function extractCodeBlock( }; } -function createHighlightCacheKey(code: string, language: string, themeName: DiffThemeName): string { +function createHighlightCacheKey(code: string, language: string, themeName: string): string { return `${fnv1a32(code).toString(36)}:${code.length}:${language}:${themeName}`; } @@ -120,24 +120,25 @@ function estimateHighlightedSize(html: string, code: string): number { return Math.max(html.length * 2, code.length * 3); } -function getHighlighterPromise(language: string): Promise { - const cached = highlighterPromiseCache.get(language); +function getHighlighterPromise(language: string, themeName: string): Promise { + const cacheKey = `${language}:${themeName}`; + const cached = highlighterPromiseCache.get(cacheKey); if (cached) return cached; const promise = getSharedHighlighter({ - themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")], + themes: [themeName], langs: [language as SupportedLanguages], preferredHighlighter: "shiki-js", }).catch((err) => { - highlighterPromiseCache.delete(language); + highlighterPromiseCache.delete(cacheKey); if (language === "text") { // "text" itself failed — Shiki cannot initialize at all, surface the error throw err; } // Language not supported by Shiki — fall back to "text" - return getHighlighterPromise("text"); + return getHighlighterPromise("text", themeName); }); - highlighterPromiseCache.set(language, promise); + highlighterPromiseCache.set(cacheKey, promise); return promise; } @@ -192,7 +193,7 @@ function MarkdownCodeBlock({ code, children }: { code: string; children: ReactNo interface SuspenseShikiCodeBlockProps { className: string | undefined; code: string; - themeName: DiffThemeName; + themeName: string; isStreaming: boolean; } @@ -229,7 +230,7 @@ function SuspenseShikiCodeBlock({ interface UncachedShikiCodeBlockProps { code: string; language: string; - themeName: DiffThemeName; + themeName: string; cacheKey: string; isStreaming: boolean; } @@ -241,7 +242,7 @@ function UncachedShikiCodeBlock({ cacheKey, isStreaming, }: UncachedShikiCodeBlockProps) { - const highlighter = use(getHighlighterPromise(language)); + const highlighter = use(getHighlighterPromise(language, themeName)); const highlightedHtml = useMemo(() => { try { return highlighter.codeToHtml(code, { lang: language, theme: themeName }); @@ -508,8 +509,11 @@ function areMarkdownFileLinkPropsEqual( } function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { - const { resolvedTheme } = useTheme(); - const diffThemeName = resolveDiffThemeName(resolvedTheme); + const { resolvedTheme, resolvedColorTheme } = useTheme(); + const codeBlockThemeName = useMemo( + () => resolveSyntaxThemeName({ resolvedTheme, resolvedColorTheme }), + [resolvedColorTheme, resolvedTheme], + ); const markdownFileLinkMetaByHref = useMemo(() => { const metaByHref = new Map< string, @@ -577,7 +581,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { @@ -587,7 +591,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { }, }), [ - diffThemeName, + codeBlockThemeName, fileLinkParentSuffixByPath, isStreaming, markdownFileLinkMetaByHref, diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index f178a69fb43..f585d6ef636 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -30,7 +30,7 @@ import { resolvePathLinkTarget } from "../terminal-links"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { useTheme } from "../hooks/useTheme"; import { buildPatchCacheKey } from "../lib/diffRendering"; -import { resolveDiffThemeName } from "../lib/diffRendering"; +import { resolveSyntaxThemeName } from "../lib/syntaxTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { selectProjectByRef, useStore } from "../store"; import { createThreadSelectorByRef } from "../storeSelectors"; @@ -185,8 +185,12 @@ export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const navigate = useNavigate(); - const { resolvedTheme } = useTheme(); + const { resolvedTheme, resolvedColorTheme } = useTheme(); const settings = useSettings(); + const diffThemeName = useMemo( + () => resolveSyntaxThemeName({ resolvedTheme, resolvedColorTheme }), + [resolvedColorTheme, resolvedTheme], + ); const [diffRenderMode, setDiffRenderMode] = useState("stacked"); const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap); const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace); @@ -321,8 +325,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const hasResolvedPatch = typeof selectedPatch === "string"; const hasNoNetChanges = hasResolvedPatch && selectedPatch.trim().length === 0; const renderablePatch = useMemo( - () => getRenderablePatch(selectedPatch, `diff-panel:${resolvedTheme}`), - [resolvedTheme, selectedPatch], + () => getRenderablePatch(selectedPatch, `diff-panel:${diffThemeName}`), + [diffThemeName, selectedPatch], ); const renderableFiles = useMemo(() => { if (!renderablePatch || renderablePatch.kind !== "files") { @@ -662,7 +666,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { {renderableFiles.map((fileDiff) => { const filePath = resolveFileDiffPath(fileDiff); const fileKey = buildFileDiffRenderKey(fileDiff); - const themedFileKey = `${fileKey}:${resolvedTheme}`; + const themedFileKey = `${fileKey}:${diffThemeName}`; const collapsed = collapsedDiffFileKeys.has(fileKey); return (
{ @@ -29,8 +30,11 @@ function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) { } export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { - const { resolvedTheme } = useTheme(); - const diffThemeName = resolveDiffThemeName(resolvedTheme); + const { resolvedTheme, resolvedColorTheme } = useTheme(); + const diffThemeName = useMemo( + () => resolveSyntaxThemeName({ resolvedTheme, resolvedColorTheme }), + [resolvedColorTheme, resolvedTheme], + ); const workerPoolSize = useMemo(() => { const cores = typeof navigator === "undefined" ? 4 : Math.max(1, navigator.hardwareConcurrency || 4); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 6c71e5eb334..42523ea3a0b 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -120,13 +120,30 @@ function terminalThemeFromApp(mountElement?: HTMLElement | null): ITheme { drawerStyles.color, normalizeComputedColor(bodyStyles.color, fallbackForeground), ); + const rootStyles = getComputedStyle(document.documentElement); + const themedBackground = normalizeComputedColor( + rootStyles.getPropertyValue("--terminal-background"), + background, + ); + const themedForeground = normalizeComputedColor( + rootStyles.getPropertyValue("--terminal-foreground"), + foreground, + ); + const themedCursor = normalizeComputedColor( + rootStyles.getPropertyValue("--terminal-cursor"), + isDark ? "rgb(180, 203, 255)" : "rgb(38, 56, 78)", + ); + const themedSelection = normalizeComputedColor( + rootStyles.getPropertyValue("--terminal-selection-background"), + isDark ? "rgba(180, 203, 255, 0.25)" : "rgba(37, 63, 99, 0.2)", + ); if (isDark) { return { - background, - foreground, - cursor: "rgb(180, 203, 255)", - selectionBackground: "rgba(180, 203, 255, 0.25)", + background: themedBackground, + foreground: themedForeground, + cursor: themedCursor, + selectionBackground: themedSelection, scrollbarSliderBackground: "rgba(255, 255, 255, 0.1)", scrollbarSliderHoverBackground: "rgba(255, 255, 255, 0.18)", scrollbarSliderActiveBackground: "rgba(255, 255, 255, 0.22)", @@ -150,10 +167,10 @@ function terminalThemeFromApp(mountElement?: HTMLElement | null): ITheme { } return { - background, - foreground, - cursor: "rgb(38, 56, 78)", - selectionBackground: "rgba(37, 63, 99, 0.2)", + background: themedBackground, + foreground: themedForeground, + cursor: themedCursor, + selectionBackground: themedSelection, scrollbarSliderBackground: "rgba(0, 0, 0, 0.15)", scrollbarSliderHoverBackground: "rgba(0, 0, 0, 0.25)", scrollbarSliderActiveBackground: "rgba(0, 0, 0, 0.3)", diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 9e3d52e89c9..d1c58c216d2 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -427,7 +427,11 @@ const createDesktopBridgeStub = (overrides?: { getAdvertisedEndpoints: vi.fn().mockResolvedValue(overrides?.advertisedEndpoints ?? []), pickFolder: vi.fn().mockResolvedValue(null), confirm: vi.fn().mockResolvedValue(false), + discoverColorThemes: vi.fn().mockResolvedValue([]), + loadColorTheme: vi.fn().mockResolvedValue(null), + getEditorThemePreferences: vi.fn().mockResolvedValue([]), setTheme: vi.fn().mockResolvedValue(undefined), + setWindowThemeColors: vi.fn().mockResolvedValue(undefined), showContextMenu: vi.fn().mockResolvedValue(null), openExternal: vi.fn().mockResolvedValue(true), onMenuAction: () => () => {}, diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index ee75fba5d06..72246a797ce 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -10,6 +10,7 @@ import { type ProviderInstanceConfig, type ProviderInstanceId, type ScopedThreadRef, + type ThemePreference, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; @@ -95,6 +96,26 @@ const THEME_OPTIONS = [ }, ] as const; +function themePreferenceToSelectValue(preference: ThemePreference) { + if (preference.mode === "system") return "system"; + if (preference.mode === "builtin") return `builtin:${preference.theme}`; + if (preference.mode === "external") return `external:${preference.themeId}`; + return `follow-editor:${preference.source}`; +} + +function selectValueToThemePreference(value: string): ThemePreference | null { + if (value === "system") return { mode: "system" }; + if (value === "builtin:light") return { mode: "builtin", theme: "light" }; + if (value === "builtin:dark") return { mode: "builtin", theme: "dark" }; + if (value.startsWith("external:")) return { mode: "external", themeId: value.slice(9) }; + if (value === "follow-editor:vscode") return { mode: "follow-editor", source: "vscode" }; + if (value === "follow-editor:cursor") return { mode: "follow-editor", source: "cursor" }; + if (value === "follow-editor:vscode-insiders") { + return { mode: "follow-editor", source: "vscode-insiders" }; + } + return null; +} + const TIMESTAMP_FORMAT_LABELS = { locale: "System default", "12-hour": "12-hour", @@ -438,7 +459,16 @@ export function useSettingsRestore(onRestored?: () => void) { } export function GeneralSettingsPanel() { - const { theme, setTheme } = useTheme(); + const { + theme, + preference, + setTheme, + setThemePreference, + discoveredThemes, + refreshThemes, + status: themeStatus, + message: themeMessage, + } = useTheme(); const settings = useSettings(); const { updateSettings } = useUpdateSettings(); const observability = useServerObservability(); @@ -479,34 +509,67 @@ export function GeneralSettingsPanel() { 0 + ? `Detected ${discoveredThemes.length} themes from VS Code and Cursor.` + : "Choose how T3 Code looks across the app.") + } resetAction={ theme !== "system" ? ( setTheme("system")} /> ) : null } control={ - { + if (!value) return; + const nextPreference = selectValueToThemePreference(value); + if (nextPreference) setThemePreference(nextPreference); + }} + > + + + {preference.mode === "external" + ? (discoveredThemes.find((entry) => entry.id === preference.themeId)?.label ?? + "External theme") + : (THEME_OPTIONS.find((option) => option.value === theme)?.label ?? "System")} + + + + + System - ))} - - + + Light + + + Dark + + {discoveredThemes.map((entry) => ( + + {entry.source === "cursor" ? "Cursor" : "VS Code"} · {entry.label} + + ))} + + +
} /> diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index cd254c97548..99815ac9782 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -1,23 +1,44 @@ import { useCallback, useEffect, useSyncExternalStore } from "react"; +import type { + DesktopTheme, + DiscoveredColorTheme, + ResolvedColorTheme, + ThemePreference, +} from "@t3tools/contracts"; +import { DEFAULT_THEME_PREFERENCE } from "@t3tools/contracts"; +import { EXTERNAL_APP_THEME_VARIABLES, isDarkThemeKind } from "@t3tools/shared/themeMapping"; -type Theme = "light" | "dark" | "system"; +type BuiltInTheme = "light" | "dark" | "system"; +type ThemeStatus = "idle" | "loading" | "ready" | "error"; type ThemeSnapshot = { - theme: Theme; + preference: ThemePreference; systemDark: boolean; + resolvedKind: "light" | "dark"; + resolvedColorTheme: ResolvedColorTheme | null; + discoveredThemes: readonly DiscoveredColorTheme[]; + status: ThemeStatus; + message: string | null; }; const STORAGE_KEY = "t3code:theme"; +const PREFERENCE_STORAGE_KEY = "t3code:theme-preference:v1"; +const BOOTSTRAP_THEME_CACHE_KEY = "t3code:resolved-theme-cache:v1"; const MEDIA_QUERY = "(prefers-color-scheme: dark)"; -const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = { - theme: "system", - systemDark: false, -}; const THEME_COLOR_META_NAME = "theme-color"; const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`; let listeners: Array<() => void> = []; let lastSnapshot: ThemeSnapshot | null = null; -let lastDesktopTheme: Theme | null = null; +let lastDesktopTheme: DesktopTheme | null = null; +let state: ThemeSnapshot = { + preference: DEFAULT_THEME_PREFERENCE, + systemDark: false, + resolvedKind: "light", + resolvedColorTheme: null, + discoveredThemes: [], + status: "idle", + message: null, +}; function emitChange() { for (const listener of listeners) listener(); @@ -31,18 +52,68 @@ function getSystemDark() { return typeof window !== "undefined" && window.matchMedia(MEDIA_QUERY).matches; } -function getStored(): Theme { - if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT.theme; - const raw = localStorage.getItem(STORAGE_KEY); - if (raw === "light" || raw === "dark" || raw === "system") return raw; - return DEFAULT_THEME_SNAPSHOT.theme; +function builtInToPreference(theme: BuiltInTheme): ThemePreference { + return theme === "system" ? { mode: "system" } : { mode: "builtin", theme }; +} + +function preferenceToBuiltInTheme(preference: ThemePreference): BuiltInTheme { + if (preference.mode === "builtin") return preference.theme; + return "system"; +} + +function parsePreference(value: string | null): ThemePreference | null { + if (!value) return null; + try { + const parsed = JSON.parse(value) as unknown; + if (typeof parsed !== "object" || parsed === null) return null; + if ("mode" in parsed && parsed.mode === "system") return { mode: "system" }; + if ("mode" in parsed && parsed.mode === "builtin" && "theme" in parsed) { + if (parsed.theme === "light" || parsed.theme === "dark") { + return { mode: "builtin", theme: parsed.theme }; + } + } + if ("mode" in parsed && parsed.mode === "external" && "themeId" in parsed) { + return typeof parsed.themeId === "string" + ? { mode: "external", themeId: parsed.themeId } + : null; + } + if ("mode" in parsed && parsed.mode === "follow-editor" && "source" in parsed) { + if ( + parsed.source === "vscode" || + parsed.source === "cursor" || + parsed.source === "vscode-insiders" + ) { + return { mode: "follow-editor", source: parsed.source }; + } + } + } catch { + return null; + } + return null; +} + +function getStoredPreference(): ThemePreference { + if (!hasThemeStorage()) return DEFAULT_THEME_PREFERENCE; + const savedPreference = parsePreference(localStorage.getItem(PREFERENCE_STORAGE_KEY)); + if (savedPreference) return savedPreference; + const legacy = localStorage.getItem(STORAGE_KEY); + if (legacy === "light" || legacy === "dark" || legacy === "system") { + const migrated = builtInToPreference(legacy); + localStorage.setItem(PREFERENCE_STORAGE_KEY, JSON.stringify(migrated)); + return migrated; + } + return DEFAULT_THEME_PREFERENCE; +} + +function setStoredPreference(preference: ThemePreference) { + if (!hasThemeStorage()) return; + localStorage.setItem(PREFERENCE_STORAGE_KEY, JSON.stringify(preference)); + localStorage.setItem(STORAGE_KEY, preferenceToBuiltInTheme(preference)); } function ensureThemeColorMetaTag(): HTMLMetaElement { let element = document.querySelector(DYNAMIC_THEME_COLOR_SELECTOR); - if (element) { - return element; - } + if (element) return element; element = document.createElement("meta"); element.name = THEME_COLOR_META_NAME; @@ -87,79 +158,214 @@ export function syncBrowserChromeTheme() { ensureThemeColorMetaTag().setAttribute("content", backgroundColor); } -function applyTheme(theme: Theme, suppressTransitions = false) { +function clearExternalThemeVariables(root: HTMLElement) { + for (const name of EXTERNAL_APP_THEME_VARIABLES) { + root.style.removeProperty(name); + } +} + +function syncDesktopTheme(theme: DesktopTheme) { + if (typeof window === "undefined") return; + const bridge = window.desktopBridge; + if (!bridge || lastDesktopTheme === theme) return; + + lastDesktopTheme = theme; + void bridge.setTheme(theme).catch(() => { + if (lastDesktopTheme === theme) lastDesktopTheme = null; + }); +} + +function syncDesktopWindowColors(theme: ResolvedColorTheme | null) { + if (typeof window === "undefined") return; + const bridge = window.desktopBridge; + if (!bridge || !theme) return; + const backgroundColor = + theme.appVariables["--app-chrome-background"] ?? + theme.appVariables["--background"] ?? + (isDarkThemeKind(theme.kind) ? "#0a0a0a" : "#ffffff"); + void bridge + .setWindowThemeColors({ + backgroundColor, + titleBarColor: backgroundColor, + titleBarSymbolColor: theme.appVariables["--foreground"], + }) + .catch(() => {}); +} + +function applyResolvedTheme(theme: ResolvedColorTheme | null, suppressTransitions = false) { if (typeof document === "undefined" || typeof window === "undefined") return; - if (suppressTransitions) { - document.documentElement.classList.add("no-transitions"); + const root = document.documentElement; + if (suppressTransitions) root.classList.add("no-transitions"); + + if (theme) { + for (const [name, value] of Object.entries(theme.appVariables)) { + root.style.setProperty(name, value); + } + } else { + clearExternalThemeVariables(root); } - const isDark = theme === "dark" || (theme === "system" && getSystemDark()); - document.documentElement.classList.toggle("dark", isDark); + + const preference = state.preference; + const isDark = theme + ? isDarkThemeKind(theme.kind) + : preference.mode === "builtin" + ? preference.theme === "dark" + : getSystemDark(); + root.classList.toggle("dark", isDark); + root.dataset.themeSource = theme?.source ?? "builtin"; + root.dataset.themeId = theme?.id ?? ""; syncBrowserChromeTheme(); - syncDesktopTheme(theme); + syncDesktopTheme( + theme + ? isDark + ? "dark" + : "light" + : preference.mode === "system" + ? "system" + : isDark + ? "dark" + : "light", + ); + syncDesktopWindowColors(theme); + if (suppressTransitions) { - // Force a reflow so the no-transitions class takes effect before removal // oxlint-disable-next-line no-unused-expressions - document.documentElement.offsetHeight; - requestAnimationFrame(() => { - document.documentElement.classList.remove("no-transitions"); - }); + root.offsetHeight; + requestAnimationFrame(() => root.classList.remove("no-transitions")); } } -function syncDesktopTheme(theme: Theme) { - if (typeof window === "undefined") return; - const bridge = window.desktopBridge; - if (!bridge || lastDesktopTheme === theme) { +function recomputeSnapshot(next: Partial = {}) { + const preference = next.preference ?? state.preference; + const systemDark = getSystemDark(); + const resolvedTheme = + next.resolvedColorTheme === undefined ? state.resolvedColorTheme : next.resolvedColorTheme; + const resolvedKind: "light" | "dark" = resolvedTheme + ? isDarkThemeKind(resolvedTheme.kind) + ? "dark" + : "light" + : preference.mode === "builtin" + ? preference.theme + : systemDark + ? "dark" + : "light"; + + state = { + ...state, + ...next, + preference, + systemDark, + resolvedKind, + resolvedColorTheme: resolvedTheme, + }; + lastSnapshot = null; + emitChange(); +} + +async function loadPreference(preference: ThemePreference, suppressTransitions = true) { + if (preference.mode === "external") { + const bridge = window.desktopBridge; + if (!bridge) { + recomputeSnapshot({ + preference, + resolvedColorTheme: null, + status: "error", + message: "Desktop theme discovery is unavailable in browser mode.", + }); + applyResolvedTheme(null, suppressTransitions); + return; + } + + recomputeSnapshot({ preference, status: "loading", message: null }); + const theme = await bridge.loadColorTheme(preference.themeId); + if (!theme) { + recomputeSnapshot({ + preference: DEFAULT_THEME_PREFERENCE, + resolvedColorTheme: null, + status: "error", + message: "Selected theme is unavailable. Falling back to System.", + }); + applyResolvedTheme(null, suppressTransitions); + return; + } + localStorage.setItem( + BOOTSTRAP_THEME_CACHE_KEY, + JSON.stringify({ id: theme.id, kind: theme.kind, appVariables: theme.appVariables }), + ); + recomputeSnapshot({ preference, resolvedColorTheme: theme, status: "ready", message: null }); + applyResolvedTheme(theme, suppressTransitions); return; } - lastDesktopTheme = theme; - void bridge.setTheme(theme).catch(() => { - if (lastDesktopTheme === theme) { - lastDesktopTheme = null; - } - }); + recomputeSnapshot({ preference, resolvedColorTheme: null, status: "ready", message: null }); + applyResolvedTheme(null, suppressTransitions); } -// Apply immediately on module load to prevent flash -if (typeof document !== "undefined" && hasThemeStorage()) { - applyTheme(getStored()); +async function refreshThemes() { + const bridge = typeof window !== "undefined" ? window.desktopBridge : null; + if (!bridge) { + recomputeSnapshot({ + discoveredThemes: [], + status: "ready", + message: "Open the desktop app to use VS Code and Cursor themes.", + }); + return; + } + recomputeSnapshot({ status: "loading", message: null }); + try { + const discoveredThemes = await bridge.discoverColorThemes(); + recomputeSnapshot({ discoveredThemes, status: "ready", message: null }); + } catch { + recomputeSnapshot({ status: "error", message: "Could not refresh editor themes." }); + } } -function getSnapshot(): ThemeSnapshot { - if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT; - const theme = getStored(); - const systemDark = theme === "system" ? getSystemDark() : false; +function bootstrapCachedTheme() { + if (!hasThemeStorage()) return null; + const raw = localStorage.getItem(BOOTSTRAP_THEME_CACHE_KEY); + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as Partial; + if (!parsed.id || !parsed.kind || !parsed.appVariables) return null; + return parsed as ResolvedColorTheme; + } catch { + return null; + } +} - if (lastSnapshot && lastSnapshot.theme === theme && lastSnapshot.systemDark === systemDark) { - return lastSnapshot; +if (typeof document !== "undefined" && hasThemeStorage()) { + const preference = getStoredPreference(); + state.preference = preference; + if (preference.mode === "external") { + state.resolvedColorTheme = bootstrapCachedTheme(); } + applyResolvedTheme(state.resolvedColorTheme); +} - lastSnapshot = { theme, systemDark }; +function getSnapshot(): ThemeSnapshot { + if (lastSnapshot) return lastSnapshot; + lastSnapshot = state; return lastSnapshot; } function getServerSnapshot() { - return DEFAULT_THEME_SNAPSHOT; + return state; } function subscribe(listener: () => void): () => void { if (typeof window === "undefined") return () => {}; listeners.push(listener); - // Listen for system preference changes const mq = window.matchMedia(MEDIA_QUERY); const handleChange = () => { - if (getStored() === "system") applyTheme("system", true); - emitChange(); + recomputeSnapshot(); + applyResolvedTheme(state.resolvedColorTheme, true); }; mq.addEventListener("change", handleChange); - // Listen for storage changes from other tabs const handleStorage = (e: StorageEvent) => { - if (e.key === STORAGE_KEY) { - applyTheme(getStored(), true); - emitChange(); + if (e.key === STORAGE_KEY || e.key === PREFERENCE_STORAGE_KEY) { + void loadPreference(getStoredPreference(), true); } }; window.addEventListener("storage", handleStorage); @@ -173,22 +379,34 @@ function subscribe(listener: () => void): () => void { export function useTheme() { const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); - const theme = snapshot.theme; - const resolvedTheme: "light" | "dark" = - theme === "system" ? (snapshot.systemDark ? "dark" : "light") : theme; - - const setTheme = useCallback((next: Theme) => { - if (!hasThemeStorage()) return; - localStorage.setItem(STORAGE_KEY, next); - applyTheme(next, true); - emitChange(); + const setThemePreference = useCallback((preference: ThemePreference) => { + setStoredPreference(preference); + void loadPreference(preference, true); }, []); - // Keep DOM in sync on mount/change + const setTheme = useCallback( + (next: BuiltInTheme) => { + setThemePreference(builtInToPreference(next)); + }, + [setThemePreference], + ); + useEffect(() => { - applyTheme(theme); - }, [theme]); + void refreshThemes(); + void loadPreference(getStoredPreference(), false); + }, []); - return { theme, setTheme, resolvedTheme } as const; + return { + theme: preferenceToBuiltInTheme(snapshot.preference), + setTheme, + preference: snapshot.preference, + setThemePreference, + resolvedTheme: snapshot.resolvedKind, + resolvedColorTheme: snapshot.resolvedColorTheme, + discoveredThemes: snapshot.discoveredThemes, + refreshThemes, + status: snapshot.status, + message: snapshot.message, + } as const; } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 0f0417d438f..8f1275a1e85 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -31,6 +31,11 @@ --color-card: var(--card); --color-foreground: var(--foreground); --color-background: var(--background); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); @@ -112,6 +117,19 @@ --success-foreground: var(--color-emerald-700); --warning: var(--color-amber-500); --warning-foreground: var(--color-amber-700); + --sidebar: var(--background); + --sidebar-foreground: var(--foreground); + --sidebar-accent: var(--accent); + --sidebar-accent-foreground: var(--accent-foreground); + --sidebar-border: var(--border); + --terminal-background: var(--background); + --terminal-foreground: var(--foreground); + --terminal-cursor: var(--ring); + --terminal-selection-background: color-mix(in srgb, var(--ring) 24%, transparent); + --diff-inserted: var(--success); + --diff-removed: var(--destructive); + --chat-request-background: var(--card); + --chat-request-border: var(--border); @variant dark { color-scheme: dark; @@ -141,6 +159,19 @@ --success-foreground: var(--color-emerald-400); --warning: var(--color-amber-500); --warning-foreground: var(--color-amber-400); + --sidebar: var(--background); + --sidebar-foreground: var(--foreground); + --sidebar-accent: var(--accent); + --sidebar-accent-foreground: var(--accent-foreground); + --sidebar-border: var(--border); + --terminal-background: var(--background); + --terminal-foreground: var(--foreground); + --terminal-cursor: var(--ring); + --terminal-selection-background: color-mix(in srgb, var(--ring) 24%, transparent); + --diff-inserted: var(--success); + --diff-removed: var(--destructive); + --chat-request-background: var(--card); + --chat-request-border: var(--border); } } diff --git a/apps/web/src/lib/syntaxTheme.test.ts b/apps/web/src/lib/syntaxTheme.test.ts new file mode 100644 index 00000000000..1821d1abd78 --- /dev/null +++ b/apps/web/src/lib/syntaxTheme.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import type { ResolvedColorTheme } from "@t3tools/contracts"; +import { resolveCodeBlockThemeName, resolveSyntaxThemeName } from "./syntaxTheme"; + +function makeTheme(overrides: Partial = {}): ResolvedColorTheme { + return { + id: "vscode:github.github-vscode-theme:GitHub Dark Default", + label: "GitHub Dark Default", + source: "vscode", + kind: "dark", + colors: { + "editor.background": "#0d1117", + "editor.foreground": "#e6edf3", + }, + tokenColors: [ + { + scope: "keyword", + settings: { + foreground: "#ff7b72", + }, + }, + ], + appVariables: { + "--background": "#0d1117", + "--foreground": "#e6edf3", + }, + ...overrides, + }; +} + +describe("syntaxTheme", () => { + it("falls back to built-in diff themes without an external theme", () => { + expect(resolveSyntaxThemeName({ resolvedTheme: "dark", resolvedColorTheme: null })).toBe( + "pierre-dark", + ); + expect(resolveSyntaxThemeName({ resolvedTheme: "light", resolvedColorTheme: null })).toBe( + "pierre-light", + ); + }); + + it("creates stable content-addressed custom syntax theme names", () => { + const theme = makeTheme(); + expect(resolveSyntaxThemeName({ resolvedTheme: "dark", resolvedColorTheme: theme })).toBe( + resolveSyntaxThemeName({ resolvedTheme: "dark", resolvedColorTheme: theme }), + ); + expect( + resolveSyntaxThemeName({ + resolvedTheme: "dark", + resolvedColorTheme: makeTheme({ + tokenColors: [{ scope: "keyword", settings: { foreground: "#79c0ff" } }], + }), + }), + ).not.toBe(resolveSyntaxThemeName({ resolvedTheme: "dark", resolvedColorTheme: theme })); + }); + + it("keeps the code block resolver as an alias of the shared syntax resolver", () => { + const theme = makeTheme(); + expect(resolveCodeBlockThemeName({ resolvedTheme: "dark", resolvedColorTheme: theme })).toBe( + resolveSyntaxThemeName({ resolvedTheme: "dark", resolvedColorTheme: theme }), + ); + }); + + it("handles bootstrap-cached external themes before full colors load", () => { + const cachedTheme = { + id: "vscode:github.github-vscode-theme:GitHub Dark Default", + kind: "dark", + appVariables: { + "--background": "#0d1117", + "--foreground": "#e6edf3", + }, + } satisfies Partial; + + expect( + resolveSyntaxThemeName({ resolvedTheme: "dark", resolvedColorTheme: cachedTheme }), + ).toMatch(/^t3-syntax-/); + }); +}); diff --git a/apps/web/src/lib/syntaxTheme.ts b/apps/web/src/lib/syntaxTheme.ts new file mode 100644 index 00000000000..c962672cb17 --- /dev/null +++ b/apps/web/src/lib/syntaxTheme.ts @@ -0,0 +1,159 @@ +import { + registerCustomTheme, + type DiffsThemeNames, + type ThemeRegistrationResolved, +} from "@pierre/diffs"; +import type { ResolvedColorTheme } from "@t3tools/contracts"; +import { isDarkThemeKind } from "@t3tools/shared/themeMapping"; +import { resolveDiffThemeName } from "./diffRendering"; + +type BuiltInResolvedTheme = "light" | "dark"; +type SyntaxColorTheme = Partial; +type ThemeSetting = ThemeRegistrationResolved["settings"][number]; +type ThemeSettingOptions = ThemeSetting["settings"]; + +const registeredSyntaxThemes = new Set(); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stableStringify(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; + } + if (isRecord(value)) { + return `{${Object.entries(value) + .toSorted(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)) + .map(([key, entry]) => `${JSON.stringify(key)}:${stableStringify(entry)}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function normalizeStringRecord(value: unknown): Record { + if (!isRecord(value)) return {}; + + const normalized: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (typeof entry === "string") { + normalized[key] = entry; + } + } + return normalized; +} + +function hashString(input: string) { + let hash = 0x811c9dc5; + for (let index = 0; index < input.length; index += 1) { + hash ^= input.charCodeAt(index); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash.toString(36); +} + +function normalizeScopes(value: unknown): ThemeSetting["scope"] | undefined { + if (typeof value === "string") return value; + if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) { + return value; + } + return undefined; +} + +function normalizeThemeSettings(value: unknown): ThemeSettingOptions | null { + if (!isRecord(value)) return null; + + const settings: ThemeSettingOptions = { + ...(typeof value.foreground === "string" ? { foreground: value.foreground } : {}), + ...(typeof value.background === "string" ? { background: value.background } : {}), + ...(typeof value.fontStyle === "string" ? { fontStyle: value.fontStyle } : {}), + }; + + return Object.keys(settings).length > 0 ? settings : null; +} + +function normalizeTokenColors(value: unknown): ThemeRegistrationResolved["settings"] { + if (!Array.isArray(value)) return []; + + const settings: ThemeRegistrationResolved["settings"] = []; + for (const entry of value) { + if (!isRecord(entry)) continue; + const tokenSettings = normalizeThemeSettings(entry.settings); + if (!tokenSettings) continue; + + const scope = normalizeScopes(entry.scope); + const next: ThemeSetting = { + ...(scope ? { scope } : {}), + ...(typeof entry.name === "string" ? { name: entry.name } : {}), + settings: tokenSettings, + }; + settings.push(next); + } + return settings; +} + +function syntaxThemeName(theme: SyntaxColorTheme, fallbackTheme: BuiltInResolvedTheme) { + const colors = normalizeStringRecord(theme.colors); + return `t3-syntax-${hashString( + stableStringify({ + id: theme.id, + kind: theme.kind ?? fallbackTheme, + colors, + tokenColors: theme.tokenColors, + semanticHighlighting: theme.semanticHighlighting, + }), + )}`; +} + +function toShikiTheme( + theme: SyntaxColorTheme, + name: string, + fallbackTheme: BuiltInResolvedTheme, +): ThemeRegistrationResolved { + const colors = normalizeStringRecord(theme.colors); + const appVariables = normalizeStringRecord(theme.appVariables); + const foreground = colors["editor.foreground"] ?? appVariables["--foreground"] ?? "#f5f5f5"; + const background = colors["editor.background"] ?? appVariables["--background"] ?? "#111111"; + const settings = normalizeTokenColors(theme.tokenColors); + const isDark = theme.kind ? isDarkThemeKind(theme.kind) : fallbackTheme === "dark"; + + return { + name, + displayName: theme.label ?? "Custom Theme", + type: isDark ? "dark" : "light", + fg: foreground, + bg: background, + colors: { + ...colors, + "editor.foreground": foreground, + "editor.background": background, + }, + settings, + tokenColors: settings, + ...(typeof theme.semanticHighlighting === "boolean" + ? { semanticHighlighting: theme.semanticHighlighting } + : {}), + }; +} + +function registerSyntaxTheme(theme: SyntaxColorTheme, fallbackTheme: BuiltInResolvedTheme) { + const name = syntaxThemeName(theme, fallbackTheme); + if (registeredSyntaxThemes.has(name)) return name; + + const shikiTheme = toShikiTheme(theme, name, fallbackTheme); + registerCustomTheme(name, async () => shikiTheme); + registeredSyntaxThemes.add(name); + return name; +} + +export function resolveSyntaxThemeName(input: { + readonly resolvedTheme: BuiltInResolvedTheme; + readonly resolvedColorTheme: SyntaxColorTheme | null; +}): DiffsThemeNames { + if (!input.resolvedColorTheme) { + return resolveDiffThemeName(input.resolvedTheme); + } + return registerSyntaxTheme(input.resolvedColorTheme, input.resolvedTheme); +} + +export const resolveCodeBlockThemeName = resolveSyntaxThemeName; diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index a4dd3b82567..1bb8b9174e4 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -222,7 +222,11 @@ function makeDesktopBridge(overrides: Partial = {}): DesktopBridg getAdvertisedEndpoints: async () => [], pickFolder: async () => null, confirm: async () => true, + discoverColorThemes: async () => [], + loadColorTheme: async () => null, + getEditorThemePreferences: async () => [], setTheme: async () => undefined, + setWindowThemeColors: async () => undefined, showContextMenu: async () => null, openExternal: async () => true, onMenuAction: () => () => undefined, @@ -614,6 +618,7 @@ describe("wsApi", () => { sidebarProjectSortOrder: "manual" as const, sidebarThreadSortOrder: "created_at" as const, timestampFormat: "24-hour" as const, + themePreference: { mode: "system" as const }, }; const getClientSettings = vi.fn().mockResolvedValue({ ...clientSettings, @@ -676,6 +681,7 @@ describe("wsApi", () => { sidebarProjectSortOrder: "manual" as const, sidebarThreadSortOrder: "created_at" as const, timestampFormat: "24-hour" as const, + themePreference: { mode: "system" as const }, }; await api.persistence.setClientSettings(clientSettings); diff --git a/bun.lock b/bun.lock index a87ac77094b..ed24b44d3e1 100644 --- a/bun.lock +++ b/bun.lock @@ -16,12 +16,13 @@ }, "apps/desktop": { "name": "@t3tools/desktop", - "version": "0.0.21", + "version": "0.0.22", "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", "electron": "40.9.3", "electron-updater": "^6.6.2", + "jsonc-parser": "^3.3.1", }, "devDependencies": { "@t3tools/client-runtime": "workspace:*", @@ -49,7 +50,7 @@ }, "apps/server": { "name": "t3", - "version": "0.0.21", + "version": "0.0.22", "bin": { "t3": "./dist/bin.mjs", }, @@ -82,7 +83,7 @@ }, "apps/web": { "name": "@t3tools/web", - "version": "0.0.21", + "version": "0.0.22", "dependencies": { "@base-ui/react": "^1.2.0", "@dnd-kit/core": "^6.3.1", @@ -148,7 +149,7 @@ }, "packages/contracts": { "name": "@t3tools/contracts", - "version": "0.0.21", + "version": "0.0.22", "dependencies": { "effect": "catalog:", }, @@ -1398,7 +1399,7 @@ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], - "jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="], + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], @@ -2168,6 +2169,8 @@ "@vitest/browser/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], + "@vscode/emmet-helper/jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="], + "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "ast-kit/@babel/parser": ["@babel/parser@8.0.0-rc.2", "", { "dependencies": { "@babel/types": "^8.0.0-rc.2" }, "bin": "./bin/babel-parser.js" }, "sha512-29AhEtcq4x8Dp3T72qvUMZHx0OMXCj4Jy/TEReQa+KWLln524Cj1fWb3QFi0l/xSpptQBR6y9RNEXuxpFvwiUQ=="], @@ -2208,8 +2211,6 @@ "vite/rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="], - "vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], - "yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="], "yaml-language-server/yaml": ["yaml@2.7.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ=="], diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 1a3647eb314..526a4f22da3 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -11,6 +11,7 @@ export * from "./model.ts"; export * from "./keybindings.ts"; export * from "./server.ts"; export * from "./settings.ts"; +export * from "./theme.ts"; export * from "./git.ts"; export * from "./vcs.ts"; export * from "./sourceControl.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index eca3bb4e66b..10a0720daaf 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -68,6 +68,13 @@ import type { AdvertisedEndpoint } from "./remoteAccess.ts"; import { EditorId } from "./editor.ts"; import type { ExecutionEnvironmentDescriptor } from "./environment.ts"; import type { ClientSettings, ServerSettings, ServerSettingsPatch } from "./settings.ts"; +import type { + DesktopWindowThemeColors, + DiscoveredColorTheme, + EditorThemePreference, + ResolvedColorTheme, + ThemeId, +} from "./theme.ts"; import type { SourceControlCloneRepositoryInput, SourceControlCloneRepositoryResult, @@ -241,7 +248,11 @@ export interface DesktopBridge { getAdvertisedEndpoints: () => Promise; pickFolder: (options?: PickFolderOptions) => Promise; confirm: (message: string) => Promise; + discoverColorThemes: () => Promise; + loadColorTheme: (themeId: ThemeId) => Promise; + getEditorThemePreferences: () => Promise; setTheme: (theme: DesktopTheme) => Promise; + setWindowThemeColors: (input: DesktopWindowThemeColors) => Promise; showContextMenu: ( items: readonly ContextMenuItem[], position?: { x: number; y: number }, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index a4805494dfb..76e8969fe3d 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -5,6 +5,7 @@ import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; import { DEFAULT_GIT_TEXT_GENERATION_MODEL, ProviderOptionSelections } from "./model.ts"; import { ModelSelection } from "./orchestration.ts"; import { ProviderInstanceConfig, ProviderInstanceId } from "./providerInstance.ts"; +import { DEFAULT_THEME_PREFERENCE, ThemePreference } from "./theme.ts"; // ── Client Settings (local-only) ─────────────────────────────── @@ -78,6 +79,9 @@ export const ClientSettingsSchema = Schema.Struct({ timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), + themePreference: ThemePreference.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_THEME_PREFERENCE)), + ), }); export type ClientSettings = typeof ClientSettingsSchema.Type; diff --git a/packages/contracts/src/theme.ts b/packages/contracts/src/theme.ts new file mode 100644 index 00000000000..fb723adfa28 --- /dev/null +++ b/packages/contracts/src/theme.ts @@ -0,0 +1,71 @@ +import * as Schema from "effect/Schema"; + +export const ThemeSource = Schema.Literals(["builtin", "vscode", "cursor", "vscode-insiders"]); +export type ThemeSource = typeof ThemeSource.Type; + +export const EditorThemeSource = Schema.Literals(["vscode", "cursor", "vscode-insiders"]); +export type EditorThemeSource = typeof EditorThemeSource.Type; + +export const ThemeKind = Schema.Literals([ + "light", + "dark", + "high-contrast-light", + "high-contrast-dark", +]); +export type ThemeKind = typeof ThemeKind.Type; + +export type ThemeId = string; + +export const DiscoveredColorTheme = Schema.Struct({ + id: Schema.String, + source: EditorThemeSource, + extensionId: Schema.String, + extensionDisplayName: Schema.optional(Schema.String), + label: Schema.String, + kind: ThemeKind, + themePath: Schema.String, + packagePath: Schema.String, + publisher: Schema.optional(Schema.String), +}); +export type DiscoveredColorTheme = typeof DiscoveredColorTheme.Type; + +export const ResolvedColorTheme = Schema.Struct({ + id: Schema.String, + label: Schema.String, + source: EditorThemeSource, + kind: ThemeKind, + colors: Schema.Record(Schema.String, Schema.String), + tokenColors: Schema.Unknown, + semanticHighlighting: Schema.optional(Schema.Boolean), + semanticTokenColors: Schema.optional(Schema.Unknown), + appVariables: Schema.Record(Schema.String, Schema.String), +}); +export type ResolvedColorTheme = typeof ResolvedColorTheme.Type; + +export const ThemePreference = Schema.Union([ + Schema.Struct({ mode: Schema.Literal("system") }), + Schema.Struct({ mode: Schema.Literal("builtin"), theme: Schema.Literals(["light", "dark"]) }), + Schema.Struct({ mode: Schema.Literal("external"), themeId: Schema.String }), + Schema.Struct({ mode: Schema.Literal("follow-editor"), source: EditorThemeSource }), +]); +export type ThemePreference = typeof ThemePreference.Type; + +export const DEFAULT_THEME_PREFERENCE = { mode: "system" } as const satisfies ThemePreference; + +export const EditorThemePreference = Schema.Struct({ + source: EditorThemeSource, + colorTheme: Schema.NullOr(Schema.String), + preferredLightColorTheme: Schema.NullOr(Schema.String), + preferredDarkColorTheme: Schema.NullOr(Schema.String), + autoDetectColorScheme: Schema.Boolean, + workbenchColorCustomizations: Schema.Record(Schema.String, Schema.Unknown), + editorTokenColorCustomizations: Schema.Unknown, +}); +export type EditorThemePreference = typeof EditorThemePreference.Type; + +export const DesktopWindowThemeColors = Schema.Struct({ + backgroundColor: Schema.String, + titleBarColor: Schema.optional(Schema.String), + titleBarSymbolColor: Schema.optional(Schema.String), +}); +export type DesktopWindowThemeColors = typeof DesktopWindowThemeColors.Type; diff --git a/packages/shared/package.json b/packages/shared/package.json index d42c2039a2b..a600dcdfdcd 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -79,6 +79,10 @@ "./keybindings": { "types": "./src/keybindings.ts", "import": "./src/keybindings.ts" + }, + "./themeMapping": { + "types": "./src/themeMapping.ts", + "import": "./src/themeMapping.ts" } }, "scripts": { diff --git a/packages/shared/src/themeMapping.test.ts b/packages/shared/src/themeMapping.test.ts new file mode 100644 index 00000000000..df17c3cea53 --- /dev/null +++ b/packages/shared/src/themeMapping.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { mapVscodeColorsToAppVariables, normalizeThemeColors } from "./themeMapping.ts"; + +describe("themeMapping", () => { + it("normalizes supported VS Code hex colors only", () => { + expect( + normalizeThemeColors({ + "editor.background": "#010203", + "button.background": "#abc", + ignored: "rgb(1, 2, 3)", + missing: null, + }), + ).toEqual({ + "editor.background": "#010203", + "button.background": "#abc", + }); + }); + + it("maps VS Code workbench colors to app variables", () => { + const variables = mapVscodeColorsToAppVariables({ + kind: "dark", + colors: { + "editor.background": "#0d1117", + "editor.foreground": "#e6edf3", + "button.background": "#238636", + "button.foreground": "#ffffff", + "sideBar.background": "#010409", + "terminal.ansiGreen": "#3fb950", + }, + }); + + expect(variables["--background"]).toBe("#0d1117"); + expect(variables["--foreground"]).toBe("#e6edf3"); + expect(variables["--primary"]).toBe("#238636"); + expect(variables["--sidebar"]).toBe("#010409"); + expect(variables["--success"]).toBe("#3fb950"); + }); +}); diff --git a/packages/shared/src/themeMapping.ts b/packages/shared/src/themeMapping.ts new file mode 100644 index 00000000000..5a916fc351f --- /dev/null +++ b/packages/shared/src/themeMapping.ts @@ -0,0 +1,246 @@ +import type { ThemeKind } from "@t3tools/contracts"; + +export const EXTERNAL_APP_THEME_VARIABLES = [ + "--background", + "--app-chrome-background", + "--foreground", + "--card", + "--card-foreground", + "--popover", + "--popover-foreground", + "--primary", + "--primary-foreground", + "--secondary", + "--secondary-foreground", + "--muted", + "--muted-foreground", + "--accent", + "--accent-foreground", + "--destructive", + "--destructive-foreground", + "--border", + "--input", + "--ring", + "--info", + "--info-foreground", + "--success", + "--success-foreground", + "--warning", + "--warning-foreground", + "--sidebar", + "--sidebar-foreground", + "--sidebar-accent", + "--sidebar-accent-foreground", + "--sidebar-border", + "--terminal-background", + "--terminal-foreground", + "--terminal-cursor", + "--terminal-selection-background", + "--diff-inserted", + "--diff-removed", + "--chat-request-background", + "--chat-request-border", +] as const; + +const HEX_COLOR_PATTERN = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i; + +const LIGHT_FALLBACKS = { + background: "#ffffff", + foreground: "#262626", + primary: "#315cec", + border: "#00000014", + destructive: "#ef4444", + info: "#3b82f6", + success: "#10b981", + warning: "#f59e0b", +}; + +const DARK_FALLBACKS = { + background: "#111111", + foreground: "#f5f5f5", + primary: "#6384ff", + border: "#ffffff14", + destructive: "#f87171", + info: "#60a5fa", + success: "#34d399", + warning: "#fbbf24", +}; + +export function isDarkThemeKind(kind: ThemeKind) { + return kind === "dark" || kind === "high-contrast-dark"; +} + +export function normalizeCssColor(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!HEX_COLOR_PATTERN.test(trimmed)) return null; + return trimmed; +} + +export function normalizeThemeColors(value: unknown): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) return {}; + + const colors: Record = {}; + for (const [key, rawColor] of Object.entries(value)) { + const color = normalizeCssColor(rawColor); + if (color) colors[key] = color; + } + return colors; +} + +function firstColor(colors: Record, keys: readonly string[]) { + for (const key of keys) { + const color = colors[key]; + if (color) return color; + } + return null; +} + +function expandHexChannel(value: string) { + return value.length === 1 ? `${value}${value}` : value; +} + +function readableForeground(background: string) { + const normalized = normalizeCssColor(background); + if (!normalized) return "#ffffff"; + const hex = normalized.slice(1); + const rgb = + hex.length === 3 || hex.length === 4 + ? [hex.slice(0, 1), hex.slice(1, 2), hex.slice(2, 3)].map((channel) => + parseInt(expandHexChannel(channel), 16), + ) + : [hex.slice(0, 2), hex.slice(2, 4), hex.slice(4, 6)].map((channel) => parseInt(channel, 16)); + const [red = 0, green = 0, blue = 0] = rgb; + const luminance = (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255; + return luminance > 0.55 ? "#111111" : "#ffffff"; +} + +function mix(base: string, foreground: string, percentage: number) { + return `color-mix(in srgb, ${base} ${percentage}%, ${foreground})`; +} + +export function mapVscodeColorsToAppVariables(input: { + readonly kind: ThemeKind; + readonly colors: Record; +}): Record { + const fallback = isDarkThemeKind(input.kind) ? DARK_FALLBACKS : LIGHT_FALLBACKS; + const colors = input.colors; + const background = + firstColor(colors, ["editor.background", "sideBar.background"]) ?? fallback.background; + const foreground = firstColor(colors, ["foreground", "editor.foreground"]) ?? fallback.foreground; + const primary = + firstColor(colors, ["button.background", "activityBarBadge.background", "focusBorder"]) ?? + fallback.primary; + const border = + firstColor(colors, ["widget.border", "sideBar.border", "editorGroup.border", "panel.border"]) ?? + fallback.border; + const accent = + firstColor(colors, [ + "list.activeSelectionBackground", + "list.hoverBackground", + "toolbar.hoverBackground", + ]) ?? mix(background, foreground, 92); + + return { + "--background": background, + "--app-chrome-background": + firstColor(colors, ["titleBar.activeBackground", "sideBar.background"]) ?? background, + "--foreground": foreground, + "--card": + firstColor(colors, ["editorGroupHeader.tabsBackground", "sideBarSectionHeader.background"]) ?? + mix(background, foreground, 94), + "--card-foreground": foreground, + "--popover": + firstColor(colors, [ + "quickInput.background", + "editorWidget.background", + "dropdown.background", + ]) ?? mix(background, foreground, 96), + "--popover-foreground": + firstColor(colors, [ + "quickInput.foreground", + "editorWidget.foreground", + "dropdown.foreground", + "foreground", + ]) ?? foreground, + "--primary": primary, + "--primary-foreground": + firstColor(colors, ["button.foreground"]) ?? readableForeground(primary), + "--secondary": + firstColor(colors, ["button.secondaryBackground", "badge.background"]) ?? + mix(background, foreground, 90), + "--secondary-foreground": + firstColor(colors, ["button.secondaryForeground", "foreground"]) ?? foreground, + "--muted": + firstColor(colors, ["editor.lineHighlightBackground", "list.hoverBackground"]) ?? + mix(background, foreground, 92), + "--muted-foreground": + firstColor(colors, ["descriptionForeground", "disabledForeground"]) ?? + mix(foreground, background, 66), + "--accent": accent, + "--accent-foreground": + firstColor(colors, ["list.activeSelectionForeground", "list.hoverForeground"]) ?? foreground, + "--destructive": + firstColor(colors, [ + "errorForeground", + "editorError.foreground", + "notificationsErrorIcon.foreground", + ]) ?? fallback.destructive, + "--destructive-foreground": + firstColor(colors, ["errorForeground", "editorError.foreground"]) ?? + readableForeground(fallback.destructive), + "--border": border, + "--input": + firstColor(colors, [ + "input.background", + "dropdown.background", + "settings.textInputBackground", + ]) ?? mix(background, foreground, 90), + "--ring": firstColor(colors, ["focusBorder", "inputOption.activeBorder"]) ?? primary, + "--info": firstColor(colors, ["textLink.foreground", "terminal.ansiBlue"]) ?? fallback.info, + "--info-foreground": + firstColor(colors, ["textLink.foreground", "terminal.ansiBrightBlue"]) ?? fallback.info, + "--success": + firstColor(colors, ["gitDecoration.addedResourceForeground", "terminal.ansiGreen"]) ?? + fallback.success, + "--success-foreground": + firstColor(colors, ["gitDecoration.addedResourceForeground", "terminal.ansiBrightGreen"]) ?? + fallback.success, + "--warning": + firstColor(colors, ["notificationsWarningIcon.foreground", "terminal.ansiYellow"]) ?? + fallback.warning, + "--warning-foreground": + firstColor(colors, ["notificationsWarningIcon.foreground", "terminal.ansiBrightYellow"]) ?? + fallback.warning, + "--sidebar": firstColor(colors, ["sideBar.background", "activityBar.background"]) ?? background, + "--sidebar-foreground": firstColor(colors, ["sideBar.foreground", "foreground"]) ?? foreground, + "--sidebar-accent": + firstColor(colors, ["list.activeSelectionBackground", "activityBar.activeBackground"]) ?? + accent, + "--sidebar-accent-foreground": + firstColor(colors, ["list.activeSelectionForeground", "sideBar.foreground"]) ?? foreground, + "--sidebar-border": firstColor(colors, ["sideBar.border", "activityBar.border"]) ?? border, + "--terminal-background": + firstColor(colors, ["terminal.background", "panel.background"]) ?? background, + "--terminal-foreground": + firstColor(colors, ["terminal.foreground", "foreground"]) ?? foreground, + "--terminal-cursor": + firstColor(colors, ["terminalCursor.foreground", "editorCursor.foreground"]) ?? primary, + "--terminal-selection-background": + firstColor(colors, ["terminal.selectionBackground", "editor.selectionBackground"]) ?? + `color-mix(in srgb, ${primary} 24%, transparent)`, + "--diff-inserted": + firstColor(colors, [ + "diffEditor.insertedTextBackground", + "gitDecoration.addedResourceForeground", + ]) ?? fallback.success, + "--diff-removed": + firstColor(colors, [ + "diffEditor.removedTextBackground", + "gitDecoration.deletedResourceForeground", + ]) ?? fallback.destructive, + "--chat-request-background": + firstColor(colors, ["chat.requestBackground"]) ?? mix(background, foreground, 94), + "--chat-request-border": firstColor(colors, ["chat.requestBorder"]) ?? border, + }; +} From 2b7dcd02958a9aa1880f9b1c71c89d4d6bb00222 Mon Sep 17 00:00:00 2001 From: Ben Davis Date: Wed, 6 May 2026 02:08:03 -0700 Subject: [PATCH 3/7] Persist fallback theme and reuse destructive color - Reset stored theme preference when the requested VS Code theme cannot be loaded - Reuse the resolved destructive color for both destructive and destructive-foreground mapping --- apps/web/src/hooks/useTheme.ts | 1 + packages/shared/src/themeMapping.ts | 15 ++++++++------- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 99815ac9782..6ddc61b48c6 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -279,6 +279,7 @@ async function loadPreference(preference: ThemePreference, suppressTransitions = recomputeSnapshot({ preference, status: "loading", message: null }); const theme = await bridge.loadColorTheme(preference.themeId); if (!theme) { + setStoredPreference(DEFAULT_THEME_PREFERENCE); recomputeSnapshot({ preference: DEFAULT_THEME_PREFERENCE, resolvedColorTheme: null, diff --git a/packages/shared/src/themeMapping.ts b/packages/shared/src/themeMapping.ts index 5a916fc351f..7f115b8fee7 100644 --- a/packages/shared/src/themeMapping.ts +++ b/packages/shared/src/themeMapping.ts @@ -140,6 +140,12 @@ export function mapVscodeColorsToAppVariables(input: { "list.hoverBackground", "toolbar.hoverBackground", ]) ?? mix(background, foreground, 92); + const destructive = + firstColor(colors, [ + "errorForeground", + "editorError.foreground", + "notificationsErrorIcon.foreground", + ]) ?? fallback.destructive; return { "--background": background, @@ -180,15 +186,10 @@ export function mapVscodeColorsToAppVariables(input: { "--accent": accent, "--accent-foreground": firstColor(colors, ["list.activeSelectionForeground", "list.hoverForeground"]) ?? foreground, - "--destructive": - firstColor(colors, [ - "errorForeground", - "editorError.foreground", - "notificationsErrorIcon.foreground", - ]) ?? fallback.destructive, + "--destructive": destructive, "--destructive-foreground": firstColor(colors, ["errorForeground", "editorError.foreground"]) ?? - readableForeground(fallback.destructive), + readableForeground(destructive), "--border": border, "--input": firstColor(colors, [ From 90c0d7d95a19170662d5959380655ac3caa77cd4 Mon Sep 17 00:00:00 2001 From: Ben Davis Date: Wed, 6 May 2026 02:21:19 -0700 Subject: [PATCH 4/7] Ignore stale theme loads after preference changes - Add preference equality guard in theme hook - Prevent outdated async theme loads from overriding newer selections --- apps/web/src/hooks/useTheme.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 6ddc61b48c6..e1713ad18ff 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -111,6 +111,16 @@ function setStoredPreference(preference: ThemePreference) { localStorage.setItem(STORAGE_KEY, preferenceToBuiltInTheme(preference)); } +function isSamePreference(a: ThemePreference, b: ThemePreference) { + if (a.mode !== b.mode) return false; + if (a.mode === "builtin") return a.theme === (b.mode === "builtin" ? b.theme : null); + if (a.mode === "external") return a.themeId === (b.mode === "external" ? b.themeId : null); + if (a.mode === "follow-editor") { + return a.source === (b.mode === "follow-editor" ? b.source : null); + } + return true; +} + function ensureThemeColorMetaTag(): HTMLMetaElement { let element = document.querySelector(DYNAMIC_THEME_COLOR_SELECTOR); if (element) return element; @@ -278,6 +288,7 @@ async function loadPreference(preference: ThemePreference, suppressTransitions = recomputeSnapshot({ preference, status: "loading", message: null }); const theme = await bridge.loadColorTheme(preference.themeId); + if (!isSamePreference(state.preference, preference)) return; if (!theme) { setStoredPreference(DEFAULT_THEME_PREFERENCE); recomputeSnapshot({ From 4e4f44f286fa6b7edd71b4356f2a7a6a5e323393 Mon Sep 17 00:00:00 2001 From: Ben Davis Date: Wed, 6 May 2026 02:29:21 -0700 Subject: [PATCH 5/7] fix --- apps/web/src/hooks/useTheme.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index e1713ad18ff..0b8d870f5cb 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -287,7 +287,19 @@ async function loadPreference(preference: ThemePreference, suppressTransitions = } recomputeSnapshot({ preference, status: "loading", message: null }); - const theme = await bridge.loadColorTheme(preference.themeId); + let theme: ResolvedColorTheme | null; + try { + theme = await bridge.loadColorTheme(preference.themeId); + } catch { + if (!isSamePreference(state.preference, preference)) return; + recomputeSnapshot({ + resolvedColorTheme: null, + status: "error", + message: "Could not load selected editor theme.", + }); + applyResolvedTheme(null, suppressTransitions); + return; + } if (!isSamePreference(state.preference, preference)) return; if (!theme) { setStoredPreference(DEFAULT_THEME_PREFERENCE); From 357211864c9f0105cb2dbf5c5d29c680cd243957 Mon Sep 17 00:00:00 2001 From: Ben Davis Date: Wed, 6 May 2026 15:24:46 -0700 Subject: [PATCH 6/7] Remove sidebar footer separator --- apps/web/src/components/Sidebar.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ccac1419437..55c2d865151 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -141,7 +141,6 @@ import { SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, - SidebarSeparator, SidebarTrigger, useSidebar, } from "./ui/sidebar"; @@ -3386,7 +3385,6 @@ export default function Sidebar() { projectsLength={projects.length} /> - )} From bb538d76c5547dfee71504578a180df00491e2d5 Mon Sep 17 00:00:00 2001 From: Ben Davis Date: Wed, 6 May 2026 16:07:53 -0700 Subject: [PATCH 7/7] Optimize thread navigation render work --- apps/web/src/components/ChatMarkdown.tsx | 238 +++++++++++++---------- apps/web/src/components/ChatView.tsx | 7 +- apps/web/src/components/Sidebar.tsx | 12 +- apps/web/src/hooks/useHandleNewThread.ts | 7 +- apps/web/src/hooks/useSettings.ts | 19 ++ apps/web/src/hooks/useTheme.ts | 96 +++++++-- apps/web/src/routes/__root.tsx | 16 +- 7 files changed, 257 insertions(+), 138 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 6ce88d635bd..ab6fb2b80a3 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,11 +1,9 @@ import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs"; import { CheckIcon, CopyIcon } from "lucide-react"; -import React, { +import { Children, - Suspense, type MouseEvent as ReactMouseEvent, isValidElement, - use, useCallback, memo, useEffect, @@ -34,27 +32,6 @@ import { import { readLocalApi } from "../localApi"; import { cn } from "../lib/utils"; -class CodeHighlightErrorBoundary extends React.Component< - { fallback: ReactNode; children: ReactNode }, - { hasError: boolean } -> { - constructor(props: { fallback: ReactNode; children: ReactNode }) { - super(props); - this.state = { hasError: false }; - } - - static getDerivedStateFromError() { - return { hasError: true }; - } - - override render() { - if (this.state.hasError) { - return this.props.fallback; - } - return this.props.children; - } -} - interface ChatMarkdownProps { text: string; cwd: string | undefined; @@ -69,12 +46,26 @@ const highlightedCodeCache = new LRUCache( MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, ); const highlighterPromiseCache = new Map>(); +const unsupportedHighlightLanguages = new Set(); +const HIGHLIGHT_LANGUAGE_ALIASES: Readonly> = { + baml: "text", + gitignore: "ini", + plaintext: "text", + txt: "text", +}; + +function normalizeHighlightLanguage(language: string): string { + const normalizedLanguage = language.trim().toLowerCase(); + if (!normalizedLanguage) { + return "text"; + } + return HIGHLIGHT_LANGUAGE_ALIASES[normalizedLanguage] ?? normalizedLanguage; +} function extractFenceLanguage(className: string | undefined): string { const match = className?.match(CODE_FENCE_LANGUAGE_REGEX); const raw = match?.[1] ?? "text"; - // Shiki doesn't bundle a gitignore grammar; ini is a close match (#685) - return raw === "gitignore" ? "ini" : raw; + return normalizeHighlightLanguage(raw); } function nodeToPlainText(node: ReactNode): string { @@ -121,27 +112,70 @@ function estimateHighlightedSize(html: string, code: string): number { } function getHighlighterPromise(language: string, themeName: string): Promise { - const cacheKey = `${language}:${themeName}`; + const highlighterLanguage = unsupportedHighlightLanguages.has(language) ? "text" : language; + const cacheKey = `${highlighterLanguage}:${themeName}`; const cached = highlighterPromiseCache.get(cacheKey); if (cached) return cached; const promise = getSharedHighlighter({ themes: [themeName], - langs: [language as SupportedLanguages], + langs: [highlighterLanguage as SupportedLanguages], preferredHighlighter: "shiki-js", }).catch((err) => { highlighterPromiseCache.delete(cacheKey); - if (language === "text") { + if (highlighterLanguage === "text") { // "text" itself failed — Shiki cannot initialize at all, surface the error throw err; } // Language not supported by Shiki — fall back to "text" + unsupportedHighlightLanguages.add(highlighterLanguage); return getHighlighterPromise("text", themeName); }); highlighterPromiseCache.set(cacheKey, promise); return promise; } +function renderHighlightedHtml( + highlighter: DiffsHighlighter, + code: string, + language: string, + themeName: string, +): string { + const highlightLanguage = unsupportedHighlightLanguages.has(language) ? "text" : language; + try { + return highlighter.codeToHtml(code, { + lang: highlightLanguage as SupportedLanguages, + theme: themeName, + }); + } catch (error) { + if (highlightLanguage === "text") { + throw error; + } + unsupportedHighlightLanguages.add(highlightLanguage); + return highlighter.codeToHtml(code, { lang: "text", theme: themeName }); + } +} + +function scheduleHighlightWork(callback: () => void): () => void { + if (typeof window === "undefined") { + callback(); + return () => undefined; + } + + const idleWindow = window as Window & { + requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number; + cancelIdleCallback?: (handle: number) => void; + }; + + if (idleWindow.requestIdleCallback && idleWindow.cancelIdleCallback) { + const idleHandle = idleWindow.requestIdleCallback(callback, { timeout: 1500 }); + return () => idleWindow.cancelIdleCallback?.(idleHandle); + } + + const timeoutHandle = window.setTimeout(callback, 0); + return () => window.clearTimeout(timeoutHandle); +} + function MarkdownCodeBlock({ code, children }: { code: string; children: ReactNode }) { const [copied, setCopied] = useState(false); const copiedTimerRef = useRef | null>(null); @@ -190,85 +224,94 @@ function MarkdownCodeBlock({ code, children }: { code: string; children: ReactNo ); } -interface SuspenseShikiCodeBlockProps { +interface ShikiCodeBlockProps { className: string | undefined; code: string; themeName: string; isStreaming: boolean; + fallback: ReactNode; } -function SuspenseShikiCodeBlock({ +type HighlightedCodeState = { + cacheKey: string; + html: string; +}; + +function ShikiCodeBlock({ className, code, themeName, isStreaming, -}: SuspenseShikiCodeBlockProps) { + fallback, +}: ShikiCodeBlockProps) { const language = extractFenceLanguage(className); const cacheKey = createHighlightCacheKey(code, language, themeName); - const cachedHighlightedHtml = !isStreaming ? highlightedCodeCache.get(cacheKey) : null; + const [highlightedCode, setHighlightedCode] = useState(() => { + const cachedHighlightedHtml = isStreaming ? null : highlightedCodeCache.get(cacheKey); + return cachedHighlightedHtml == null ? null : { cacheKey, html: cachedHighlightedHtml }; + }); - if (cachedHighlightedHtml != null) { - return ( -
- ); - } + useEffect(() => { + if (isStreaming) { + setHighlightedCode(null); + return; + } - return ( - - ); -} + const cachedHighlightedHtml = highlightedCodeCache.get(cacheKey); + if (cachedHighlightedHtml != null) { + setHighlightedCode({ cacheKey, html: cachedHighlightedHtml }); + return; + } -interface UncachedShikiCodeBlockProps { - code: string; - language: string; - themeName: string; - cacheKey: string; - isStreaming: boolean; -} + let isCancelled = false; + let cancelHighlightWork: (() => void) | null = null; + setHighlightedCode(null); + + void getHighlighterPromise(language, themeName) + .then((highlighter) => { + if (isCancelled) return; + cancelHighlightWork = scheduleHighlightWork(() => { + if (isCancelled) return; + let highlightedHtml: string; + try { + highlightedHtml = renderHighlightedHtml(highlighter, code, language, themeName); + } catch { + return; + } + if (isCancelled) return; + highlightedCodeCache.set( + cacheKey, + highlightedHtml, + estimateHighlightedSize(highlightedHtml, code), + ); + setHighlightedCode({ cacheKey, html: highlightedHtml }); + }); + }) + .catch(() => { + if (!isCancelled) { + setHighlightedCode(null); + } + }); -function UncachedShikiCodeBlock({ - code, - language, - themeName, - cacheKey, - isStreaming, -}: UncachedShikiCodeBlockProps) { - const highlighter = use(getHighlighterPromise(language, themeName)); - const highlightedHtml = useMemo(() => { - try { - return highlighter.codeToHtml(code, { lang: language, theme: themeName }); - } catch (error) { - // Log highlighting failures for debugging while falling back to plain text - console.warn( - `Code highlighting failed for language "${language}", falling back to plain text.`, - error instanceof Error ? error.message : error, - ); - // If highlighting fails for this language, render as plain text - return highlighter.codeToHtml(code, { lang: "text", theme: themeName }); - } - }, [code, highlighter, language, themeName]); + return () => { + isCancelled = true; + cancelHighlightWork?.(); + }; + }, [cacheKey, code, isStreaming, language, themeName]); - useEffect(() => { - if (!isStreaming) { - highlightedCodeCache.set( - cacheKey, - highlightedHtml, - estimateHighlightedSize(highlightedHtml, code), - ); - } - }, [cacheKey, code, highlightedHtml, isStreaming]); + const cachedHighlightedHtml = isStreaming ? null : highlightedCodeCache.get(cacheKey); + const activeHighlightedHtml = + cachedHighlightedHtml ?? (highlightedCode?.cacheKey === cacheKey ? highlightedCode.html : null); + + if (activeHighlightedHtml == null) { + return <>{fallback}; + } return ( -
+
); } @@ -576,16 +619,13 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { return ( - {children}}> - {children}}> - - - + {children}} + /> ); }, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9ef221e262a..4f421c67bbf 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -116,7 +116,7 @@ import { } from "~/projectScripts"; import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; -import { useSettings } from "../hooks/useSettings"; +import { useProjectGroupingSettings, useSettings } from "../hooks/useSettings"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { isTerminalFocused } from "../lib/terminalFocus"; import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; @@ -941,10 +941,7 @@ export default function ChatView(props: ChatViewProps) { }, [], ); - const projectGroupingSettings = useSettings((settings) => ({ - sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, - sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, - })); + const projectGroupingSettings = useProjectGroupingSettings(); const logicalProjectEnvironments = useMemo(() => { if (!activeProject) return []; const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 55c2d865151..43f4637f546 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -166,7 +166,7 @@ import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { CommandDialogTrigger } from "./ui/command"; import { readEnvironmentApi } from "../environmentApi"; -import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; +import { useProjectGroupingSettings, useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; import { derivePhysicalProjectKey, @@ -930,10 +930,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const defaultThreadEnvMode = useSettings( (settings) => settings.defaultThreadEnvMode, ); - const projectGroupingSettings = useSettings((settings) => ({ - sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, - sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, - })); + const projectGroupingSettings = useProjectGroupingSettings(); const { updateSettings } = useUpdateSettings(); const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); @@ -2724,10 +2721,7 @@ export default function Sidebar() { const sidebarThreadSortOrder = useSettings((s) => s.sidebarThreadSortOrder); const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder); const sidebarProjectGroupingMode = useSettings((s) => s.sidebarProjectGroupingMode); - const projectGroupingSettings = useSettings((settings) => ({ - sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, - sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, - })); + const projectGroupingSettings = useProjectGroupingSettings(); const { updateSettings } = useUpdateSettings(); const { handleNewThread } = useNewThreadHandler(); const { archiveThread, deleteThread } = useThreadActions(); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 3630171bf59..86966af4470 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -15,14 +15,11 @@ import { selectProjectsAcrossEnvironments, useStore } from "../store"; import { createThreadSelectorByRef } from "../storeSelectors"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { useUiStateStore } from "../uiStateStore"; -import { useSettings } from "./useSettings"; +import { useProjectGroupingSettings } from "./useSettings"; function useNewThreadState() { const projects = useStore(useShallow((store) => selectProjectsAcrossEnvironments(store))); - const projectGroupingSettings = useSettings((settings) => ({ - sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, - sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, - })); + const projectGroupingSettings = useProjectGroupingSettings(); const router = useRouter(); const getCurrentRouteTarget = useCallback(() => { const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {}; diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 6541bb7a822..5b0a72cc7f1 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -22,6 +22,7 @@ import { ensureLocalApi } from "~/localApi"; import { Struct } from "effect"; import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/serverState"; +import type { ProjectGroupingSettings } from "~/logicalProject"; const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; @@ -31,6 +32,11 @@ let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; let clientSettingsHydrated = false; let clientSettingsHydrationPromise: Promise | null = null; +const selectSidebarProjectGroupingMode = (settings: UnifiedSettings) => + settings.sidebarProjectGroupingMode; +const selectSidebarProjectGroupingOverrides = (settings: UnifiedSettings) => + settings.sidebarProjectGroupingOverrides; + function emitClientSettingsChange() { for (const listener of clientSettingsListeners) { listener(); @@ -186,6 +192,19 @@ export function useSettings(selector?: (s: UnifiedSettings) return useMemo(() => (selector ? selector(merged) : (merged as T)), [merged, selector]); } +export function useProjectGroupingSettings(): ProjectGroupingSettings { + const sidebarProjectGroupingMode = useSettings(selectSidebarProjectGroupingMode); + const sidebarProjectGroupingOverrides = useSettings(selectSidebarProjectGroupingOverrides); + + return useMemo( + () => ({ + sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides, + }), + [sidebarProjectGroupingMode, sidebarProjectGroupingOverrides], + ); +} + /** * Returns an updater that routes each key to the correct backing store. * diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 0b8d870f5cb..9fc1a530ddd 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useSyncExternalStore } from "react"; +import { useCallback, useSyncExternalStore } from "react"; import type { DesktopTheme, DiscoveredColorTheme, @@ -30,6 +30,10 @@ const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data let listeners: Array<() => void> = []; let lastSnapshot: ThemeSnapshot | null = null; let lastDesktopTheme: DesktopTheme | null = null; +let themeRuntimeRefCount = 0; +let stopThemeRuntime: (() => void) | null = null; +let refreshThemesPromise: Promise | null = null; +const preferenceLoadPromises = new Map>(); let state: ThemeSnapshot = { preference: DEFAULT_THEME_PREFERENCE, systemDark: false, @@ -121,6 +125,19 @@ function isSamePreference(a: ThemePreference, b: ThemePreference) { return true; } +function themePreferenceKey(preference: ThemePreference) { + switch (preference.mode) { + case "builtin": + return `builtin:${preference.theme}`; + case "external": + return `external:${preference.themeId}`; + case "follow-editor": + return `follow-editor:${preference.source}`; + case "system": + return "system"; + } +} + function ensureThemeColorMetaTag(): HTMLMetaElement { let element = document.querySelector(DYNAMIC_THEME_COLOR_SELECTOR); if (element) return element; @@ -177,7 +194,7 @@ function clearExternalThemeVariables(root: HTMLElement) { function syncDesktopTheme(theme: DesktopTheme) { if (typeof window === "undefined") return; const bridge = window.desktopBridge; - if (!bridge || lastDesktopTheme === theme) return; + if (!bridge?.setTheme || lastDesktopTheme === theme) return; lastDesktopTheme = theme; void bridge.setTheme(theme).catch(() => { @@ -188,7 +205,7 @@ function syncDesktopTheme(theme: DesktopTheme) { function syncDesktopWindowColors(theme: ResolvedColorTheme | null) { if (typeof window === "undefined") return; const bridge = window.desktopBridge; - if (!bridge || !theme) return; + if (!bridge?.setWindowThemeColors || !theme) return; const backgroundColor = theme.appVariables["--app-chrome-background"] ?? theme.appVariables["--background"] ?? @@ -275,7 +292,7 @@ function recomputeSnapshot(next: Partial = {}) { async function loadPreference(preference: ThemePreference, suppressTransitions = true) { if (preference.mode === "external") { const bridge = window.desktopBridge; - if (!bridge) { + if (!bridge?.loadColorTheme) { recomputeSnapshot({ preference, resolvedColorTheme: null, @@ -325,9 +342,27 @@ async function loadPreference(preference: ThemePreference, suppressTransitions = applyResolvedTheme(null, suppressTransitions); } -async function refreshThemes() { +function loadThemePreference(preference: ThemePreference, suppressTransitions = true) { + const key = themePreferenceKey(preference); + const existing = preferenceLoadPromises.get(key); + if (existing) return existing; + + const promise = loadPreference(preference, suppressTransitions).finally(() => { + if (preferenceLoadPromises.get(key) === promise) { + preferenceLoadPromises.delete(key); + } + }); + preferenceLoadPromises.set(key, promise); + return promise; +} + +function loadStoredPreference(suppressTransitions = true) { + return loadThemePreference(getStoredPreference(), suppressTransitions); +} + +async function refreshThemesNow() { const bridge = typeof window !== "undefined" ? window.desktopBridge : null; - if (!bridge) { + if (!bridge?.discoverColorThemes) { recomputeSnapshot({ discoveredThemes: [], status: "ready", @@ -344,6 +379,15 @@ async function refreshThemes() { } } +export function refreshThemes() { + if (refreshThemesPromise) return refreshThemesPromise; + + refreshThemesPromise = refreshThemesNow().finally(() => { + refreshThemesPromise = null; + }); + return refreshThemesPromise; +} + function bootstrapCachedTheme() { if (!hasThemeStorage()) return null; const raw = localStorage.getItem(BOOTSTRAP_THEME_CACHE_KEY); @@ -380,6 +424,12 @@ function subscribe(listener: () => void): () => void { if (typeof window === "undefined") return () => {}; listeners.push(listener); + return () => { + listeners = listeners.filter((l) => l !== listener); + }; +} + +function startThemeRuntimeUnsafe() { const mq = window.matchMedia(MEDIA_QUERY); const handleChange = () => { recomputeSnapshot(); @@ -389,24 +439,47 @@ function subscribe(listener: () => void): () => void { const handleStorage = (e: StorageEvent) => { if (e.key === STORAGE_KEY || e.key === PREFERENCE_STORAGE_KEY) { - void loadPreference(getStoredPreference(), true); + void loadStoredPreference(true); } }; window.addEventListener("storage", handleStorage); + void loadStoredPreference(false).finally(() => { + void refreshThemes(); + }); + return () => { - listeners = listeners.filter((l) => l !== listener); mq.removeEventListener("change", handleChange); window.removeEventListener("storage", handleStorage); }; } +export function startThemeRuntime() { + if (typeof window === "undefined") return () => {}; + + themeRuntimeRefCount += 1; + if (themeRuntimeRefCount === 1) { + stopThemeRuntime = startThemeRuntimeUnsafe(); + } + + let released = false; + return () => { + if (released) return; + released = true; + themeRuntimeRefCount = Math.max(0, themeRuntimeRefCount - 1); + if (themeRuntimeRefCount === 0) { + stopThemeRuntime?.(); + stopThemeRuntime = null; + } + }; +} + export function useTheme() { const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); const setThemePreference = useCallback((preference: ThemePreference) => { setStoredPreference(preference); - void loadPreference(preference, true); + void loadThemePreference(preference, true); }, []); const setTheme = useCallback( @@ -416,11 +489,6 @@ export function useTheme() { [setThemePreference], ); - useEffect(() => { - void refreshThemes(); - void loadPreference(getStoredPreference(), false); - }, []); - return { theme: preferenceToBuiltInTheme(snapshot.preference), setTheme, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 8c63003cda5..1ac4918286e 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -29,7 +29,7 @@ import { } from "../components/ui/toast"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { readLocalApi } from "../localApi"; -import { useSettings } from "../hooks/useSettings"; +import { useProjectGroupingSettings } from "../hooks/useSettings"; import { deriveLogicalProjectKeyFromSettings, derivePhysicalProjectKeyFromPath, @@ -44,7 +44,7 @@ import { } from "../rpc/serverState"; import { useStore } from "../store"; import { useUiStateStore } from "../uiStateStore"; -import { syncBrowserChromeTheme } from "../hooks/useTheme"; +import { startThemeRuntime, syncBrowserChromeTheme } from "../hooks/useTheme"; import { ensureEnvironmentConnectionBootstrapped, getPrimaryEnvironmentConnection, @@ -133,6 +133,7 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} + @@ -259,6 +260,12 @@ function ServerStateBootstrap() { return null; } +function ThemeRuntimeBootstrap() { + useEffect(() => startThemeRuntime(), []); + + return null; +} + function AuthenticatedTracingBootstrap() { useEffect(() => { void configureClientTracing(); @@ -281,10 +288,7 @@ function EventRouter() { const setActiveEnvironmentId = useStore((store) => store.setActiveEnvironmentId); const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); - const projectGroupingSettings = useSettings((settings) => ({ - sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, - sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, - })); + const projectGroupingSettings = useProjectGroupingSettings(); const readPathname = useEffectEvent(() => pathname); const handledBootstrapThreadIdRef = useRef(null); const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0);