From 50182418667d99313fab4ff18efde230b08b79f3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:13:10 -0700 Subject: [PATCH] feat(plugins): app and terminal themes in one Theme list, painted safely (#30719) * plugins: app and terminal theme contributions Manifests can contribute themes (public CSS tokens over a light or dark base) and terminal themes (an ANSI palette), each at most 16 KiB. App themes must set --background and --foreground; omitted tokens are derived from them (surfaces copy the background, muted text, borders and accents mix the foreground into it), and values must be colors (no keywords, lengths, calc, var or url). Both load through the shared artifact-pack registry with the plugin's display name and a content revision, reach the renderer over IPC and are re-validated there. Catalog releases can declare app-theme and terminal-theme, and marketplaces stop hiding theme listings. docs/reference/plugin-themes.md documents the format. * plugins: resolve a selected plugin terminal palette on every host Selecting a plugin terminal theme saves a copy of its palette in settings (terminalPluginPalettes). The shared lookup reads it, so main, headless serve and relays answer terminal color queries and native chat matches the terminal without the plugin loaded; a malformed snapshot falls back to the default theme. * plugins: one Theme list with plugin themes, painted safely Appearance keeps the System/Dark/Light control until a plugin offers a theme; then Theme becomes one list with plugin themes after a divider, labelled with the publisher when names collide. Choosing one stores its base in theme and remembers the earlier built-in choice, which comes back while the plugin is off or after it is uninstalled; a plugin update that changes the base re-syncs theme. It also offers the plugin's terminal colors in one click. The renderer paints only values CSS parses as colors, checks every text/surface pair, ring and border against the effective set (plugin values over this mode's built-ins) and repeats until nothing changes, so dropping one pair cannot expose another. The applied theme is cached with its id and revision, painted before first render, and dropped as soon as settings show it no longer applies. Main bounds the new settings on every write and load, and uninstalling a plugin clears its theme choices and palette copies. Terminal themes appear under Plugins in the picker with the plugin's name and are passed to lookups instead of read from a store. * plugins: type the theme settings normalizer input; let the store own its normalization * fix(plugins): check text contrast on the editor surface and clear terminal overrides when adopting a theme's terminal colors * fix(plugins): reject theme colors the canvas probe silently ignores --- docs/reference/plugin-themes.md | 36 ++++ src/main/ipc/plugins.ts | 15 +- .../applying-settings/settings-update.ts | 2 + .../normalize-loaded-global-settings.ts | 2 + .../plugins/plugin-artifact-validation.ts | 8 + .../plugins/plugin-content-pack-registry.ts | 10 + .../plugins/plugin-marketplace-fetch.test.ts | 2 +- .../plugin-marketplace-service.test.ts | 3 +- .../plugins/plugin-theme-registries.test.ts | 98 +++++++++ src/main/plugins/plugin-theme-registries.ts | 70 +++++++ src/preload/api/plugin-host-api.ts | 4 + src/preload/api/plugins-bridge.ts | 2 + .../use-document-appearance.test.tsx | 46 ++++- .../src/app-shell/use-document-appearance.ts | 24 ++- .../settings/AppearanceInterfaceSection.tsx | 34 +--- .../settings/AppearanceThemeControl.test.tsx | 104 ++++++++++ .../settings/AppearanceThemeControl.tsx | 146 ++++++++++++++ .../PluginMarketplaceBrowser.test.tsx | 4 +- .../settings/TerminalThemePicker.tsx | 25 ++- .../TerminalThemeSections.lifecycle.test.ts | 6 + .../settings/TerminalThemeSections.tsx | 22 +- src/renderer/src/i18n/locales/en.json | 7 +- src/renderer/src/i18n/locales/es.json | 7 +- src/renderer/src/i18n/locales/fr.json | 7 +- src/renderer/src/i18n/locales/ja.json | 7 +- src/renderer/src/i18n/locales/ko.json | 7 +- src/renderer/src/i18n/locales/zh.json | 7 +- src/renderer/src/lib/document-theme.test.ts | 179 ++++++++++++++++- src/renderer/src/lib/document-theme.ts | 134 +++++++++++++ src/renderer/src/lib/plugin-theme-tokens.ts | 146 ++++++++++++++ .../src/lib/terminal-plugin-palettes.test.ts | 51 +++++ .../src/lib/terminal-plugin-palettes.ts | 53 +++++ src/renderer/src/lib/terminal-theme.test.ts | 36 ++++ src/renderer/src/lib/terminal-theme.ts | 21 +- src/renderer/src/main.tsx | 12 +- src/renderer/src/store/plugin-themes.ts | 79 ++++++++ src/shared/default-global-settings.ts | 1 + src/shared/global-settings-base.ts | 4 +- .../plugin-appearance-settings-types.ts | 12 ++ src/shared/plugin-theme-settings.test.ts | 72 +++++++ src/shared/plugin-theme-settings.ts | 98 +++++++++ .../plugins/plugin-catalog-release.test.ts | 2 +- src/shared/plugins/plugin-catalog-release.ts | 8 + .../plugin-content-pack-contributions.test.ts | 4 +- .../plugin-content-pack-contributions.ts | 18 +- ...plugin-manifest-contribution-validation.ts | 4 +- src/shared/plugins/plugin-manifest.ts | 8 + src/shared/plugins/plugin-marketplace.test.ts | 5 +- src/shared/plugins/plugin-marketplace.ts | 7 +- .../plugins/plugin-terminal-theme-artifact.ts | 53 +++++ .../plugins/plugin-theme-artifact.test.ts | 117 +++++++++++ src/shared/plugins/plugin-theme-artifact.ts | 189 ++++++++++++++++++ src/shared/terminal-color-overrides.ts | 6 + src/shared/terminal-theme-selection.test.ts | 26 +++ src/shared/terminal-theme-selection.ts | 33 ++- .../wsl-move/wsl-move-storage-dispositions.ts | 1 + 56 files changed, 2007 insertions(+), 77 deletions(-) create mode 100644 docs/reference/plugin-themes.md create mode 100644 src/main/plugins/plugin-theme-registries.test.ts create mode 100644 src/main/plugins/plugin-theme-registries.ts create mode 100644 src/renderer/src/components/settings/AppearanceThemeControl.test.tsx create mode 100644 src/renderer/src/components/settings/AppearanceThemeControl.tsx create mode 100644 src/renderer/src/lib/plugin-theme-tokens.ts create mode 100644 src/renderer/src/lib/terminal-plugin-palettes.test.ts create mode 100644 src/renderer/src/lib/terminal-plugin-palettes.ts create mode 100644 src/renderer/src/store/plugin-themes.ts create mode 100644 src/shared/plugin-appearance-settings-types.ts create mode 100644 src/shared/plugin-theme-settings.test.ts create mode 100644 src/shared/plugin-theme-settings.ts create mode 100644 src/shared/plugins/plugin-terminal-theme-artifact.ts create mode 100644 src/shared/plugins/plugin-theme-artifact.test.ts create mode 100644 src/shared/plugins/plugin-theme-artifact.ts diff --git a/docs/reference/plugin-themes.md b/docs/reference/plugin-themes.md new file mode 100644 index 00000000000..d8ac2b0ad1c --- /dev/null +++ b/docs/reference/plugin-themes.md @@ -0,0 +1,36 @@ +# Plugin themes + +Plugins contribute three kinds of theme through `orca-plugin.json` `contributes`: +`themes` (app colors), `terminalThemes` (an ANSI palette) and, separately, icon themes. +Each entry is `{ "id", "label", "path" }` pointing at a JSON file of at most 16 KiB. + +## App themes + +```json +{ "base": "dark", "tokens": { "--background": "#1b1430", "--foreground": "#e8e0ff" } } +``` + +- `base` (`light` or `dark`) becomes the app's light/dark mode while the theme is selected. +- `--background` and `--foreground` are required. Every other public token is optional; an + omitted token is derived from the ones you set (cards and sidebars from `--background`, text + from `--foreground`, sidebar accents from `--accent`, rings from `--primary`, and so on), so a + two-token theme still colors the whole app. +- The public tokens are `PLUGIN_APP_THEME_TOKENS` in + `src/shared/plugins/plugin-theme-artifact.ts`. Other variables cannot be set. +- Values must be colors: hex, a color function (`rgb`, `hsl`, `oklch`, …) or a color name. + `transparent`, `currentColor`, CSS-wide keywords, lengths, `calc()`, `var()` and `url()` are + refused. +- Each text/surface pair (for example `--background`/`--foreground`, + `--primary`/`--primary-foreground`) must be opaque with at least 4.5:1 contrast; a pair that + fails is dropped and keeps Orca's built-in colors. +- Trust and approval dialogs always use Orca's own colors, whatever theme is active. + +## Terminal themes + +```json +{ "schemaVersion": 1, "mode": "dark", "terminal": { "background": "#1b1430", "foreground": "#e8e0ff", "red": "#ff6b8b" } } +``` + +Colors are hex. A palette needs `background`, `foreground` and at least one ANSI color. When a +user selects it, Orca saves a copy of the palette in settings so hosts, headless servers and +relays answer terminal color queries without the plugin loaded. diff --git a/src/main/ipc/plugins.ts b/src/main/ipc/plugins.ts index 1799cde774f..dd2fdb490a9 100644 --- a/src/main/ipc/plugins.ts +++ b/src/main/ipc/plugins.ts @@ -1,3 +1,4 @@ +import { pluginThemeSettingsAfterRemoval } from '../../shared/plugin-theme-settings' import { ipcMain } from 'electron' import { z } from 'zod' import type { Store } from '../persistence' @@ -111,6 +112,14 @@ export function registerPluginHandlers( await pluginService.whenReady() return pluginService.contentPacks.languagePacks.list() }) + ipcMain.handle('plugins:listThemes', async () => { + await pluginService.whenReady() + return pluginService.contentPacks.themes.list() + }) + ipcMain.handle('plugins:listTerminalThemes', async () => { + await pluginService.whenReady() + return pluginService.contentPacks.terminalThemes.list() + }) ipcMain.handle('plugins:consent', async (event, args: unknown) => { await pluginService.whenReady() const parsed = parsePluginConsentArgs(args) @@ -222,7 +231,11 @@ export function registerPluginHandlers( (pluginKey) => pluginKey !== parsed.pluginKey ) store.updateSettings( - { pluginConsents: consents, disabledPlugins }, + { + pluginConsents: consents, + disabledPlugins, + ...pluginThemeSettingsAfterRemoval(settings, parsed.pluginKey) + }, { notifyListeners: true, originWebContentsId: event.sender.id } ) await pluginService.refresh() diff --git a/src/main/persistence/applying-settings/settings-update.ts b/src/main/persistence/applying-settings/settings-update.ts index 52adef6bfcf..0d2187d2b76 100644 --- a/src/main/persistence/applying-settings/settings-update.ts +++ b/src/main/persistence/applying-settings/settings-update.ts @@ -10,6 +10,7 @@ import { sanitizeAgentLaunchProfileUpdates } from '../../../shared/tui-agent-lau import { normalizeTerminalQuickCommands } from '../../../shared/terminal-quick-commands' import { assertTerminalQuickCommandsStorable } from '../../../shared/terminal-quick-command-prompt-limit' import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes' +import { normalizePluginThemeSettings } from '../../../shared/plugin-theme-settings' import { normalizeTerminalDefaultShell } from '../../../shared/terminal-default-shell' import { normalizeTerminalCursorStyleDefault } from '../../../shared/terminal-cursor-style-settings' import { normalizeDesktopTerminalScrollbackRows } from '../../../shared/terminal-scrollback-policy' @@ -131,6 +132,7 @@ export function updateSettings( updates.terminalCustomThemes ) } + Object.assign(sanitizedUpdates, normalizePluginThemeSettings(updates)) if ('terminalCursorStyle' in updates) { Object.assign( sanitizedUpdates, diff --git a/src/main/persistence/loading-store/normalize-loaded-global-settings.ts b/src/main/persistence/loading-store/normalize-loaded-global-settings.ts index 889e5001309..c039b6b2cfe 100644 --- a/src/main/persistence/loading-store/normalize-loaded-global-settings.ts +++ b/src/main/persistence/loading-store/normalize-loaded-global-settings.ts @@ -6,6 +6,7 @@ import { normalizeOpenInApplications } from '../../../shared/open-in-application import { normalizeTerminalShortcutPolicy } from '../../../shared/keybindings' import { normalizeAppIconId } from '../../../shared/app-icon' import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes' +import { normalizePluginThemeSettings } from '../../../shared/plugin-theme-settings' import { normalizeTerminalDefaultShell } from '../../../shared/terminal-default-shell' import { projectSourceControlAiToLegacyCommitMessageAi } from '../../../shared/source-control-ai' import { normalizeNativeChatShellEnvironmentVariables } from '../../../shared/native-chat/native-chat-shell-environment' @@ -120,6 +121,7 @@ export function normalizeLoadedGlobalSettings( terminalDefaultShell: normalizeTerminalDefaultShell(parsed.settings?.terminalDefaultShell), terminalQuickCommands: normalizeTerminalQuickCommands(parsed.settings?.terminalQuickCommands), terminalCustomThemes: normalizeTerminalCustomThemes(parsed.settings?.terminalCustomThemes), + ...normalizePluginThemeSettings(parsed.settings ?? {}), appIcon: normalizeAppIconId(parsed.settings?.appIcon), mobilePairingCustomAddress, mobilePairingCustomAddresses, diff --git a/src/main/plugins/plugin-artifact-validation.ts b/src/main/plugins/plugin-artifact-validation.ts index b024fa383cb..4a8d71011bb 100644 --- a/src/main/plugins/plugin-artifact-validation.ts +++ b/src/main/plugins/plugin-artifact-validation.ts @@ -12,6 +12,8 @@ const PLUGIN_ICON_MAX_BYTES = 2 * 1024 * 1024 export const PLUGIN_LANGUAGE_PACK_MAX_BYTES = 5 * 1024 * 1024 export const PLUGIN_VM_RECIPE_MAX_BYTES = 256 * 1024 const PLUGIN_AGENT_PROFILE_MAX_BYTES = 1024 * 1024 +// Why 16 KiB: a theme is a few dozen tokens or palette slots. +export const PLUGIN_THEME_MAX_BYTES = 16 * 1024 type DeclaredArtifact = | { label: string; path: string; kind: 'file'; maxBytes: number } @@ -37,6 +39,12 @@ function declaredArtifactPaths(manifest: PluginManifest): DeclaredArtifact[] { ), ...c.agents.flatMap((agent) => declaredFile('agent profile', agent.path, PLUGIN_AGENT_PROFILE_MAX_BYTES) + ), + ...c.themes.flatMap((theme) => + declaredFile(`theme "${theme.id}"`, theme.path, PLUGIN_THEME_MAX_BYTES) + ), + ...c.terminalThemes.flatMap((theme) => + declaredFile(`terminal theme "${theme.id}"`, theme.path, PLUGIN_THEME_MAX_BYTES) ) ] } diff --git a/src/main/plugins/plugin-content-pack-registry.ts b/src/main/plugins/plugin-content-pack-registry.ts index 7035281f504..7edbf1a9432 100644 --- a/src/main/plugins/plugin-content-pack-registry.ts +++ b/src/main/plugins/plugin-content-pack-registry.ts @@ -5,6 +5,10 @@ import { type ValidDiscoveredPlugin } from './plugin-discovery' import { PluginLanguagePackRegistry } from './plugin-language-pack-registry' +import { + createPluginTerminalThemeRegistry, + createPluginThemeRegistry +} from './plugin-theme-registries' import { PluginVmRecipeRegistry } from './plugin-vm-recipe-registry' import { PluginCommandRegistry } from './plugin-command-registry' import { verifyInstructionalPluginContent } from './plugin-instructional-content-integrity' @@ -20,6 +24,8 @@ type ContentFamily = { export class PluginContentPackRegistry { readonly languagePacks: PluginLanguagePackRegistry + readonly themes: ReturnType + readonly terminalThemes: ReturnType readonly vmRecipes: PluginVmRecipeRegistry readonly commands: PluginCommandRegistry private readonly activationErrors = new Map() @@ -30,6 +36,8 @@ export class PluginContentPackRegistry { private readonly isKilled: (pluginKey: string) => boolean ) { this.languagePacks = new PluginLanguagePackRegistry(contentVerifier) + this.themes = createPluginThemeRegistry(contentVerifier) + this.terminalThemes = createPluginTerminalThemeRegistry(contentVerifier) this.vmRecipes = new PluginVmRecipeRegistry() this.commands = new PluginCommandRegistry() } @@ -112,6 +120,8 @@ export class PluginContentPackRegistry { private families(keybindings: KeybindingOverrides = {}): readonly ContentFamily[] { return [ this.languagePacks, + this.themes, + this.terminalThemes, this.vmRecipes, { reconcile: async (discovered, isApproved) => diff --git a/src/main/plugins/plugin-marketplace-fetch.test.ts b/src/main/plugins/plugin-marketplace-fetch.test.ts index 5f91aaea204..b966c3a8222 100644 --- a/src/main/plugins/plugin-marketplace-fetch.test.ts +++ b/src/main/plugins/plugin-marketplace-fetch.test.ts @@ -30,7 +30,7 @@ describe('parsePluginMarketplaceIndex', () => { owner: 'samples', futureHeader: true, plugins: [ - { ...entry, id: 'samples.theme', release: { ...release, types: ['app-theme'] } }, + { ...entry, id: 'samples.theme', release: { ...release, types: ['icon-theme'] } }, { ...entry, id: 'samples.broken', release: { ...release, commit: 'nope' } }, { ...entry, release: { ...release, futureField: 1 }, futureField: 1 } ] diff --git a/src/main/plugins/plugin-marketplace-service.test.ts b/src/main/plugins/plugin-marketplace-service.test.ts index ae4aa84d0ec..138cc4afa03 100644 --- a/src/main/plugins/plugin-marketplace-service.test.ts +++ b/src/main/plugins/plugin-marketplace-service.test.ts @@ -183,7 +183,7 @@ describe('PluginMarketplaceService', () => { { id: 'community.midnight', source: { kind: 'git', url: 'https://github.com/community/midnight.git', ref: 'v1' }, - categories: ['themes', 'official'] + categories: ['icon-themes', 'official'] }, { id: 'community.recipes', @@ -199,6 +199,7 @@ describe('PluginMarketplaceService', () => { const added = await service.addSource(source()) + // The icon-theme pack is filtered out; only the supported recipe listing remains. await expect(service.listPlugins()).resolves.toEqual([ expect.objectContaining({ pluginKey: 'community.recipes' }) ]) diff --git a/src/main/plugins/plugin-theme-registries.test.ts b/src/main/plugins/plugin-theme-registries.test.ts new file mode 100644 index 00000000000..b2dd7a26f08 --- /dev/null +++ b/src/main/plugins/plugin-theme-registries.test.ts @@ -0,0 +1,98 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' +import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest' +import { PluginContentVerifier } from './plugin-content-integrity' +import type { ValidDiscoveredPlugin } from './plugin-discovery' +import { + createPluginTerminalThemeRegistry, + createPluginThemeRegistry +} from './plugin-theme-registries' + +const roots: string[] = [] +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function themePlugin(files: { app?: unknown; terminal?: unknown }) { + const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-theme-registry-')) + roots.push(rootDir) + await writeFile(join(rootDir, 'app.json'), JSON.stringify(files.app ?? {})) + await writeFile(join(rootDir, 'terminal.json'), JSON.stringify(files.terminal ?? {})) + const manifest = pluginManifestSchema.parse({ + manifestVersion: 1, + id: 'midnight', + publisher: 'samples', + name: 'Midnight', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + contributes: { + themes: [{ id: 'midnight', label: 'Midnight', path: 'app.json' }], + terminalThemes: [{ id: 'midnight', label: 'Midnight', path: 'terminal.json' }] + } + }) + const plugin: ValidDiscoveredPlugin = { + pluginKey: 'samples.midnight', + rootDir, + manifest, + consentFingerprint: fingerprintPluginConsent(manifest), + contentHash: null, + isDev: true + } + return plugin +} + +const terminal = { + schemaVersion: 1, + mode: 'dark', + terminal: { background: '#000000', foreground: '#ffffff', red: '#ff0000' } +} + +describe('plugin theme registries', () => { + it('loads app and terminal themes under host-owned ids', async () => { + const plugin = await themePlugin({ + app: { base: 'dark', tokens: { '--background': '#101010', '--foreground': '#f0f0f0' } }, + terminal + }) + const themes = createPluginThemeRegistry(new PluginContentVerifier()) + const terminalThemes = createPluginTerminalThemeRegistry(new PluginContentVerifier()) + + await themes.reconcile([plugin], () => true) + await terminalThemes.reconcile([plugin], () => true) + + expect(themes.list()).toEqual([ + expect.objectContaining({ + id: 'plugin:samples.midnight/midnight', + pluginKey: 'samples.midnight', + pluginName: 'Midnight', + label: 'Midnight', + base: 'dark', + tokens: expect.objectContaining({ '--background': '#101010', '--card': '#101010' }) + }) + ]) + expect(terminalThemes.list()).toEqual([ + expect.objectContaining({ id: 'plugin:samples.midnight/midnight', mode: 'dark' }) + ]) + }) + + it('reports an invalid theme and drops it, then clears when the plugin is disabled', async () => { + const plugin = await themePlugin({ + app: { base: 'dark', tokens: { '--background': 'url(https://example.com)' } }, + terminal: { ...terminal, terminal: { background: '#000000' } } + }) + const themes = createPluginThemeRegistry(new PluginContentVerifier()) + const terminalThemes = createPluginTerminalThemeRegistry(new PluginContentVerifier()) + + await themes.reconcile([plugin], () => true) + await terminalThemes.reconcile([plugin], () => true) + expect(themes.list()).toEqual([]) + expect(themes.error(plugin.pluginKey)).toContain('theme "midnight" is invalid') + expect(terminalThemes.error(plugin.pluginKey)).toContain('requires background, foreground') + + await themes.reconcile([plugin], () => false) + expect(themes.error(plugin.pluginKey)).toBeNull() + }) +}) diff --git a/src/main/plugins/plugin-theme-registries.ts b/src/main/plugins/plugin-theme-registries.ts new file mode 100644 index 00000000000..494fa49db4c --- /dev/null +++ b/src/main/plugins/plugin-theme-registries.ts @@ -0,0 +1,70 @@ +import { createHash } from 'node:crypto' +import type { PluginThemeContribution } from '../../shared/plugins/plugin-content-pack-contributions' +import { + parsePluginAppThemeArtifact, + type PluginThemeRegistration +} from '../../shared/plugins/plugin-theme-artifact' +import { + parsePluginTerminalThemeArtifact, + type PluginTerminalThemeRegistration +} from '../../shared/plugins/plugin-terminal-theme-artifact' +import { + PLUGIN_THEME_MAX_BYTES, + readContainedPluginArtifactText +} from './plugin-artifact-validation' +import { PluginArtifactPackRegistry } from './plugin-artifact-pack-registry' +import type { PluginContentVerifier } from './plugin-content-integrity' +import type { ValidDiscoveredPlugin } from './plugin-discovery' + +async function readTheme( + plugin: ValidDiscoveredPlugin, + contribution: PluginThemeContribution, + kind: string, + parse: (text: string) => T +): Promise< + T & { + id: `plugin:${string}` + revision: string + pluginKey: string + pluginName: string + label: string + } +> { + const text = await readContainedPluginArtifactText( + plugin.rootDir, + contribution.path, + PLUGIN_THEME_MAX_BYTES + ) + try { + return { + ...parse(text), + id: `plugin:${plugin.pluginKey}/${contribution.id}`, + revision: createHash('sha256').update(text).digest('hex').slice(0, 32), + pluginKey: plugin.pluginKey, + pluginName: plugin.manifest.name, + label: contribution.label + } + } catch (error) { + throw new Error( + `${kind} "${contribution.id}" is invalid: ${error instanceof Error ? error.message : String(error)}` + ) + } +} + +export function createPluginThemeRegistry(contentVerifier: PluginContentVerifier) { + return new PluginArtifactPackRegistry({ + contentVerifier, + contributions: (plugin) => plugin.manifest.contributes.themes, + load: (plugin, contribution) => + readTheme(plugin, contribution, 'theme', parsePluginAppThemeArtifact) + }) +} + +export function createPluginTerminalThemeRegistry(contentVerifier: PluginContentVerifier) { + return new PluginArtifactPackRegistry({ + contentVerifier, + contributions: (plugin) => plugin.manifest.contributes.terminalThemes, + load: (plugin, contribution) => + readTheme(plugin, contribution, 'terminal theme', parsePluginTerminalThemeArtifact) + }) +} diff --git a/src/preload/api/plugin-host-api.ts b/src/preload/api/plugin-host-api.ts index 89165079495..9858f93c470 100644 --- a/src/preload/api/plugin-host-api.ts +++ b/src/preload/api/plugin-host-api.ts @@ -1,3 +1,5 @@ +import type { PluginThemeRegistration } from '../../shared/plugins/plugin-theme-artifact' +import type { PluginTerminalThemeRegistration } from '../../shared/plugins/plugin-terminal-theme-artifact' import type { PluginPanelActionOutcome, PluginPanelEntry @@ -139,6 +141,8 @@ export type PluginMarketplaceHostInstallPreview = { export type PluginsApi = { list: () => Promise listLanguagePacks: () => Promise + listThemes: () => Promise + listTerminalThemes: () => Promise /** Approval is bound to the current capability and trusted-worker fingerprint. */ consent: (args: PluginConsentRequest) => Promise setEnabled: (args: { pluginKey: string; enabled: boolean }) => Promise diff --git a/src/preload/api/plugins-bridge.ts b/src/preload/api/plugins-bridge.ts index 0e529e74d96..92b01be3dd5 100644 --- a/src/preload/api/plugins-bridge.ts +++ b/src/preload/api/plugins-bridge.ts @@ -16,6 +16,8 @@ import type { export const pluginsApi = { list: (): Promise => ipcRenderer.invoke('plugins:list'), listLanguagePacks: () => ipcRenderer.invoke('plugins:listLanguagePacks'), + listThemes: () => ipcRenderer.invoke('plugins:listThemes'), + listTerminalThemes: () => ipcRenderer.invoke('plugins:listTerminalThemes'), consent: (args: PluginConsentRequest): Promise => ipcRenderer.invoke('plugins:consent', args), setEnabled: (args: { pluginKey: string; enabled: boolean }): Promise => diff --git a/src/renderer/src/app-shell/use-document-appearance.test.tsx b/src/renderer/src/app-shell/use-document-appearance.test.tsx index b06b9c7517c..f61c2257c8d 100644 --- a/src/renderer/src/app-shell/use-document-appearance.test.tsx +++ b/src/renderer/src/app-shell/use-document-appearance.test.tsx @@ -3,14 +3,18 @@ import { act, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../shared/constants' import { useAppStore } from '../store' +import { usePluginThemeStore } from '../store/plugin-themes' const mocks = vi.hoisted(() => ({ applyDocumentTheme: vi.fn(), + applyPluginAppTheme: vi.fn(), buildAppFontFamily: vi.fn((fontFamily: string | null | undefined) => fontFamily ?? '') })) vi.mock('../lib/document-theme', () => ({ - applyDocumentTheme: mocks.applyDocumentTheme + applyDocumentTheme: mocks.applyDocumentTheme, + applyPluginAppTheme: mocks.applyPluginAppTheme, + readCachedPluginAppTheme: () => null })) vi.mock('@/lib/app-font-family', () => ({ @@ -75,4 +79,44 @@ describe('useDocumentAppearance', () => { expect(mocks.buildAppFontFamily).toHaveBeenCalledTimes(2) unmount() }) + + it('applies a selected plugin theme and restores the earlier choice when it disappears', async () => { + const midnight = { + id: 'plugin:samples.midnight/midnight' as const, + revision: 'r1', + pluginKey: 'samples.midnight', + pluginName: 'Midnight', + label: 'Midnight', + base: 'light' as const, + tokens: { '--background': '#fafafa' } + } + usePluginThemeStore.setState({ entries: [midnight], loaded: true }) + useAppStore.setState({ + updateSettings: async (updates) => { + const current = useAppStore.getState().settings ?? getDefaultSettings('/tmp') + useAppStore.setState({ settings: { ...current, ...updates } }) + } + }) + act(() => { + const settings = useAppStore.getState().settings! + useAppStore.setState({ + settings: { + ...settings, + theme: midnight.base, + themeBeforePlugin: 'dark', + pluginAppTheme: midnight.id + } + }) + }) + const { unmount } = renderHook(() => useDocumentAppearance()) + expect(mocks.applyPluginAppTheme).toHaveBeenLastCalledWith(midnight) + expect(mocks.applyDocumentTheme).toHaveBeenLastCalledWith('light') + + act(() => usePluginThemeStore.setState({ entries: [] })) + expect(mocks.applyPluginAppTheme).toHaveBeenLastCalledWith(null) + // The earlier built-in choice comes back with the built-in colors. + await vi.waitFor(() => expect(useAppStore.getState().settings?.theme).toBe('dark')) + unmount() + usePluginThemeStore.setState({ entries: [], loaded: false }) + }) }) diff --git a/src/renderer/src/app-shell/use-document-appearance.ts b/src/renderer/src/app-shell/use-document-appearance.ts index 25f20d89783..ed7c9b8795c 100644 --- a/src/renderer/src/app-shell/use-document-appearance.ts +++ b/src/renderer/src/app-shell/use-document-appearance.ts @@ -1,11 +1,25 @@ import { useEffect } from 'react' import { buildAppFontFamily } from '@/lib/app-font-family' -import { applyDocumentTheme } from '../lib/document-theme' +import { applyDocumentTheme, applyPluginAppTheme } from '../lib/document-theme' +import { + usePluginAppThemeSync, + usePluginTerminalPaletteRefresh, + usePluginThemeStore, + usePluginThemes +} from '../store/plugin-themes' import { scheduleRuntimeGraphSync } from '../runtime/sync-runtime-graph' import { useAppStore } from '../store' export function useDocumentAppearance(): void { + const selectedPluginTheme = useAppStore((s) => s.settings?.pluginAppTheme) + // Why find by id: a disabled or removed plugin drops out of the list, which restores built-in colors. + const pluginTheme = usePluginThemes().find((entry) => entry.id === selectedPluginTheme) ?? null + const pluginThemesLoaded = usePluginThemeStore((s) => s.loaded) + // Why settings.theme alone: choosing a plugin theme stores its base there, so every reader of + // the light/dark mode agrees with the painted colors. const theme = useAppStore((s) => s.settings?.theme) + usePluginTerminalPaletteRefresh() + usePluginAppThemeSync() const appFontFamily = useAppStore((s) => s.settings?.appFontFamily) useEffect(() => { @@ -31,6 +45,14 @@ export function useDocumentAppearance(): void { return () => mq.removeEventListener('change', handler) }, [theme]) + useEffect(() => { + // Why wait for the list: until it loads, the startup cache is still the right paint. + // Why theme too: contrast is checked against the built-ins of the current mode. + if (pluginThemesLoaded && theme) { + applyPluginAppTheme(pluginTheme) + } + }, [pluginTheme, pluginThemesLoaded, theme]) + useEffect(() => { document.documentElement.style.setProperty( '--app-font-family', diff --git a/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx b/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx index 3e5d495d993..d404ce83c3b 100644 --- a/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx +++ b/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx @@ -8,12 +8,7 @@ import { AppearanceAdvancedDisclosure } from './AppearanceAdvancedDisclosure' import { useAppStore } from '../../store' import { useShortcutKeyComboDetails } from '@/hooks/useShortcutLabel' import { ShortcutHintList } from './AppearanceShortcutHintList' -import { - FontAutocomplete, - SettingsRow, - SettingsSegmentedControl, - SettingsSwitchRow -} from './SettingsFormControls' +import { FontAutocomplete, SettingsRow, SettingsSwitchRow } from './SettingsFormControls' import { DEFAULT_APP_FONT_FAMILY } from '../../../../shared/constants' import { getLanguageEntries, @@ -33,6 +28,7 @@ import { translate } from '@/i18n/i18n' import type { UiLanguage } from '../../../../shared/ui-language' import { matchesSettingsSearch, normalizeSettingsSearchQuery } from './settings-search' import { usePluginLanguagePacks } from '@/store/plugin-language-packs' +import { AppearanceThemeControl } from './AppearanceThemeControl' type AppearanceInterfaceSectionProps = { settings: GlobalSettings @@ -87,27 +83,11 @@ export function AppearanceInterfaceSection({ { - updateSettings({ theme: option }) - applyTheme(option) - }} - options={[ - { - value: 'system', - label: translate('auto.components.settings.AppearancePane.fb0e0b4453', 'System') - }, - { - value: 'dark', - label: translate('auto.components.settings.AppearancePane.7d26ccabe8', 'Dark') - }, - { - value: 'light', - label: translate('auto.components.settings.AppearancePane.fd89b5487c', 'Light') - } - ]} + } /> diff --git a/src/renderer/src/components/settings/AppearanceThemeControl.test.tsx b/src/renderer/src/components/settings/AppearanceThemeControl.test.tsx new file mode 100644 index 00000000000..ef69830631f --- /dev/null +++ b/src/renderer/src/components/settings/AppearanceThemeControl.test.tsx @@ -0,0 +1,104 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' +import { AppearanceThemeControl } from './AppearanceThemeControl' + +const themes = vi.hoisted((): { app: unknown[]; terminal: unknown[] } => ({ + app: [], + terminal: [] +})) +vi.mock('@/store/plugin-themes', () => ({ + usePluginThemes: () => themes.app, + usePluginTerminalThemes: () => themes.terminal +})) + +afterEach(() => { + cleanup() + themes.app = [] + themes.terminal = [] +}) + +const dusk = { + id: 'plugin:samples.dusk/dusk', + pluginKey: 'samples.dusk', + revision: 'r1', + pluginName: 'Dusk', + label: 'Dusk', + base: 'dark', + tokens: { '--background': '#1b1430', '--foreground': '#e8e0ff' } +} + +it('keeps the System/Dark/Light segments until a plugin offers a theme', () => { + render( + + ) + expect(screen.getByRole('radiogroup', { name: 'Theme' })).toBeDefined() +}) + +it('lists plugin themes in the one Theme select and offers the plugin terminal colors', () => { + themes.app = [dusk] + themes.terminal = [ + { + ...dusk, + mode: 'dark', + terminal: { background: '#1b1430', foreground: '#e8e0ff', red: '#f00' } + } + ] + const updateSettings = vi.fn() + render( + + ) + expect(screen.getByRole('combobox', { name: 'Theme' }).textContent).toContain('Dusk') + screen.getByRole('button', { name: 'Use its terminal colors' }).click() + expect(updateSettings).toHaveBeenCalledWith({ + terminalThemeDark: 'plugin:samples.dusk/dusk', + terminalPluginPalettes: [ + { + id: 'plugin:samples.dusk/dusk', + colors: { background: '#1b1430', foreground: '#e8e0ff', red: '#f00' } + } + ] + }) + // Overrides would otherwise mask the palette the user just chose. + expect(updateSettings.mock.calls[0]?.[0]).toHaveProperty('terminalColorOverrides', undefined) +}) + +it('remembers the built-in choice and names the publisher when theme names collide', async () => { + themes.app = [ + dusk, + { ...dusk, id: 'plugin:acme.dusk/dusk', pluginKey: 'acme.dusk', pluginName: 'Acme Dusk' } + ] + const updateSettings = vi.fn() + render( + + ) + screen.getByRole('combobox', { name: 'Theme' }).click() + const option = await screen.findByRole('option', { name: 'Dusk · acme' }) + option.click() + expect(updateSettings).toHaveBeenCalledWith({ + theme: 'dark', + pluginAppTheme: 'plugin:acme.dusk/dusk', + themeBeforePlugin: 'system' + }) +}) diff --git a/src/renderer/src/components/settings/AppearanceThemeControl.tsx b/src/renderer/src/components/settings/AppearanceThemeControl.tsx new file mode 100644 index 00000000000..c9febb8aa21 --- /dev/null +++ b/src/renderer/src/components/settings/AppearanceThemeControl.tsx @@ -0,0 +1,146 @@ +import type React from 'react' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { Button } from '../ui/button' +import { + Select, + SelectContent, + SelectItem, + SelectSeparator, + SelectTrigger, + SelectValue +} from '../ui/select' +import { SettingsSegmentedControl } from './SettingsFormControls' +import { translate } from '@/i18n/i18n' +import { usePluginTerminalThemes, usePluginThemes } from '@/store/plugin-themes' +import { withTerminalPluginPalettes } from '@/lib/terminal-plugin-palettes' + +type BuiltInTheme = GlobalSettings['theme'] + +type AppearanceThemeControlProps = { + label: string + settings: GlobalSettings + updateSettings: (updates: Partial) => void + applyTheme: (theme: BuiltInTheme) => void +} + +function builtInOptions(): { value: BuiltInTheme; label: string }[] { + return [ + { + value: 'system', + label: translate('auto.components.settings.AppearancePane.fb0e0b4453', 'System') + }, + { + value: 'dark', + label: translate('auto.components.settings.AppearancePane.7d26ccabe8', 'Dark') + }, + { + value: 'light', + label: translate('auto.components.settings.AppearancePane.fd89b5487c', 'Light') + } + ] +} + +/** One Theme choice: System, Dark and Light, then any enabled plugin themes. A plugin theme + * stores its base in `theme`, so every light/dark reader follows the painted colors. */ +export function AppearanceThemeControl({ + label, + settings, + updateSettings, + applyTheme +}: AppearanceThemeControlProps): React.JSX.Element { + const pluginThemes = usePluginThemes() + const pluginTerminalThemes = usePluginTerminalThemes() + const selectBuiltIn = (theme: BuiltInTheme): void => { + updateSettings({ theme, pluginAppTheme: null, themeBeforePlugin: null }) + applyTheme(theme) + } + if (pluginThemes.length === 0) { + return ( + + ) + } + const active = pluginThemes.find((theme) => theme.id === settings.pluginAppTheme) + const terminalSlot = + active?.base === 'light' && settings.terminalUseSeparateLightTheme + ? 'terminalThemeLight' + : 'terminalThemeDark' + const matchingTerminal = active + ? (pluginTerminalThemes.find( + (theme) => theme.pluginKey === active.pluginKey && theme.id === active.id + ) ?? pluginTerminalThemes.find((theme) => theme.pluginKey === active.pluginKey)) + : undefined + return ( +
+ + {matchingTerminal && settings[terminalSlot] !== matchingTerminal.id ? ( + + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/PluginMarketplaceBrowser.test.tsx b/src/renderer/src/components/settings/PluginMarketplaceBrowser.test.tsx index ad9e3ccc924..08ad725852a 100644 --- a/src/renderer/src/components/settings/PluginMarketplaceBrowser.test.tsx +++ b/src/renderer/src/components/settings/PluginMarketplaceBrowser.test.tsx @@ -72,7 +72,9 @@ const preview: PluginMarketplaceHostInstallPreview = { languagePacks: [], keybindings: [], vmRecipes: [], - agents: [] + agents: [], + themes: [], + terminalThemes: [] }, capabilities: [{ kind: 'workspace:read' }] } diff --git a/src/renderer/src/components/settings/TerminalThemePicker.tsx b/src/renderer/src/components/settings/TerminalThemePicker.tsx index 38a34639356..c1c6ffe9db2 100644 --- a/src/renderer/src/components/settings/TerminalThemePicker.tsx +++ b/src/renderer/src/components/settings/TerminalThemePicker.tsx @@ -70,6 +70,10 @@ export function ThemePicker({ themes: matchingThemes .filter((theme) => theme.group === 'imported') .slice(0, MAX_THEME_RESULTS) + }, + { + label: translate('auto.components.settings.SettingsFormControls.plugin_themes', 'Plugins'), + themes: matchingThemes.filter((theme) => theme.group === 'plugin').slice(0, MAX_THEME_RESULTS) } ].filter((group) => group.themes.length > 0) const visibleThemeCount = groupedThemes.reduce((sum, group) => sum + group.themes.length, 0) @@ -146,17 +150,24 @@ export function ThemePicker({ {theme.label} {theme.sourceLabel ? ( - {translate( - 'auto.components.settings.SettingsFormControls.imported_from', - 'Imported from {{value0}}', - { value0: theme.sourceLabel } - )} + {theme.group === 'plugin' + ? translate( + 'auto.components.settings.SettingsFormControls.from_plugin', + 'From {{value0}}', + { value0: theme.sourceLabel } + ) + : translate( + 'auto.components.settings.SettingsFormControls.imported_from', + 'Imported from {{value0}}', + { value0: theme.sourceLabel } + )} {theme.mode && theme.mode !== 'unknown' ? ` · ${theme.mode}` : ''} ) : null} - {/* Hide current-row swatches so the Current label cannot shift their grid. */} - {theme.group === 'imported' && + {/* Why: hide swatches on the current row so the color grid + doesn't shift left to make room for the "Current" label. */} + {theme.group !== 'built-in' && theme.previewTheme && selectedTheme !== theme.value ? ( diff --git a/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts b/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts index fed924cfceb..462e05c9526 100644 --- a/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts +++ b/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts @@ -5,6 +5,12 @@ import type { UseWarpThemeImportReturn } from './useWarpThemeImport' let themeTarget: 'dark' | 'light' | undefined = 'dark' +// Why: these tests drive hooks through a stubbed React, so the plugin theme store is stubbed too. +vi.mock('@/store/plugin-themes', () => ({ + usePluginTerminalThemes: () => [], + usePluginTerminalThemeStore: { getState: () => ({ entries: [] }) } +})) + vi.mock('react', async () => { const actual = await vi.importActual('react') return { diff --git a/src/renderer/src/components/settings/TerminalThemeSections.tsx b/src/renderer/src/components/settings/TerminalThemeSections.tsx index c64c6272873..abea84bc6f9 100644 --- a/src/renderer/src/components/settings/TerminalThemeSections.tsx +++ b/src/renderer/src/components/settings/TerminalThemeSections.tsx @@ -1,3 +1,5 @@ +import { usePluginTerminalThemes } from '@/store/plugin-themes' +import { withTerminalPluginPalettes } from '@/lib/terminal-plugin-palettes' import { useState, type Dispatch, type ReactNode, type SetStateAction } from 'react' import type { GlobalSettings } from '../../../../shared/global-settings-types' import { @@ -95,7 +97,8 @@ export function TerminalThemeCatalogSection({ rememberTerminalThemeTarget(nextTarget) setTargetState(nextTarget) } - const themeOptions = getAvailableTerminalThemeOptions(settings) + const pluginTerminalThemes = usePluginTerminalThemes() + const themeOptions = getAvailableTerminalThemeOptions(settings, pluginTerminalThemes) const isLightTarget = target === 'light' const matchDarkMode = !settings.terminalUseSeparateLightTheme const lightModeMatchesDark = isLightTarget && matchDarkMode @@ -255,15 +258,14 @@ export function TerminalThemeCatalogSection({ const hasColorOverrides = Object.keys(settings.terminalColorOverrides ?? {}).length > 0 updateSettings( - isLightTarget - ? { - terminalThemeLight: theme, - ...(hasColorOverrides ? { terminalColorOverrides: undefined } : {}) - } - : { - terminalThemeDark: theme, - ...(hasColorOverrides ? { terminalColorOverrides: undefined } : {}) - } + withTerminalPluginPalettes( + settings, + { + [isLightTarget ? 'terminalThemeLight' : 'terminalThemeDark']: theme, + ...(hasColorOverrides ? { terminalColorOverrides: undefined } : {}) + }, + pluginTerminalThemes + ) ) }} importedHighlightSignal={importedHighlightSignal} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index de2aff446fd..ecfc0b5c1d8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -8355,7 +8355,9 @@ "fbb428db98": "Selected:", "search_terminal_themes": "Search terminal themes", "cb330ef7f8": " of {{value0}}", - "c822571b2e": " matching \"{{value0}}\"" + "c822571b2e": " matching \"{{value0}}\"", + "plugin_themes": "Plugins", + "from_plugin": "From {{value0}}" }, "SettingsSidebar": { "e0900f83e7": "SSH", @@ -12496,6 +12498,9 @@ "shortcut": "shortcut" } }, + "AppearanceThemeControl": { + "useTerminalTheme": "Use its terminal colors" + }, "notification": { "settings": { "copy": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 5359668998b..c9e7cb8046c 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -7944,7 +7944,9 @@ "builtin_themes": "Integrados", "imported_from": "Importado desde {{value0}}", "imported_themes": "Importados", - "search_terminal_themes": "Buscar temas de terminal" + "search_terminal_themes": "Buscar temas de terminal", + "plugin_themes": "Plugins", + "from_plugin": "De {{value0}}" }, "SettingsSidebar": { "e0900f83e7": "SSH", @@ -12094,6 +12096,9 @@ "noMatches": "No hay compilaciones coincidentes.", "searchPlaceholder": "Buscar compilaciones…" }, + "AppearanceThemeControl": { + "useTerminalTheme": "Usar sus colores de terminal" + }, "notification": { "settings": { "copy": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index a243674e670..6584a9b95cc 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -8168,7 +8168,9 @@ "fbb428db98": "Sélectionné :", "search_terminal_themes": "Rechercher des thèmes de terminal", "cb330ef7f8": " sur {{value0}}", - "c822571b2e": " correspondant à \"{{value0}}\"" + "c822571b2e": " correspondant à \"{{value0}}\"", + "plugin_themes": "Plugins", + "from_plugin": "De {{value0}}" }, "SettingsSidebar": { "e0900f83e7": "SSH", @@ -12280,6 +12282,9 @@ "searchLabel": "Rechercher des versions", "noMatches": "Aucune version correspondante." }, + "AppearanceThemeControl": { + "useTerminalTheme": "Utiliser ses couleurs de terminal" + }, "notification": { "settings": { "copy": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 310c36ccf94..2bcb7ea6671 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -7949,7 +7949,9 @@ "fbb428db98": "選択済み:", "search_terminal_themes": "ターミナルテーマを検索", "cb330ef7f8": "/{{value0}}", - "c822571b2e": " 「{{value0}}」に一致" + "c822571b2e": " 「{{value0}}」に一致", + "plugin_themes": "プラグイン", + "from_plugin": "{{value0}} から" }, "SettingsSidebar": { "e0900f83e7": "SSH", @@ -12077,6 +12079,9 @@ "searchPlaceholder": "ビルドを検索…", "noMatches": "一致するビルドはありません。" }, + "AppearanceThemeControl": { + "useTerminalTheme": "このテーマのターミナル配色を使う" + }, "notification": { "settings": { "copy": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a6865420000..a974fd6f6ce 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -7912,7 +7912,9 @@ "builtin_themes": "기본 제공", "imported_from": "{{value0}}에서 가져옴", "imported_themes": "가져옴", - "search_terminal_themes": "터미널 테마 검색" + "search_terminal_themes": "터미널 테마 검색", + "plugin_themes": "플러그인", + "from_plugin": "{{value0}}에서" }, "SettingsSidebar": { "e0900f83e7": "SSH", @@ -12077,6 +12079,9 @@ "searchPlaceholder": "빌드 검색…", "searchLabel": "빌드 검색" }, + "AppearanceThemeControl": { + "useTerminalTheme": "이 테마의 터미널 색상 사용" + }, "notification": { "settings": { "copy": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index ba72a57f72b..95d886d0751 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -7912,7 +7912,9 @@ "builtin_themes": "内置", "imported_from": "从 {{value0}} 导入", "imported_themes": "已导入", - "search_terminal_themes": "搜索终端主题" + "search_terminal_themes": "搜索终端主题", + "plugin_themes": "插件", + "from_plugin": "来自 {{value0}}" }, "SettingsSidebar": { "e0900f83e7": "SSH", @@ -12110,6 +12112,9 @@ "noMatches": "没有匹配的版本。", "searchLabel": "搜索构建" }, + "AppearanceThemeControl": { + "useTerminalTheme": "使用其终端配色" + }, "notification": { "settings": { "copy": { diff --git a/src/renderer/src/lib/document-theme.test.ts b/src/renderer/src/lib/document-theme.test.ts index 759f4ceb12c..2deacee4c66 100644 --- a/src/renderer/src/lib/document-theme.test.ts +++ b/src/renderer/src/lib/document-theme.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from 'vitest' -import { applyDocumentTheme, THEME_TRANSITION_DISABLED_CLASS } from './document-theme' +import { parsePluginAppThemeArtifact } from '../../../shared/plugins/plugin-theme-artifact' +import { + applyCachedPluginAppTheme, + applyDocumentTheme, + applyPluginAppTheme, + readCachedPluginAppTheme, + THEME_TRANSITION_DISABLED_CLASS +} from './document-theme' class FakeClassList { private readonly tokens = new Set() @@ -118,3 +125,173 @@ describe('document theme', () => { expect(frames.pendingCount()).toBe(0) }) }) + +describe('applyPluginAppTheme', () => { + function fakeRoot() { + const properties = new Map() + return { + properties, + style: { + setProperty: (name: string, value: string | null) => { + properties.set(name, value ?? '') + }, + removeProperty: (name: string) => { + const previous = properties.get(name) ?? '' + properties.delete(name) + return previous + } + } + } + } + function memoryStorage(): Storage { + const values = new Map() + return { + get length() { + return values.size + }, + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => [...values.keys()][index] ?? null, + removeItem: (key) => { + values.delete(key) + }, + setItem: (key, value) => { + values.set(key, value) + } + } + } + // Test probe: #rrggbb or #rrggbbaa. + const probe = (value: string) => { + const match = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})?$/i.exec(value) + if (!match) { + return null + } + const [r = 0, g = 0, b = 0] = match.slice(1, 4).map((hex) => Number.parseInt(hex, 16)) + return [r, g, b, Number.parseInt(match[4] ?? 'ff', 16) / 255] as const + } + // Like the browser, resolves color-mix(in srgb, A N%, B) for derived tokens. + const mixingProbe = (value: string) => { + const mix = /^color-mix\(in srgb, (#[0-9a-f]{6}) (\d+)%, (#[0-9a-f]{6})\)$/i.exec(value) + if (!mix) { + return probe(value) + } + const a = probe(mix[1] ?? '') + const b = probe(mix[3] ?? '') + const weight = Number(mix[2]) / 100 + if (!a || !b) { + return null + } + const channel = (i: number) => (a[i] ?? 0) * weight + (b[i] ?? 0) * (1 - weight) + return [channel(0), channel(1), channel(2), 1] as const + } + // Orca's dark built-ins, as the document would report them. + const darkBuiltIns = () => ({ + '--background': '#0a0a0a', + '--foreground': '#fafafa', + '--muted-foreground': '#a1a1a1', + '--border': '#ffffff12', + '--ring': '#737373' + }) + const options = (root = fakeRoot()) => ({ root, probe, builtIns: darkBuiltIns, storage: null }) + const theme = (tokens: Record) => ({ + id: 'plugin:samples.midnight/midnight' as const, + revision: 'r1', + pluginKey: 'samples.midnight', + pluginName: 'Midnight', + label: 'Midnight', + base: 'dark' as const, + tokens + }) + const painted = (tokens: Record) => { + const root = fakeRoot() + applyPluginAppTheme(theme(tokens), options(root)) + return Object.fromEntries(root.properties) + } + + it('overlays tokens, drops ones a new theme stops setting, and restores built-ins on removal', () => { + const root = fakeRoot() + applyPluginAppTheme(theme({ '--background': '#000000', '--border': '#111111' }), options(root)) + expect(Object.fromEntries(root.properties)).toEqual({ + '--background': '#000000', + '--border': '#111111' + }) + applyPluginAppTheme(theme({ '--background': '#222222' }), options(root)) + expect(Object.fromEntries(root.properties)).toEqual({ '--background': '#222222' }) + applyPluginAppTheme(null, options(root)) + expect(root.properties.size).toBe(0) + }) + + it('checks contrast on the effective set, so dropping one pair cannot expose another', () => { + // White background passes with black text, but fails against muted text; dropping the + // background leaves black text on the dark built-in background, so that drops too. + expect( + painted({ + '--background': '#ffffff', + '--foreground': '#000000', + '--muted-foreground': '#ffffff' + }) + ).toEqual({}) + }) + + it('checks plugin colors against the built-ins they sit on', () => { + // A light plugin surface would put the dark built-in light text on near-white. + expect(painted({ '--background': '#f4f4f4' })).toEqual({}) + expect(painted({ '--background': '#151520' })).toEqual({ '--background': '#151520' }) + }) + + it('keeps text readable on the editor surface notebooks paint on', () => { + const tokens = { '--background': '#000000', '--foreground': '#ffffff' } + expect(painted({ ...tokens, '--editor-surface': '#ffffff' })).not.toHaveProperty( + '--editor-surface' + ) + expect(painted({ ...tokens, '--editor-surface': '#111111' })).toHaveProperty( + '--editor-surface', + '#111111' + ) + }) + + it('rejects invisible rings and borders in any spelling', () => { + expect( + painted({ '--ring': '#0a0a0a', '--border': '#00000000', '--input': '#ffffff03' }) + ).toEqual({}) + expect(painted({ '--ring': '#ff2bd6', '--border': '#ffffff1a' })).toEqual({ + '--ring': '#ff2bd6', + '--border': '#ffffff1a' + }) + }) + + it('keeps the startup cache tied to the theme id and revision until cleared', () => { + const storage = memoryStorage() + applyPluginAppTheme(theme({ '--background': '#101018' }), { ...options(), storage }) + expect(readCachedPluginAppTheme(storage)).toEqual({ + id: 'plugin:samples.midnight/midnight', + revision: 'r1', + base: 'dark', + tokens: { '--background': '#101018' } + }) + const startup = fakeRoot() + const cached = readCachedPluginAppTheme(storage) + if (cached) { + applyCachedPluginAppTheme(cached, { root: startup, probe, builtIns: darkBuiltIns }) + } + expect(startup.properties.get('--background')).toBe('#101018') + + applyPluginAppTheme(null, { ...options(), storage }) + expect(readCachedPluginAppTheme(storage)).toBeNull() + }) + + it('paints a minimal two-token theme with readable derived muted text, borders and accents', () => { + const { tokens } = parsePluginAppThemeArtifact( + JSON.stringify({ + base: 'dark', + tokens: { '--background': '#1b1430', '--foreground': '#e8e0ff' } + }) + ) + const root = fakeRoot() + applyPluginAppTheme(theme(tokens), { ...options(root), probe: mixingProbe }) + for (const token of ['--muted-foreground', '--border', '--accent', '--sidebar-accent']) { + expect(root.properties.get(token)).toMatch(/^color-mix/) + } + expect(root.properties.get('--background')).toBe('#1b1430') + }) +}) diff --git a/src/renderer/src/lib/document-theme.ts b/src/renderer/src/lib/document-theme.ts index 9354caf0590..3e6a20a7aa3 100644 --- a/src/renderer/src/lib/document-theme.ts +++ b/src/renderer/src/lib/document-theme.ts @@ -1,4 +1,9 @@ import type { GlobalSettings } from '../../../shared/global-settings-types' +import { + PLUGIN_APP_THEME_TOKENS, + type PluginThemeRegistration +} from '../../../shared/plugins/plugin-theme-artifact' +import { browserColorProbe, safePluginThemeTokens, type ColorProbe } from './plugin-theme-tokens' export type DocumentThemePreference = GlobalSettings['theme'] @@ -95,3 +100,132 @@ export function applyDocumentTheme( }) pendingTransitionDisableFrames.push(firstFrame) } + +type PluginThemeRoot = { + style: Pick +} + +export type CachedPluginAppTheme = { + id: string + revision: string + base: 'light' | 'dark' + tokens: Record +} + +type PluginThemePaintOptions = { + root?: PluginThemeRoot + probe?: ColorProbe + /** Built-in token values for the current light/dark mode; read from the document by default. */ + builtIns?: () => Record +} + +const PUBLIC_TOKENS = new Set(PLUGIN_APP_THEME_TOKENS) +const appliedPluginTokens = new WeakMap() +// Why: plugin themes load after discovery; repainting from this cache before first paint +// avoids flashing the built-in theme on every launch. +const PLUGIN_THEME_CACHE_KEY = 'orca.pluginAppTheme.lastApplied' + +function setPluginTokens(root: PluginThemeRoot, tokens: Record): void { + for (const token of appliedPluginTokens.get(root) ?? []) { + if (!tokens[token]) { + root.style.removeProperty(token) + } + } + for (const [token, value] of Object.entries(tokens)) { + root.style.setProperty(token, value) + } + appliedPluginTokens.set(root, Object.keys(tokens)) +} + +function documentBuiltIns(): Record { + const style = getComputedStyle(document.documentElement) + return Object.fromEntries( + PLUGIN_APP_THEME_TOKENS.map((token) => [token, style.getPropertyValue(token).trim()]) + ) +} + +/** Paints tokens that stay readable over this mode's built-ins; returns what was painted. */ +function paintPluginTokens( + tokens: Record, + options: PluginThemePaintOptions +): Record { + const root = options.root ?? document.documentElement + // Why clear first: built-ins must be read without the previous theme's overrides. + setPluginTokens(root, {}) + const safe = safePluginThemeTokens( + tokens, + (options.builtIns ?? documentBuiltIns)(), + options.probe ?? browserColorProbe + ) + setPluginTokens(root, safe) + return safe +} + +function writeCache(storage: Storage | null, cached: CachedPluginAppTheme | null): void { + try { + if (cached) { + storage?.setItem(PLUGIN_THEME_CACHE_KEY, JSON.stringify(cached)) + } else { + storage?.removeItem(PLUGIN_THEME_CACHE_KEY) + } + } catch { + // Storage can be full or unavailable; the cache only shortens startup flashes. + } +} + +/** Overlays a plugin theme's safe tokens; tokens it no longer sets fall back to main.css. Call + * after the document's light/dark mode matches the theme's base. */ +export function applyPluginAppTheme( + theme: PluginThemeRegistration | null, + options: PluginThemePaintOptions & { storage?: Storage | null } = {} +): void { + const storage = options.storage === undefined ? globalThis.localStorage : options.storage + if (!theme) { + setPluginTokens(options.root ?? document.documentElement, {}) + writeCache(storage, null) + return + } + const tokens = paintPluginTokens(theme.tokens, options) + writeCache(storage, { id: theme.id, revision: theme.revision, base: theme.base, tokens }) +} + +/** The last applied plugin theme, for painting before settings and plugins load. */ +export function readCachedPluginAppTheme( + storage: Storage | null = globalThis.localStorage ?? null +): CachedPluginAppTheme | null { + try { + const cached: unknown = JSON.parse(storage?.getItem(PLUGIN_THEME_CACHE_KEY) ?? 'null') + if ( + typeof cached !== 'object' || + cached === null || + !('id' in cached) || + typeof cached.id !== 'string' || + !('revision' in cached) || + typeof cached.revision !== 'string' || + !('base' in cached) || + (cached.base !== 'light' && cached.base !== 'dark') || + !('tokens' in cached) || + typeof cached.tokens !== 'object' || + cached.tokens === null + ) { + return null + } + const tokens = Object.fromEntries( + Object.entries(cached.tokens).filter( + (entry): entry is [string, string] => + PUBLIC_TOKENS.has(entry[0]) && typeof entry[1] === 'string' + ) + ) + return { id: cached.id, revision: cached.revision, base: cached.base, tokens } + } catch { + return null + } +} + +/** Repaints a cached theme at startup, re-checked against this mode's built-ins. */ +export function applyCachedPluginAppTheme( + cached: CachedPluginAppTheme, + options: PluginThemePaintOptions = {} +): void { + paintPluginTokens(cached.tokens, options) +} diff --git a/src/renderer/src/lib/plugin-theme-tokens.ts b/src/renderer/src/lib/plugin-theme-tokens.ts new file mode 100644 index 00000000000..86c0a9d4ad7 --- /dev/null +++ b/src/renderer/src/lib/plugin-theme-tokens.ts @@ -0,0 +1,146 @@ +/** sRGB channels 0-255 and alpha 0-1, or null when the value is not a CSS color. */ +export type ColorProbe = (value: string) => readonly [number, number, number, number] | null +type Rgba = readonly [number, number, number, number] + +// Why: a theme must not be able to hide text, so every text/surface pair keeps WCAG AA contrast. +const CONTRAST_PAIRS: readonly (readonly [string, string])[] = [ + ['--background', '--foreground'], + ['--background', '--muted-foreground'], + // Notebook markdown renders directly on the editor surface. + ['--editor-surface', '--foreground'], + ['--editor-surface', '--muted-foreground'], + ['--card', '--card-foreground'], + ['--popover', '--popover-foreground'], + ['--primary', '--primary-foreground'], + ['--secondary', '--secondary-foreground'], + ['--muted', '--muted-foreground'], + ['--accent', '--accent-foreground'], + ['--sidebar', '--sidebar-foreground'], + ['--sidebar-primary', '--sidebar-primary-foreground'], + ['--sidebar-accent', '--sidebar-accent-foreground'], + ['--worktree-sidebar', '--worktree-sidebar-foreground'], + ['--worktree-sidebar-accent', '--worktree-sidebar-accent-foreground'] +] +const MIN_TEXT_CONTRAST = 4.5 +// Why 2:1, not WCAG's 3:1: Orca's own light ring is 2.6:1; the goal is a ring that never vanishes. +const RINGS: readonly (readonly [string, string])[] = [ + ['--ring', '--background'], + ['--sidebar-ring', '--sidebar'], + ['--worktree-sidebar-ring', '--worktree-sidebar'] +] +const MIN_RING_CONTRAST = 2 +// Why a floor, not opaque: Orca's own dark borders are 7% white; 0 or near-0 alpha hides them. +const BORDERS = ['--border', '--input', '--sidebar-border', '--worktree-sidebar-border'] +const MIN_BORDER_ALPHA = 0.05 + +function luminance([r, g, b]: Rgba): number { + const channel = (value: number): number => { + const c = value / 255 + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 + } + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b) +} + +function contrast(a: Rgba, b: Rgba): number { + const first = luminance(a) + const second = luminance(b) + return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05) +} + +function over([r, g, b, a]: Rgba, [br, bg, bb]: Rgba): Rgba { + return [r * a + br * (1 - a), g * a + bg * (1 - a), b * a + bb * (1 - a), 1] +} + +/** + * The plugin tokens that are safe to paint over the built-in ones for the theme's base. + * Checks run on the effective set (plugin values, else built-in), and dropping a token can + * expose a built-in value that breaks another pair, so checks repeat until nothing changes. + * Dropped tokens keep their built-in values. + */ +export function safePluginThemeTokens( + tokens: Readonly>, + builtIns: Readonly>, + probe: ColorProbe +): Record { + const accepted = new Map() + for (const [token, value] of Object.entries(tokens)) { + const color = probe(value) + if (color && !(BORDERS.includes(token) && color[3] < MIN_BORDER_ALPHA)) { + accepted.set(token, color) + } + } + const builtInColors = new Map() + const effective = (token: string): Rgba | null => { + const own = accepted.get(token) + if (own) { + return own + } + if (!builtInColors.has(token)) { + builtInColors.set(token, builtIns[token] ? probe(builtIns[token]) : null) + } + return builtInColors.get(token) ?? null + } + const drop = (...pair: string[]): boolean => + pair.map((token) => accepted.delete(token)).some(Boolean) + + for (let changed = true; changed;) { + changed = false + for (const [surface, text] of CONTRAST_PAIRS) { + const surfaceColor = effective(surface) + const textColor = effective(text) + if ( + surfaceColor && + textColor && + (surfaceColor[3] < 1 || + textColor[3] < 1 || + contrast(surfaceColor, textColor) < MIN_TEXT_CONTRAST) + ) { + changed = drop(surface, text) || changed + } + } + for (const [ring, surface] of RINGS) { + const ringColor = accepted.get(ring) + const surfaceColor = effective(surface) + if ( + ringColor && + surfaceColor && + contrast(over(ringColor, surfaceColor), surfaceColor) < MIN_RING_CONTRAST + ) { + changed = drop(ring) || changed + } + } + } + return Object.fromEntries(Object.entries(tokens).filter(([token]) => accepted.has(token))) +} + +let probeContext: OffscreenCanvasRenderingContext2D | null | undefined + +/** Resolves a color through the browser's own parser by painting one pixel. */ +export const browserColorProbe: ColorProbe = (value) => { + if (typeof CSS === 'undefined' || !CSS.supports('color', value)) { + return null + } + probeContext ??= + typeof OffscreenCanvas === 'undefined' + ? null + : new OffscreenCanvas(1, 1).getContext('2d', { willReadFrequently: true }) + if (!probeContext) { + return null + } + const context = probeContext + // Why two seeds: fillStyle silently keeps its old value for values it rejects (e.g. `inherit`). + const paint = (seed: string): number[] => { + context.clearRect(0, 0, 1, 1) + context.fillStyle = seed + context.fillStyle = value + context.fillRect(0, 0, 1, 1) + return [...context.getImageData(0, 0, 1, 1).data] + } + const first = paint('#000000') + const second = paint('#ffffff') + if (first.some((channel, index) => channel !== second[index])) { + return null + } + const [r = 0, g = 0, b = 0, a = 0] = first + return [r, g, b, a / 255] +} diff --git a/src/renderer/src/lib/terminal-plugin-palettes.test.ts b/src/renderer/src/lib/terminal-plugin-palettes.test.ts new file mode 100644 index 00000000000..eb8eda446d1 --- /dev/null +++ b/src/renderer/src/lib/terminal-plugin-palettes.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { + refreshedTerminalPluginPalettes, + withTerminalPluginPalettes +} from './terminal-plugin-palettes' + +const dusk = { + id: 'plugin:samples.dusk/dusk' as const, + pluginKey: 'samples.dusk', + pluginName: 'Dusk', + label: 'Dusk', + mode: 'dark' as const, + terminal: { background: '#1b1430', foreground: '#e8e0ff', red: '#ff6b8b' } +} +const base = { + terminalThemeDark: 'Builtin Dark', + terminalThemeLight: '', + terminalPluginPalettes: [] +} + +describe('terminal plugin palette snapshots', () => { + it('saves the palette of a newly selected plugin theme and drops unselected ones', () => { + const selected = withTerminalPluginPalettes(base, { terminalThemeDark: dusk.id }, [dusk]) + expect(selected.terminalPluginPalettes).toEqual([{ id: dusk.id, colors: dusk.terminal }]) + + const cleared = withTerminalPluginPalettes( + { + ...base, + terminalThemeDark: dusk.id, + terminalPluginPalettes: selected.terminalPluginPalettes + }, + { terminalThemeDark: 'Builtin Dark' }, + [dusk] + ) + expect(cleared.terminalPluginPalettes).toEqual([]) + }) + + it('refreshes after a plugin update and keeps the last palette while the plugin is off', () => { + const settings = { + ...base, + terminalThemeDark: dusk.id, + terminalPluginPalettes: [{ id: dusk.id, colors: dusk.terminal }] + } + expect(refreshedTerminalPluginPalettes(settings, [dusk])).toBeNull() + expect(refreshedTerminalPluginPalettes(settings, [])).toBeNull() + const updated = { ...dusk, terminal: { ...dusk.terminal, red: '#ff0000' } } + expect(refreshedTerminalPluginPalettes(settings, [updated])).toEqual({ + terminalPluginPalettes: [{ id: dusk.id, colors: updated.terminal }] + }) + }) +}) diff --git a/src/renderer/src/lib/terminal-plugin-palettes.ts b/src/renderer/src/lib/terminal-plugin-palettes.ts new file mode 100644 index 00000000000..a74b6ca0731 --- /dev/null +++ b/src/renderer/src/lib/terminal-plugin-palettes.ts @@ -0,0 +1,53 @@ +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { TerminalPluginPalette } from '../../../shared/terminal-color-overrides' +import type { PluginTerminalThemeRegistration } from '../../../shared/plugins/plugin-terminal-theme-artifact' + +type PaletteSettings = Pick< + GlobalSettings, + 'terminalThemeDark' | 'terminalThemeLight' | 'terminalPluginPalettes' +> + +function selectedPalettes( + dark: string, + light: string, + current: readonly TerminalPluginPalette[], + pluginThemes: readonly PluginTerminalThemeRegistration[] +): TerminalPluginPalette[] { + return [...new Set([dark, light])].flatMap((selection) => { + const theme = pluginThemes.find((candidate) => candidate.id === selection) + // Why keep the old snapshot: a disabled plugin's palette keeps painting the terminal it chose. + const palette = theme + ? { id: theme.id, colors: theme.terminal } + : current.find((entry) => entry.id === selection) + return palette ? [palette] : [] + }) +} + +/** Adds the palette snapshot a terminal theme selection needs, so every host resolves it. */ +export function withTerminalPluginPalettes( + settings: PaletteSettings, + update: Partial, + pluginThemes: readonly PluginTerminalThemeRegistration[] +): Partial { + const current = settings.terminalPluginPalettes ?? [] + const dark = update.terminalThemeDark ?? settings.terminalThemeDark + const light = update.terminalThemeLight ?? settings.terminalThemeLight + // Why: users who never pick a plugin theme keep their settings writes unchanged. + if (current.length === 0 && !dark.startsWith('plugin:') && !light.startsWith('plugin:')) { + return update + } + return { ...update, terminalPluginPalettes: selectedPalettes(dark, light, current, pluginThemes) } +} + +/** The snapshot refresh after a plugin updates a selected palette, or null when nothing changed. */ +export function refreshedTerminalPluginPalettes( + settings: PaletteSettings, + pluginThemes: readonly PluginTerminalThemeRegistration[] +): Partial | null { + const next = withTerminalPluginPalettes(settings, {}, pluginThemes) + return !next.terminalPluginPalettes || + JSON.stringify(next.terminalPluginPalettes) === + JSON.stringify(settings.terminalPluginPalettes ?? []) + ? null + : next +} diff --git a/src/renderer/src/lib/terminal-theme.test.ts b/src/renderer/src/lib/terminal-theme.test.ts index 94ffe0750a6..7257cb16bb7 100644 --- a/src/renderer/src/lib/terminal-theme.test.ts +++ b/src/renderer/src/lib/terminal-theme.test.ts @@ -337,3 +337,39 @@ describe('resolveOpaqueTerminalBackground', () => { expect(resolveOpaqueTerminalBackground('var(--background)')).toBe(null) }) }) + +describe('plugin terminal themes', () => { + const pluginTheme = { + id: 'plugin:samples.midnight/midnight' as const, + pluginKey: 'samples.midnight', + pluginName: 'Midnight', + label: 'Midnight', + mode: 'dark' as const, + terminal: { background: '#010203', foreground: '#fefefe', red: '#ff0000' } + } + const settings = { + theme: 'dark' as const, + terminalThemeDark: pluginTheme.id, + terminalDividerColorDark: '', + terminalUseSeparateLightTheme: false, + terminalThemeLight: '', + terminalCustomThemes: [], + terminalDividerColorLight: '', + terminalPluginPalettes: [{ id: pluginTheme.id, colors: pluginTheme.terminal }] + } + + it('paints a selected plugin theme from its saved palette and lists it by plugin name', () => { + expect(resolveEffectiveTerminalAppearance(settings, true).theme?.background).toBe('#010203') + expect(getAvailableTerminalThemeOptions(settings, [pluginTheme]).at(-1)).toMatchObject({ + value: pluginTheme.id, + group: 'plugin', + sourceLabel: 'Midnight' + }) + }) + + it('falls back to the default theme when no palette was saved', () => { + expect( + resolveEffectiveTerminalAppearance({ ...settings, terminalPluginPalettes: [] }, true).theme + ).toEqual(getBuiltinTheme(DEFAULT_TERMINAL_THEME_DARK)) + }) +}) diff --git a/src/renderer/src/lib/terminal-theme.ts b/src/renderer/src/lib/terminal-theme.ts index 9782ff16428..64d0a9c20f3 100644 --- a/src/renderer/src/lib/terminal-theme.ts +++ b/src/renderer/src/lib/terminal-theme.ts @@ -1,6 +1,7 @@ import type { ITheme } from '@xterm/xterm' import { getTheme, getThemeNames } from './terminal-themes-data' import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { PluginTerminalThemeRegistration } from '../../../shared/plugins/plugin-terminal-theme-artifact' import { makeCustomTerminalThemeSelection, normalizeTerminalCustomThemes, @@ -32,7 +33,7 @@ export type EffectiveTerminalAppearance = { export type TerminalThemeOption = { value: string label: string - group: 'built-in' | 'imported' + group: 'built-in' | 'imported' | 'plugin' sourceLabel?: string mode?: TerminalCustomTheme['mode'] previewTheme: ITheme | null @@ -50,7 +51,7 @@ export function getBuiltinTheme(name: string): ITheme | null { } export function getTerminalTheme( - settings: Pick | undefined, + settings: Pick | undefined, selection: string ): ITheme | null { return lookupTerminalTheme(settings, selection) @@ -58,7 +59,7 @@ export function getTerminalTheme( function getTerminalThemePreview( name: string, - settings?: Pick, + settings?: Pick, fallbackMode: 'dark' | 'light' = 'dark' ): ITheme | null { const theme = getTerminalTheme(settings, name) @@ -71,7 +72,8 @@ function getTerminalThemePreview( } export function getAvailableTerminalThemeOptions( - settings: Pick + settings: Pick, + pluginThemes: readonly PluginTerminalThemeRegistration[] = [] ): TerminalThemeOption[] { const builtinOptions = BUILTIN_TERMINAL_THEME_NAMES.map((name) => ({ value: name, @@ -90,7 +92,15 @@ export function getAvailableTerminalThemeOptions( previewTheme: terminalCustomThemeToXtermTheme(theme) }) ) - return [...builtinOptions, ...customOptions] + const pluginOptions = pluginThemes.map((theme) => ({ + value: theme.id, + label: theme.label, + group: 'plugin' as const, + sourceLabel: theme.pluginName, + mode: theme.mode, + previewTheme: theme.terminal + })) + return [...builtinOptions, ...customOptions, ...pluginOptions] } export function resolveEffectiveTerminalAppearance( @@ -102,6 +112,7 @@ export function resolveEffectiveTerminalAppearance( | 'terminalUseSeparateLightTheme' | 'terminalThemeLight' | 'terminalCustomThemes' + | 'terminalPluginPalettes' | 'terminalDividerColorLight' >, systemPrefersDark = getSystemPrefersDark() diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 3829302a41b..7ad07daab9b 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -12,7 +12,11 @@ import { recordRendererCrashBreadcrumb } from './lib/crash-diagnostics' import { installAutomationHostDiagnostic } from './components/automations/automation-host-diagnostics' -import { applyDocumentTheme } from './lib/document-theme' +import { + applyCachedPluginAppTheme, + applyDocumentTheme, + readCachedPluginAppTheme +} from './lib/document-theme' import { installTypingLatencyDiagnostic } from './lib/typing-latency/diagnostic' import { installAppKeyboardRouter } from './lib/app-keyboard-router' import { shouldEnableReactGrab } from './lib/react-grab-dev-gate' @@ -48,7 +52,11 @@ if ( void import('react-grab/styles.css') } -applyDocumentTheme('system', { disableTransitions: false }) +const cachedPluginTheme = readCachedPluginAppTheme() +applyDocumentTheme(cachedPluginTheme?.base ?? 'system', { disableTransitions: false }) +if (cachedPluginTheme) { + applyCachedPluginAppTheme(cachedPluginTheme) +} registerBrowserClientPageGuestMessageHandler(handleGuestWindowCloseMessage) const browserClientPageRenderer = installBrowserClientPageRenderer() import.meta.hot?.dispose(() => browserClientPageRenderer?.dispose()) diff --git a/src/renderer/src/store/plugin-themes.ts b/src/renderer/src/store/plugin-themes.ts new file mode 100644 index 00000000000..b2344644465 --- /dev/null +++ b/src/renderer/src/store/plugin-themes.ts @@ -0,0 +1,79 @@ +import { + isPluginThemeRegistration, + type PluginThemeRegistration +} from '../../../shared/plugins/plugin-theme-artifact' +import { + isPluginTerminalThemeRegistration, + type PluginTerminalThemeRegistration +} from '../../../shared/plugins/plugin-terminal-theme-artifact' +import { useEffect } from 'react' +import { createPluginContentListStore } from './plugin-content-list-store' +import { refreshedTerminalPluginPalettes } from '../lib/terminal-plugin-palettes' +import { applyPluginAppTheme, readCachedPluginAppTheme } from '../lib/document-theme' +import { useAppStore } from './index' + +const appThemes = createPluginContentListStore({ + kind: 'themes', + list: (api) => api.listThemes, + isEntry: isPluginThemeRegistration +}) + +const terminalThemes = createPluginContentListStore({ + kind: 'terminal themes', + list: (api) => api.listTerminalThemes, + isEntry: isPluginTerminalThemeRegistration +}) + +export const usePluginThemeStore = appThemes.useStore +export const usePluginThemes = appThemes.useEntries +export const usePluginTerminalThemes = terminalThemes.useEntries + +/** Keeps the saved snapshot of selected plugin terminal palettes current as plugins update. */ +export function usePluginTerminalPaletteRefresh(): void { + const pluginTerminalThemes = usePluginTerminalThemes() + const settings = useAppStore((s) => s.settings) + const updateSettings = useAppStore((s) => s.updateSettings) + useEffect(() => { + const update = settings && refreshedTerminalPluginPalettes(settings, pluginTerminalThemes) + if (update) { + void updateSettings(update) + } + }, [settings, pluginTerminalThemes, updateSettings]) +} + +function pluginKeyOf(id: string): string { + return id.slice('plugin:'.length, id.lastIndexOf('/')) +} + +/** Keeps `theme` equal to the selected plugin theme's base while it is enabled, restores the + * user's earlier choice while it is not, and drops a startup paint the settings no longer back. */ +export function usePluginAppThemeSync(): void { + const pluginThemes = usePluginThemes() + const loaded = usePluginThemeStore((s) => s.loaded) + const settings = useAppStore((s) => s.settings) + const updateSettings = useAppStore((s) => s.updateSettings) + const selected = settings?.pluginAppTheme ?? null + const disabled = + !selected || + settings?.pluginSystemEnabled === false || + (settings?.disabledPlugins ?? []).includes(pluginKeyOf(selected)) + + useEffect(() => { + const cached = readCachedPluginAppTheme() + // Why before the list loads: the settings already say whether the cached paint still applies. + if (settings && !loaded && cached && (cached.id !== selected || disabled)) { + applyPluginAppTheme(null) + } + }, [settings, loaded, selected, disabled]) + + useEffect(() => { + if (!settings || !loaded || !selected) { + return + } + const active = pluginThemes.find((theme) => theme.id === selected) + const target = active ? active.base : settings.themeBeforePlugin + if (target && settings.theme !== target) { + void updateSettings({ theme: target }) + } + }, [settings, loaded, selected, pluginThemes, updateSettings]) +} diff --git a/src/shared/default-global-settings.ts b/src/shared/default-global-settings.ts index af37a09cb4b..3862366b694 100644 --- a/src/shared/default-global-settings.ts +++ b/src/shared/default-global-settings.ts @@ -41,6 +41,7 @@ export function buildDefaultSettings(args: { branchPrefixCustom: '', jiraIssueKeyCasing: 'lowercase', theme: 'system', + pluginAppTheme: null, leftSidebarAppearanceMode: 'default', leftSidebarTintColor: DEFAULT_LEFT_SIDEBAR_TINT_COLOR, leftSidebarTintOpacity: DEFAULT_LEFT_SIDEBAR_TINT_OPACITY, diff --git a/src/shared/global-settings-base.ts b/src/shared/global-settings-base.ts index 1d2f2b48508..e177b06ae8e 100644 --- a/src/shared/global-settings-base.ts +++ b/src/shared/global-settings-base.ts @@ -4,6 +4,7 @@ import type { BrowserLinkOpeningSettings } from './browser-link-opening-settings import type { ClaudeAccountSwitchSettings } from './claude-account-switch-settings' import type { ExternalConversationSettings } from './external-conversation-settings' import type { NativeChatGlobalSettings } from './native-chat/native-chat-appearance-settings' +import type { PluginAppearanceSettings } from './plugin-appearance-settings-types' import type { WorktreeDeletionPreferences } from './worktree-deletion-preferences' import type { AgentPermissionGlobalSettings } from './tui-agent-permission-choice' import type { OrchestrationHeartbeatSettings } from './orchestration-heartbeat' @@ -17,4 +18,5 @@ export type GlobalSettingsBase = ClaudeAgentViewSettings & ExternalConversationSettings & BrowserLinkOpeningSettings & OrchestrationHeartbeatSettings & - BrowserExtensionGlobalSettings + BrowserExtensionGlobalSettings & + PluginAppearanceSettings diff --git a/src/shared/plugin-appearance-settings-types.ts b/src/shared/plugin-appearance-settings-types.ts new file mode 100644 index 00000000000..58f2f7e1d99 --- /dev/null +++ b/src/shared/plugin-appearance-settings-types.ts @@ -0,0 +1,12 @@ +import type { TerminalPluginPalette } from './terminal-color-overrides' + +/** Appearance choices that come from plugins. */ +export type PluginAppearanceSettings = { + /** A plugin theme's id; its colors replace the built-in ones and its base picks light or dark. */ + pluginAppTheme?: `plugin:${string}` | null + /** The built-in choice to return to when the plugin theme is off or removed. */ + themeBeforePlugin?: 'system' | 'dark' | 'light' | null + /** Palettes of the selected plugin terminal themes, so main, headless serve and relays + * resolve them without the plugin loaded. Only selected themes are kept. */ + terminalPluginPalettes?: TerminalPluginPalette[] +} diff --git a/src/shared/plugin-theme-settings.test.ts b/src/shared/plugin-theme-settings.test.ts new file mode 100644 index 00000000000..13197e58a7c --- /dev/null +++ b/src/shared/plugin-theme-settings.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import type { GlobalSettings } from './global-settings-types' +import { + normalizePluginThemeSettings, + pluginThemeSettingsAfterRemoval +} from './plugin-theme-settings' + +const colors = { background: '#1b1430', foreground: '#e8e0ff', red: '#ff6b8b' } + +describe('plugin theme settings', () => { + it('bounds what a client may write', () => { + expect( + normalizePluginThemeSettings({ + pluginAppTheme: 'plugin:samples.dusk/dusk', + themeBeforePlugin: 'system', + terminalPluginPalettes: [ + { id: 'plugin:samples.dusk/dusk', colors }, + { id: 'plugin:samples.dusk/dusk', colors }, + { id: 'not-a-plugin', colors }, + { id: 'plugin:samples.other/a', colors: { background: 'red;' } }, + { id: 'plugin:samples.b/b', colors }, + { id: 'plugin:samples.c/c', colors } + ] + }) + ).toEqual({ + pluginAppTheme: 'plugin:samples.dusk/dusk', + themeBeforePlugin: 'system', + terminalPluginPalettes: [ + { id: 'plugin:samples.dusk/dusk', colors }, + { id: 'plugin:samples.b/b', colors } + ] + }) + expect( + normalizePluginThemeSettings({ pluginAppTheme: 'plugin:../x', themeBeforePlugin: 'neon' }) + ).toEqual({ pluginAppTheme: null, themeBeforePlugin: null }) + const unrelated: Partial = { editorAutoSave: true } + expect(normalizePluginThemeSettings(unrelated)).toEqual({}) + }) + + it('forgets an uninstalled plugin and restores the earlier Theme choice', () => { + expect( + pluginThemeSettingsAfterRemoval( + { + theme: 'dark', + themeBeforePlugin: 'system', + pluginAppTheme: 'plugin:samples.dusk/dusk', + terminalThemeDark: 'plugin:samples.dusk/dusk', + terminalThemeLight: 'Builtin Tango Light', + terminalPluginPalettes: [{ id: 'plugin:samples.dusk/dusk', colors }] + }, + 'samples.dusk' + ) + ).toEqual({ + pluginAppTheme: null, + themeBeforePlugin: null, + theme: 'system', + terminalThemeDark: '', + terminalPluginPalettes: [] + }) + expect( + pluginThemeSettingsAfterRemoval( + { + theme: 'light', + pluginAppTheme: 'plugin:samples.dusk-two/dusk', + terminalThemeDark: '', + terminalThemeLight: '' + }, + 'samples.dusk' + ) + ).toEqual({}) + }) +}) diff --git a/src/shared/plugin-theme-settings.ts b/src/shared/plugin-theme-settings.ts new file mode 100644 index 00000000000..47bb8cb9050 --- /dev/null +++ b/src/shared/plugin-theme-settings.ts @@ -0,0 +1,98 @@ +import type { GlobalSettings } from './global-settings-types' +import type { TerminalPluginPalette } from './terminal-color-overrides' +import { + hasUsableTerminalThemeColors, + normalizeTerminalColorOverrides +} from './terminal-custom-themes' + +type PluginThemeSettings = Pick< + GlobalSettings, + 'pluginAppTheme' | 'themeBeforePlugin' | 'terminalPluginPalettes' +> + +// Two terminal slots (dark, light) can each hold one plugin palette. +const MAX_TERMINAL_PLUGIN_PALETTES = 2 +const PLUGIN_CONTENT_ID = + /^plugin:[a-z0-9][a-z0-9-]{0,63}\.[a-z0-9][a-z0-9-]{0,63}\/[a-z0-9][a-z0-9-]{0,63}$/ + +function contentId(value: unknown): `plugin:${string}` | null { + return typeof value === 'string' && PLUGIN_CONTENT_ID.test(value) + ? `plugin:${value.slice('plugin:'.length)}` + : null +} + +function palettes(value: unknown): TerminalPluginPalette[] { + if (!Array.isArray(value)) { + return [] + } + const result: TerminalPluginPalette[] = [] + for (const entry of value) { + const id = + typeof entry === 'object' && entry !== null && 'id' in entry ? contentId(entry.id) : null + const colors = normalizeTerminalColorOverrides( + typeof entry === 'object' && entry !== null && 'colors' in entry ? entry.colors : undefined + ) + if (id && hasUsableTerminalThemeColors(colors) && !result.some((p) => p.id === id)) { + result.push({ id, colors }) + } + } + return result.slice(0, MAX_TERMINAL_PLUGIN_PALETTES) +} + +/** Unvalidated plugin theme keys as a settings write or load carries them. */ +export type PluginThemeSettingsInput = { [Key in keyof PluginThemeSettings]?: unknown } + +/** Bounds plugin theme settings on every write and load; only keys present in `input` return. */ +export function normalizePluginThemeSettings( + input: PluginThemeSettingsInput +): Partial { + const result: Partial = {} + if ('pluginAppTheme' in input) { + result.pluginAppTheme = contentId(input.pluginAppTheme) + } + if ('themeBeforePlugin' in input) { + const theme = input.themeBeforePlugin + result.themeBeforePlugin = + theme === 'system' || theme === 'dark' || theme === 'light' ? theme : null + } + if ('terminalPluginPalettes' in input) { + result.terminalPluginPalettes = palettes(input.terminalPluginPalettes) + } + return result +} + +function ownedBy(id: string | null | undefined, pluginKey: string): boolean { + return typeof id === 'string' && id.startsWith(`plugin:${pluginKey}/`) +} + +/** Forgets an uninstalled plugin's theme choices so a reinstall does not quietly reapply them. */ +export function pluginThemeSettingsAfterRemoval( + settings: Pick< + GlobalSettings, + | 'theme' + | 'terminalThemeDark' + | 'terminalThemeLight' + | 'pluginAppTheme' + | 'themeBeforePlugin' + | 'terminalPluginPalettes' + >, + pluginKey: string +): Partial { + const update: Partial = {} + if (ownedBy(settings.pluginAppTheme, pluginKey)) { + update.pluginAppTheme = null + update.themeBeforePlugin = null + update.theme = settings.themeBeforePlugin ?? settings.theme + } + if (ownedBy(settings.terminalThemeDark, pluginKey)) { + update.terminalThemeDark = '' + } + if (ownedBy(settings.terminalThemeLight, pluginKey)) { + update.terminalThemeLight = '' + } + const kept = (settings.terminalPluginPalettes ?? []).filter((p) => !ownedBy(p.id, pluginKey)) + if (kept.length !== (settings.terminalPluginPalettes ?? []).length) { + update.terminalPluginPalettes = kept + } + return update +} diff --git a/src/shared/plugins/plugin-catalog-release.test.ts b/src/shared/plugins/plugin-catalog-release.test.ts index 3c5244a61e7..777b38d813c 100644 --- a/src/shared/plugins/plugin-catalog-release.test.ts +++ b/src/shared/plugins/plugin-catalog-release.test.ts @@ -74,7 +74,7 @@ describe('catalog releases', () => { it('rejects a release type its profile cannot run or this client cannot install', () => { expect(() => release({ types: ['worker'] })).toThrow('data cannot declare worker') expect( - pluginCatalogReleaseSchema.safeParse({ ...release(), types: ['app-theme'] }).success + pluginCatalogReleaseSchema.safeParse({ ...release(), types: ['icon-theme'] }).success ).toBe(false) }) diff --git a/src/shared/plugins/plugin-catalog-release.ts b/src/shared/plugins/plugin-catalog-release.ts index 23a474f8b8a..3e81621bf6d 100644 --- a/src/shared/plugins/plugin-catalog-release.ts +++ b/src/shared/plugins/plugin-catalog-release.ts @@ -10,6 +10,8 @@ import type { PluginManifest } from './plugin-manifest' // v2 catalog drops it instead of listing a plugin this client cannot install. export const PLUGIN_CATALOG_TYPES = [ 'interface-language', + 'app-theme', + 'terminal-theme', 'instructions', 'panel', 'worker' @@ -60,6 +62,12 @@ export function pluginCatalogTypes(manifest: PluginManifest): PluginCatalogType[ if (c.languagePacks.length > 0) { types.push('interface-language') } + if (c.themes.length > 0) { + types.push('app-theme') + } + if (c.terminalThemes.length > 0) { + types.push('terminal-theme') + } if (c.commands.length || c.keybindings.length || c.vmRecipes.length || c.agents.length) { types.push('instructions') } diff --git a/src/shared/plugins/plugin-content-pack-contributions.test.ts b/src/shared/plugins/plugin-content-pack-contributions.test.ts index 6e495899bbd..89a6a9e9a9b 100644 --- a/src/shared/plugins/plugin-content-pack-contributions.test.ts +++ b/src/shared/plugins/plugin-content-pack-contributions.test.ts @@ -79,7 +79,9 @@ describe('content-pack manifest contributions', () => { languagePacks: [], keybindings: [], vmRecipes: [], - agents: [] + agents: [], + themes: [], + terminalThemes: [] }) }) diff --git a/src/shared/plugins/plugin-content-pack-contributions.ts b/src/shared/plugins/plugin-content-pack-contributions.ts index 92f8cd1a98c..f57d09b8fb8 100644 --- a/src/shared/plugins/plugin-content-pack-contributions.ts +++ b/src/shared/plugins/plugin-content-pack-contributions.ts @@ -1,11 +1,17 @@ import { z } from 'zod' -import { pluginCommandIdSchema, pluginRelativePathSchema } from './plugin-manifest-fields' +import { + pluginCommandIdSchema, + pluginIdSchema, + pluginRelativePathSchema +} from './plugin-manifest-fields' import { normalizeKeybinding } from '../keybindings' export const PLUGIN_LANGUAGE_PACK_LIMIT = 16 export const PLUGIN_KEYBINDING_LIMIT = 256 export const PLUGIN_VM_RECIPE_LIMIT = 64 export const PLUGIN_AGENT_PROFILE_LIMIT = 64 +export const PLUGIN_THEME_LIMIT = 64 +export const PLUGIN_TERMINAL_THEME_LIMIT = 64 /** True when Intl can parse the tag's grammar, not just its shape. */ function isWellFormedLocaleId(value: string): boolean { @@ -62,7 +68,17 @@ export const pluginAgentProfileContributionSchema = z .object({ path: pluginRelativePathSchema }) .strict() +/** App and terminal themes share one shape: a labelled JSON artifact. */ +export const pluginThemeContributionSchema = z + .object({ + id: pluginIdSchema, + label: z.string().min(1).max(128), + path: pluginRelativePathSchema + }) + .strict() + export type PluginLanguagePackContribution = z.infer export type PluginKeybindingContribution = z.infer export type PluginVmRecipeContribution = z.infer export type PluginAgentProfileContribution = z.infer +export type PluginThemeContribution = z.infer diff --git a/src/shared/plugins/plugin-manifest-contribution-validation.ts b/src/shared/plugins/plugin-manifest-contribution-validation.ts index 3c06048269b..ffafab06537 100644 --- a/src/shared/plugins/plugin-manifest-contribution-validation.ts +++ b/src/shared/plugins/plugin-manifest-contribution-validation.ts @@ -15,6 +15,8 @@ type ContributionValidationManifest = { keybindings: { command: string; key: string; when?: 'global' | 'worktree' }[] vmRecipes: PathContribution[] agents: PathContribution[] + themes: IdentifiedContribution[] + terminalThemes: IdentifiedContribution[] } capabilities: { kind: string }[] } @@ -44,7 +46,7 @@ export function validatePluginManifestContributions( manifest: ContributionValidationManifest, ctx: RefinementCtx ): void { - for (const path of ['panels', 'commands'] as const) { + for (const path of ['panels', 'commands', 'themes', 'terminalThemes'] as const) { rejectDuplicateValues( manifest.contributes[path], (entry) => (entry as IdentifiedContribution).id, diff --git a/src/shared/plugins/plugin-manifest.ts b/src/shared/plugins/plugin-manifest.ts index c8139ad85f0..cd04b1a085e 100644 --- a/src/shared/plugins/plugin-manifest.ts +++ b/src/shared/plugins/plugin-manifest.ts @@ -4,10 +4,13 @@ import { PLUGIN_AGENT_PROFILE_LIMIT, PLUGIN_KEYBINDING_LIMIT, PLUGIN_LANGUAGE_PACK_LIMIT, + PLUGIN_TERMINAL_THEME_LIMIT, + PLUGIN_THEME_LIMIT, PLUGIN_VM_RECIPE_LIMIT, pluginAgentProfileContributionSchema, pluginKeybindingContributionSchema, pluginLanguagePackContributionSchema, + pluginThemeContributionSchema, pluginVmRecipeContributionSchema } from './plugin-content-pack-contributions' import { @@ -111,6 +114,11 @@ const pluginManifestV1Object = z.object({ agents: z .array(pluginAgentProfileContributionSchema) .max(PLUGIN_AGENT_PROFILE_LIMIT) + .default([]), + themes: z.array(pluginThemeContributionSchema).max(PLUGIN_THEME_LIMIT).default([]), + terminalThemes: z + .array(pluginThemeContributionSchema) + .max(PLUGIN_TERMINAL_THEME_LIMIT) .default([]) }) .strict() diff --git a/src/shared/plugins/plugin-marketplace.test.ts b/src/shared/plugins/plugin-marketplace.test.ts index 8effa173f89..798f0871bb3 100644 --- a/src/shared/plugins/plugin-marketplace.test.ts +++ b/src/shared/plugins/plugin-marketplace.test.ts @@ -181,8 +181,11 @@ describe('marketplace provenance contracts', () => { expect(isMarketplaceListingSupported(['vm-recipes', 'official'])).toBe(true) expect(isMarketplaceListingSupported(['keybindings'])).toBe(true) expect(isMarketplaceListingSupported([])).toBe(true) - for (const deferred of ['themes', 'icons', 'icon-themes', 'terminal-themes', 'skills']) { + for (const deferred of ['icons', 'icon-themes', 'skills']) { expect(isMarketplaceListingSupported([deferred, 'official'])).toBe(false) } + for (const supported of ['themes', 'terminal-themes']) { + expect(isMarketplaceListingSupported([supported, 'official'])).toBe(true) + } }) }) diff --git a/src/shared/plugins/plugin-marketplace.ts b/src/shared/plugins/plugin-marketplace.ts index 602770c8767..c2183823092 100644 --- a/src/shared/plugins/plugin-marketplace.ts +++ b/src/shared/plugins/plugin-marketplace.ts @@ -15,13 +15,12 @@ export const OFFICIAL_PLUGIN_ID_PREFIX = 'orca-' export const OFFICIAL_MARKETPLACE_OWNER = 'stablyai' export const OFFICIAL_MARKETPLACE_REPOSITORY = 'orca-plugins' -// Hide unsupported contribution categories still advertised by the externally maintained index; otherwise their manifests -// fail wholesale and present a dead install path until the marketplace catches up. +// Why: icon-theme and skill contributions are not supported, so a plugin declaring one fails +// to install. Shared catalogs this build does not control still list such packs; hide them so +// users never reach a dead install. export const UNSUPPORTED_MARKETPLACE_CATEGORIES: readonly string[] = [ - 'themes', 'icons', 'icon-themes', - 'terminal-themes', 'skills' ] diff --git a/src/shared/plugins/plugin-terminal-theme-artifact.ts b/src/shared/plugins/plugin-terminal-theme-artifact.ts new file mode 100644 index 00000000000..8d76a5c264b --- /dev/null +++ b/src/shared/plugins/plugin-terminal-theme-artifact.ts @@ -0,0 +1,53 @@ +import { z } from 'zod' +import { + TERMINAL_COLOR_KEYS, + hasUsableTerminalThemeColors, + normalizeTerminalColorOverrides +} from '../terminal-custom-themes' +import { pluginContentIdSchema } from './plugin-theme-artifact' + +const terminalColorKeySchema = z.enum(TERMINAL_COLOR_KEYS) +const terminalModeSchema = z.enum(['dark', 'light', 'unknown']) + +const pluginTerminalThemeArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + mode: terminalModeSchema.default('unknown'), + terminal: z.partialRecord(terminalColorKeySchema, z.string()) + }) + .strict() + +const pluginTerminalThemeRegistrationSchema = z.object({ + id: pluginContentIdSchema, + pluginKey: z.string(), + pluginName: z.string(), + label: z.string(), + mode: terminalModeSchema, + terminal: z.partialRecord(terminalColorKeySchema, z.string()) +}) + +export type PluginTerminalThemeRegistration = z.infer + +export function parsePluginTerminalThemeArtifact( + raw: string +): Pick { + const parsed = pluginTerminalThemeArtifactSchema.parse(JSON.parse(raw)) + const terminal = normalizeTerminalColorOverrides(parsed.terminal) + if (Object.keys(terminal).length !== Object.keys(parsed.terminal).length) { + throw new Error('terminal theme contains an invalid color') + } + if (!hasUsableTerminalThemeColors(terminal)) { + throw new Error('terminal theme requires background, foreground, and at least one ANSI color') + } + return { mode: parsed.mode, terminal } +} + +export function isPluginTerminalThemeRegistration( + value: unknown +): value is PluginTerminalThemeRegistration { + const parsed = pluginTerminalThemeRegistrationSchema.safeParse(value) + return ( + parsed.success && + hasUsableTerminalThemeColors(normalizeTerminalColorOverrides(parsed.data.terminal)) + ) +} diff --git a/src/shared/plugins/plugin-theme-artifact.test.ts b/src/shared/plugins/plugin-theme-artifact.test.ts new file mode 100644 index 00000000000..5356c4b4f7a --- /dev/null +++ b/src/shared/plugins/plugin-theme-artifact.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest' +import { isPluginThemeRegistration, parsePluginAppThemeArtifact } from './plugin-theme-artifact' +import { + isPluginTerminalThemeRegistration, + parsePluginTerminalThemeArtifact +} from './plugin-terminal-theme-artifact' + +const parseApp = (value: unknown) => () => parsePluginAppThemeArtifact(JSON.stringify(value)) + +describe('plugin app theme artifacts', () => { + it('accepts public tokens with bounded color values', () => { + expect( + parsePluginAppThemeArtifact( + JSON.stringify({ + base: 'light', + tokens: { '--background': '#ffffff', '--foreground': 'oklch(0.2 0.02 250)' } + }) + ).tokens + ).toMatchObject({ '--background': '#ffffff', '--foreground': 'oklch(0.2 0.02 250)' }) + }) + + it.each([ + ['a private token', { base: 'dark', tokens: { '--internal-gap': '#000' } }], + ['a URL', { base: 'dark', tokens: { '--background': 'url(x)' } }], + ['a var() chain', { base: 'dark', tokens: { '--background': 'var(--a)' } }], + ['declaration syntax', { base: 'dark', tokens: { '--background': '#000; color: red' } }], + ['no tokens', { base: 'dark', tokens: {} }], + [ + 'transparent', + { base: 'dark', tokens: { '--background': 'transparent', '--foreground': '#fff' } } + ], + ['an unknown key', { base: 'dark', tokens: { '--border': '#000' }, extra: 1 }] + ])('rejects %s', (_label, value) => { + expect(parseApp(value)).toThrow() + }) + + it('validates registrations crossing IPC', () => { + const registration = { + id: 'plugin:samples.midnight/midnight', + revision: 'r1', + pluginKey: 'samples.midnight', + pluginName: 'Midnight', + label: 'Midnight', + base: 'dark', + tokens: { '--background': '#000000', '--foreground': '#ffffff' } + } + expect(isPluginThemeRegistration(registration)).toBe(true) + expect(isPluginThemeRegistration({ ...registration, id: 'midnight' })).toBe(false) + expect(isPluginThemeRegistration({ ...registration, tokens: { '--x': '#000' } })).toBe(false) + }) +}) + +describe('plugin terminal theme artifacts', () => { + const terminal = { background: '#000000', foreground: '#ffffff', green: '#00ff00' } + + it('normalizes a usable palette', () => { + expect( + parsePluginTerminalThemeArtifact(JSON.stringify({ schemaVersion: 1, terminal })) + ).toEqual({ mode: 'unknown', terminal }) + }) + + it.each([ + ['an unknown slot', { ...terminal, glow: '#000000' }], + ['an invalid color', { ...terminal, red: 'red; x' }], + ['no ANSI color', { background: '#000000', foreground: '#ffffff' }] + ])('rejects %s', (_label, colors) => { + expect(() => + parsePluginTerminalThemeArtifact(JSON.stringify({ schemaVersion: 1, terminal: colors })) + ).toThrow() + }) + + it('validates registrations crossing IPC', () => { + const registration = { + id: 'plugin:samples.midnight/midnight', + pluginKey: 'samples.midnight', + pluginName: 'Midnight', + label: 'Midnight', + mode: 'dark', + terminal + } + expect(isPluginTerminalThemeRegistration(registration)).toBe(true) + expect(isPluginTerminalThemeRegistration({ ...registration, terminal: {} })).toBe(false) + }) +}) + +describe('plugin app theme completion', () => { + it('requires a background and foreground', () => { + expect(parseApp({ base: 'dark', tokens: { '--background': '#000' } })).toThrow('--foreground') + }) + + it.each(['transparent', 'inherit', 'currentColor', '100px', 'calc(1px + 2px)', 'env(x)'])( + 'rejects the non-color value %s', + (value) => { + expect( + parseApp({ base: 'dark', tokens: { '--background': value, '--foreground': '#fff' } }) + ).toThrow() + } + ) + + it('derives omitted surfaces and accents from the tokens a theme sets', () => { + const { tokens } = parsePluginAppThemeArtifact( + JSON.stringify({ + base: 'dark', + tokens: { '--background': '#101018', '--foreground': '#f0f0ff', '--accent': '#2a2050' } + }) + ) + expect(tokens).toMatchObject({ + '--card': '#101018', + '--sidebar': '#101018', + '--worktree-sidebar': '#101018', + '--sidebar-accent': '#2a2050', + '--worktree-sidebar-accent': '#2a2050', + '--primary': '#f0f0ff', + '--primary-foreground': '#101018' + }) + }) +}) diff --git a/src/shared/plugins/plugin-theme-artifact.ts b/src/shared/plugins/plugin-theme-artifact.ts new file mode 100644 index 00000000000..5e98641be7c --- /dev/null +++ b/src/shared/plugins/plugin-theme-artifact.ts @@ -0,0 +1,189 @@ +import { z } from 'zod' + +// Why: this is a public, additive-only theme API. Exposing every main.css +// variable would make internal renderer refactors plugin-breaking changes. +export const PLUGIN_APP_THEME_TOKENS = [ + '--background', + '--editor-surface', + '--foreground', + '--card', + '--card-foreground', + '--popover', + '--popover-foreground', + '--primary', + '--primary-foreground', + '--secondary', + '--secondary-foreground', + '--muted', + '--muted-foreground', + '--accent', + '--accent-foreground', + '--border', + '--input', + '--ring', + '--chart-1', + '--chart-2', + '--chart-3', + '--chart-4', + '--chart-5', + '--sidebar', + '--sidebar-foreground', + '--sidebar-primary', + '--sidebar-primary-foreground', + '--sidebar-accent', + '--sidebar-accent-foreground', + '--sidebar-border', + '--sidebar-ring', + '--worktree-sidebar', + '--worktree-sidebar-foreground', + '--worktree-sidebar-accent', + '--worktree-sidebar-accent-foreground', + '--worktree-sidebar-border', + '--worktree-sidebar-ring' +] as const + +const THEME_TOKEN_SET = new Set(PLUGIN_APP_THEME_TOKENS) + +/** Every theme sets these; the rest are optional and derived from them when omitted. */ +export const PLUGIN_APP_THEME_REQUIRED_TOKENS = ['--background', '--foreground'] as const + +// Why: an omitted token would otherwise keep the built-in value and clash with the theme's +// palette (e.g. a neutral grey selected row on a purple theme). A rule names source tokens +// (first present wins) or mixes the theme's foreground into its background; the renderer's +// contrast check still drops any derived pair that ends up unreadable. +type DerivationRule = readonly string[] | { mixForeground: number } + +const DERIVED_TOKENS: Record = { + '--editor-surface': ['--background'], + '--card': ['--background'], + '--card-foreground': ['--foreground'], + '--popover': ['--card', '--background'], + '--popover-foreground': ['--card-foreground', '--foreground'], + '--primary': ['--foreground'], + '--primary-foreground': ['--background'], + '--muted': ['--secondary'], + '--secondary': ['--muted'], + '--muted-foreground': { mixForeground: 70 }, + '--border': { mixForeground: 16 }, + '--accent': { mixForeground: 12 }, + '--accent-foreground': ['--foreground'], + '--secondary-foreground': ['--foreground'], + '--input': ['--border'], + '--ring': ['--primary'], + '--sidebar': ['--background'], + '--sidebar-foreground': ['--foreground'], + '--sidebar-primary': ['--primary'], + '--sidebar-primary-foreground': ['--primary-foreground'], + '--sidebar-accent': ['--accent'], + '--sidebar-accent-foreground': ['--accent-foreground'], + '--sidebar-border': ['--border'], + '--sidebar-ring': ['--ring'], + '--worktree-sidebar': ['--sidebar'], + '--worktree-sidebar-foreground': ['--sidebar-foreground'], + '--worktree-sidebar-accent': ['--sidebar-accent'], + '--worktree-sidebar-accent-foreground': ['--sidebar-accent-foreground'], + '--worktree-sidebar-border': ['--sidebar-border'], + '--worktree-sidebar-ring': ['--sidebar-ring'], + '--chart-1': ['--primary'] +} + +/** Fills omitted tokens from the ones the theme set, in dependency order. */ +export function completePluginAppThemeTokens( + tokens: Record +): Record { + const complete = { ...tokens } + for (const [token, rule] of Object.entries(DERIVED_TOKENS)) { + if (complete[token]) { + continue + } + const derived = + 'mixForeground' in rule + ? `color-mix(in srgb, ${complete['--foreground']} ${rule.mixForeground}%, ${complete['--background']})` + : rule.map((source) => complete[source]).find(Boolean) + if (derived) { + complete[token] = derived + } + } + return complete +} + +const NON_COLOR_KEYWORDS = new Set([ + 'transparent', + 'currentcolor', + 'inherit', + 'initial', + 'unset', + 'revert', + 'revert-layer', + 'none' +]) + +// Why: values land in CSS custom properties, so only color syntax is accepted: hex, a color +// function, or a bare color name. The renderer also requires CSS.supports('color', value). +function isThemeColorValue(value: string): boolean { + const normalized = value.trim().toLowerCase() + return ( + normalized.length <= 64 && + !NON_COLOR_KEYWORDS.has(normalized) && + (/^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.test(normalized) || + /^(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch)\([0-9.,%/ +-]*(?:deg|turn|rad)?[0-9.,%/ +-]*\)$/.test( + normalized + ) || + /^[a-z]{3,20}$/.test(normalized)) + ) +} + +const themeFields = { + base: z.enum(['light', 'dark']), + tokens: z.record( + z.string().refine((token) => THEME_TOKEN_SET.has(token), 'is not a public theme token'), + z.string().refine(isThemeColorValue, 'must be a color value') + ) +} + +const hasRequiredTokens = (theme: { tokens: Record }): boolean => + PLUGIN_APP_THEME_REQUIRED_TOKENS.every((token) => theme.tokens[token]) + +const pluginAppThemeArtifactSchema = z + .object(themeFields) + .strict() + .refine(hasRequiredTokens, `must define ${PLUGIN_APP_THEME_REQUIRED_TOKENS.join(' and ')}`) + +/** Host-owned id; plugin keys keep ids from colliding across publishers. */ +export const pluginContentIdSchema = z.templateLiteral(['plugin:', z.string()]) + +// Why looser values here: the host adds derived color-mix() values; the renderer still accepts +// only what CSS parses as a color and checks contrast before painting. +const registrationTokenValueSchema = z + .string() + .max(256) + .refine((value) => !/url\(|var\(|[;{}\\]/i.test(value), 'must be a color value') + +const pluginThemeRegistrationSchema = z + .object({ + base: themeFields.base, + tokens: z.record( + z.string().refine((token) => THEME_TOKEN_SET.has(token)), + registrationTokenValueSchema + ), + id: pluginContentIdSchema, + /** Changes whenever the theme's content does, so a cached paint can be checked. */ + revision: z.string().max(128), + pluginKey: z.string(), + pluginName: z.string(), + label: z.string() + }) + .refine(hasRequiredTokens) + +export type PluginAppThemeArtifact = z.infer +export type PluginThemeRegistration = z.infer + +/** Parses a theme and fills its omitted tokens. */ +export function parsePluginAppThemeArtifact(raw: string): PluginAppThemeArtifact { + const theme = pluginAppThemeArtifactSchema.parse(JSON.parse(raw)) + return { base: theme.base, tokens: completePluginAppThemeTokens(theme.tokens) } +} + +export function isPluginThemeRegistration(value: unknown): value is PluginThemeRegistration { + return pluginThemeRegistrationSchema.safeParse(value).success +} diff --git a/src/shared/terminal-color-overrides.ts b/src/shared/terminal-color-overrides.ts index 1c75a44e217..e0e3a31d1cb 100644 --- a/src/shared/terminal-color-overrides.ts +++ b/src/shared/terminal-color-overrides.ts @@ -24,3 +24,9 @@ export type TerminalColorOverrides = { // Preserve Ghostty bold for future renderer support; xterm ITheme has no bold key. bold?: string } + +/** A selected plugin terminal theme's palette, saved so every host resolves it. */ +export type TerminalPluginPalette = { + id: `plugin:${string}` + colors: TerminalColorOverrides +} diff --git a/src/shared/terminal-theme-selection.test.ts b/src/shared/terminal-theme-selection.test.ts index f245cfe158b..dea5d59dec0 100644 --- a/src/shared/terminal-theme-selection.test.ts +++ b/src/shared/terminal-theme-selection.test.ts @@ -44,3 +44,29 @@ describe('the terminal colours a host configures', () => { ).toEqual({ foreground: '#ffffff', background: '#282c34' }) }) }) + +describe('plugin terminal palettes', () => { + const id = 'plugin:samples.dusk/dusk' as const + const colors = { background: '#1b1430', foreground: '#e8e0ff', red: '#ff6b8b' } + + it('answers a host colour query from the saved palette without the plugin loaded', () => { + expect( + resolveConfiguredTerminalColors( + { ...base, terminalThemeDark: id, terminalPluginPalettes: [{ id, colors }] }, + true + ) + ).toEqual({ foreground: '#e8e0ff', background: '#1b1430' }) + }) + + it('falls back to the default theme for a missing or malformed snapshot', () => { + const fallback = resolveConfiguredTerminalColors(base, true) + for (const terminalPluginPalettes of [[], [{ id, colors: { background: 'red;' } }]]) { + expect( + resolveConfiguredTerminalColors( + { ...base, terminalThemeDark: id, terminalPluginPalettes }, + true + ) + ).toEqual(fallback) + } + }) +}) diff --git a/src/shared/terminal-theme-selection.ts b/src/shared/terminal-theme-selection.ts index a29c35683c2..a6bcfa50209 100644 --- a/src/shared/terminal-theme-selection.ts +++ b/src/shared/terminal-theme-selection.ts @@ -1,5 +1,7 @@ import type { GlobalSettings } from './global-settings-types' import { + hasUsableTerminalThemeColors, + normalizeTerminalColorOverrides, normalizeTerminalCustomThemes, parseCustomTerminalThemeSelection, terminalCustomThemeToXtermTheme @@ -34,10 +36,14 @@ export function selectTerminalTheme( return { mode, useLightVariant, themeName } } +/** A selection names a plugin, user-imported or built-in theme; one lookup for all three. */ export function lookupTerminalTheme( - settings: Pick | undefined, + settings: Pick | undefined, selection: string ): TerminalThemeMap[string] | null { + if (selection.startsWith('plugin:')) { + return pluginPalette(settings?.terminalPluginPalettes, selection) + } const customId = parseCustomTerminalThemeSelection(selection) const custom = customId && settings @@ -50,9 +56,27 @@ export function lookupTerminalTheme( : (TERMINAL_THEME_CATALOG[selection] ?? null) } +// Why re-validate: settings are persisted and synced, so a snapshot is untrusted input here. +function pluginPalette(palettes: unknown, selection: string): TerminalThemeMap[string] | null { + if (!Array.isArray(palettes)) { + return null + } + const entry: unknown = palettes.find( + (candidate: unknown) => + typeof candidate === 'object' && + candidate !== null && + 'id' in candidate && + candidate.id === selection + ) + const colors = normalizeTerminalColorOverrides( + typeof entry === 'object' && entry !== null && 'colors' in entry ? entry.colors : undefined + ) + return hasUsableTerminalThemeColors(colors) ? colors : null +} + function themeColors( selection: string, - settings: Pick + settings: Pick ): TerminalOscColorQueryReplyColors | null { const theme = lookupTerminalTheme(settings, selection) return theme ? { foreground: theme.foreground, background: theme.background } : null @@ -61,7 +85,10 @@ function themeColors( /** Answer OSC 10/11 from saved settings without a renderer; queries report opaque theme colors, ignoring opacity. */ export function resolveConfiguredTerminalColors( settings: TerminalThemeSelectionSettings & - Pick, + Pick< + GlobalSettings, + 'terminalCustomThemes' | 'terminalPluginPalettes' | 'terminalColorOverrides' + >, systemPrefersDark: boolean ): TerminalOscColorQueryReplyColors { const { themeName, useLightVariant } = selectTerminalTheme(settings, systemPrefersDark) diff --git a/src/shared/wsl-move/wsl-move-storage-dispositions.ts b/src/shared/wsl-move/wsl-move-storage-dispositions.ts index 8f580b21b2b..2f75e484c81 100644 --- a/src/shared/wsl-move/wsl-move-storage-dispositions.ts +++ b/src/shared/wsl-move/wsl-move-storage-dispositions.ts @@ -80,6 +80,7 @@ export const LOCAL_STORAGE_MODULE_DISPOSITIONS: Record