diff --git a/packages/extension/src/chat_panel.ts b/packages/extension/src/chat_panel.ts index 969d3790..22c784e2 100644 --- a/packages/extension/src/chat_panel.ts +++ b/packages/extension/src/chat_panel.ts @@ -5,6 +5,7 @@ import { registerInspectorPoster } from "./inspector_bridge"; import { getBugReport } from "./bug_report"; import { FileWatcherBridge } from "./file_watcher_bridge"; import { resolveExplorerIconTheme } from "./explorer_icon_theme"; +import { resolveSyntaxTheme } from "./syntax_theme_bridge"; import { ExternalMutationTransport } from "./external_mutation_transport"; import type { MutationTrackingContext } from "./external_mutation"; @@ -127,6 +128,7 @@ export class ChatPanel { colorScheme: themeKindToScheme(t.kind), }); this.postExplorerIconTheme(); + this.postSyntaxTheme(); }, null, this.disposables, @@ -134,6 +136,7 @@ export class ChatPanel { vscode.workspace.onDidChangeConfiguration( (event) => { if (event.affectsConfiguration("workbench.iconTheme")) this.postExplorerIconTheme(); + if (event.affectsConfiguration("workbench.colorTheme") || event.affectsConfiguration("editor.tokenColorCustomizations")) this.postSyntaxTheme(); }, null, this.disposables, @@ -154,6 +157,10 @@ export class ChatPanel { // app-ready: the SolidJS app has mounted and is rendering. Fire // any registered callbacks (one-shot) and clear the list. if (msg && msg.source === "amicode" && msg.kind === "app-ready") { + // The syntax theme cannot be sent at panel construction: the iframe + // has not registered its message listener yet. app-ready is the + // first reliable point to deliver the initial VS Code token theme. + this.postSyntaxTheme(); const cbs = ChatPanel.appReadyCallbacks.slice(); ChatPanel.appReadyCallbacks = []; for (const cb of cbs) cb(); @@ -227,6 +234,16 @@ export class ChatPanel { }); } + private postSyntaxTheme(): void { + const theme = resolveSyntaxTheme(); + if (theme == null) return; + void this.panel.webview.postMessage({ + source: "amicode", + kind: "syntax-theme", + theme, + }); + } + /** `authToken` is the per-boot server credential (#163) as the app's * `?auth_token=` bootstrap value — base64("opencode:"), from * serverAuthToken(). The app adopts it for its authenticated-fetch path and @@ -553,7 +570,7 @@ export class ChatPanel { if (invalidated && invalidated.contentWindow) invalidated.contentWindow.postMessage(d, ${origin}); return; } - if (d && d.source === "amicode" && (d.kind === "theme" || d.kind === "clipboard" || d.kind === "navigate" || d.kind === "open-compute-connect" || d.kind === "open-bug-report" || d.kind === "close-bug-report" || d.kind === "dev-tools-status" || d.kind === "connect-harmoniqs-provider-result" || d.kind === "dev-tools-rebuild-status" || d.kind === "dev-tools-build-vsix-status" || d.kind === "data-storage-defaults" || d.kind === "data-storage-status" || d.kind === "connections-credential-result" || d.kind === "connections-disconnect-result" || d.kind === "connections-revalidate-result" || d.kind === "connections-auth-result" || d.kind === "connections-choose-project-result" || d.kind === "connections-add-custom-result" || d.kind === "connections-remove-result" || d.kind === "skill-providers-data" || d.kind === "skill-providers-discovered" || (typeof d.kind === "string" && (d.kind.indexOf("run:") === 0 || d.kind.indexOf("device:") === 0)) || d.kind === "clipboard-image" || d.kind === "workspace-projects" || d.kind === "file-op-notify" || d.kind === "fs-diff-invalidate" || d.kind === "agent-cycle" || d.kind === "preview-file" || d.kind === "preview-visible-children-result" || d.kind === "explorer-icon-theme")) { + if (d && d.source === "amicode" && (d.kind === "theme" || d.kind === "clipboard" || d.kind === "navigate" || d.kind === "open-compute-connect" || d.kind === "open-bug-report" || d.kind === "close-bug-report" || d.kind === "dev-tools-status" || d.kind === "connect-harmoniqs-provider-result" || d.kind === "dev-tools-rebuild-status" || d.kind === "dev-tools-build-vsix-status" || d.kind === "data-storage-defaults" || d.kind === "data-storage-status" || d.kind === "connections-credential-result" || d.kind === "connections-disconnect-result" || d.kind === "connections-revalidate-result" || d.kind === "connections-auth-result" || d.kind === "connections-choose-project-result" || d.kind === "connections-add-custom-result" || d.kind === "connections-remove-result" || d.kind === "skill-providers-data" || d.kind === "skill-providers-discovered" || (typeof d.kind === "string" && (d.kind.indexOf("run:") === 0 || d.kind.indexOf("device:") === 0)) || d.kind === "clipboard-image" || d.kind === "workspace-projects" || d.kind === "file-op-notify" || d.kind === "fs-diff-invalidate" || d.kind === "agent-cycle" || d.kind === "preview-file" || d.kind === "preview-visible-children-result" || d.kind === "explorer-icon-theme" || d.kind === "syntax-theme")) { var f = document.querySelector("iframe"); if (f && f.contentWindow) f.contentWindow.postMessage(d, ${origin}); } @@ -719,7 +736,7 @@ export class ChatPanel { if (invalidated && invalidated.contentWindow) invalidated.contentWindow.postMessage(d, origin); return; } - if (d && d.source === "amicode" && (d.kind === "theme" || d.kind === "clipboard" || d.kind === "navigate" || d.kind === "open-compute-connect" || d.kind === "open-bug-report" || d.kind === "close-bug-report" || d.kind === "dev-tools-status" || d.kind === "connect-harmoniqs-provider-result" || d.kind === "dev-tools-rebuild-status" || d.kind === "dev-tools-build-vsix-status" || d.kind === "data-storage-defaults" || d.kind === "data-storage-status" || d.kind === "connections-credential-result" || d.kind === "connections-disconnect-result" || d.kind === "connections-revalidate-result" || d.kind === "connections-auth-result" || d.kind === "connections-choose-project-result" || d.kind === "connections-add-custom-result" || d.kind === "connections-remove-result" || d.kind === "skill-providers-data" || d.kind === "skill-providers-discovered" || (typeof d.kind === "string" && (d.kind.indexOf("run:") === 0 || d.kind.indexOf("device:") === 0)) || d.kind === "clipboard-image" || d.kind === "workspace-projects" || d.kind === "file-op-notify" || d.kind === "fs-diff-invalidate" || d.kind === "agent-cycle" || d.kind === "preview-file" || d.kind === "preview-visible-children-result" || d.kind === "explorer-icon-theme")) { + if (d && d.source === "amicode" && (d.kind === "theme" || d.kind === "clipboard" || d.kind === "navigate" || d.kind === "open-compute-connect" || d.kind === "open-bug-report" || d.kind === "close-bug-report" || d.kind === "dev-tools-status" || d.kind === "connect-harmoniqs-provider-result" || d.kind === "dev-tools-rebuild-status" || d.kind === "dev-tools-build-vsix-status" || d.kind === "data-storage-defaults" || d.kind === "data-storage-status" || d.kind === "connections-credential-result" || d.kind === "connections-disconnect-result" || d.kind === "connections-revalidate-result" || d.kind === "connections-auth-result" || d.kind === "connections-choose-project-result" || d.kind === "connections-add-custom-result" || d.kind === "connections-remove-result" || d.kind === "skill-providers-data" || d.kind === "skill-providers-discovered" || (typeof d.kind === "string" && (d.kind.indexOf("run:") === 0 || d.kind.indexOf("device:") === 0)) || d.kind === "clipboard-image" || d.kind === "workspace-projects" || d.kind === "file-op-notify" || d.kind === "fs-diff-invalidate" || d.kind === "agent-cycle" || d.kind === "preview-file" || d.kind === "preview-visible-children-result" || d.kind === "explorer-icon-theme" || d.kind === "syntax-theme")) { var f = document.querySelector("iframe"); if (f && f.contentWindow) f.contentWindow.postMessage(d, origin); } diff --git a/packages/extension/src/syntax_theme_bridge.ts b/packages/extension/src/syntax_theme_bridge.ts new file mode 100644 index 00000000..c6b5941f --- /dev/null +++ b/packages/extension/src/syntax_theme_bridge.ts @@ -0,0 +1,205 @@ +/** + * syntax_theme_bridge — Extract the active VS Code color theme's token colors + * and resolve them to a Shiki-compatible theme for the webview. + * + * Follows the same host-side pattern as explorer_icon_theme.ts: a pure resolver + * function called by ChatPanel, posting the result to the framed app. + * + * @module + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as vscode from "vscode"; + +// --------------------------------------------------------------------------- +// Known VS Code theme → Shiki built-in name mapping. +// +// When the user's active theme matches a key here, we send just the string +// name to the webview — Shiki resolves it from its bundledThemes, no file +// reading needed. This covers the majority of users. +// --------------------------------------------------------------------------- + +export const SHIKI_BUILTIN_MAP: Record = { + // VS Code built-in themes + "Default Dark+": "dark-plus", + "Default Light+": "light-plus", + "Default Dark Modern": "dark-plus", + "Default Light Modern": "light-plus", + "Visual Studio Dark": "dark-plus", + "Visual Studio Light": "light-plus", + "Monokai": "monokai", + "Monokai Dimmed": "monokai", + + // Popular third-party themes (by their VS Code marketplace label) + "Dracula": "dracula", + "Dracula Soft": "dracula-soft", + "Dracula Theme": "dracula", + "One Dark Pro": "one-dark-pro", + "Nord": "nord", + "Night Owl": "night-owl", + "GitHub Dark": "github-dark", + "GitHub Dark Default": "github-dark-default", + "GitHub Dark Dimmed": "github-dark-dimmed", + "GitHub Light": "github-light", + "GitHub Light Default": "github-light-default", + "Material Theme": "material-theme", + "Material Theme Darker": "material-theme-darker", + "Material Theme Lighter": "material-theme-lighter", + "Material Theme Ocean": "material-theme-ocean", + "Material Theme Palenight": "material-theme-palenight", + "Vitesse Dark": "vitesse-dark", + "Vitesse Light": "vitesse-light", + "Min Dark": "min-dark", + "Min Light": "min-light", + "Rosé Pine": "rose-pine", + "Rosé Pine Dawn": "rose-pine-dawn", + "Rosé Pine Moon": "rose-pine-moon", + "Catppuccin Mocha": "catppuccin-mocha", + "Catppuccin Latte": "catppuccin-latte", + "Catppuccin Frappé": "catppuccin-frappe", + "Catppuccin Macchiato": "catppuccin-macchiato", + "Tokyo Night": "tokyo-night", + "Slack Theme Dark Mode": "slack-dark", + "Synthwave '84": "synthwave-84", + "Poimandres": "poimandres", +}; + +// --------------------------------------------------------------------------- +// Theme extraction types +// --------------------------------------------------------------------------- + +/** A TextMate token color rule — the shape VS Code theme JSON uses. */ +export interface TextMateTokenRule { + scope?: string | string[]; + settings: { foreground?: string; fontStyle?: string }; +} + +/** + * A resolved TextMate theme object that Shiki can consume directly. + * Subset of Shiki's ThemeRegistrationRaw. + */ +export interface TextMateThemeObject { + name: string; + type?: "light" | "dark"; + colors?: Record; + tokenColors: TextMateTokenRule[]; +} + +/** The result of resolveSyntaxTheme: either a Shiki built-in name or a full theme object. */ +export type SyntaxThemeResult = string | TextMateThemeObject; + +// --------------------------------------------------------------------------- +// Theme resolution +// --------------------------------------------------------------------------- + +const MAX_INCLUDE_DEPTH = 5; + +/** + * Resolve the active VS Code theme to a Shiki-compatible result. + * + * Returns: + * - A string (Shiki built-in name) if the theme matches SHIKI_BUILTIN_MAP. + * - A TextMateThemeObject if the theme was extracted from the extension filesystem. + * - null if the theme could not be resolved (caller should use the fallback). + */ +export function resolveSyntaxTheme(): SyntaxThemeResult | null { + try { + const themeName = vscode.workspace.getConfiguration("workbench").get("colorTheme"); + if (!themeName) return null; + + // Fast path: check the built-in map + const builtinName = SHIKI_BUILTIN_MAP[themeName]; + if (builtinName) return builtinName; + + // Slow path: extract from the VS Code extension filesystem + return extractThemeFromExtensions(themeName); + } catch { + return null; + } +} + +/** + * Search vscode.extensions.all for the extension contributing the named theme, + * read its theme JSON, resolve includes, and merge user overrides. + */ +function extractThemeFromExtensions(themeName: string): TextMateThemeObject | null { + for (const extension of vscode.extensions.all ?? []) { + const themes: any[] = (extension as any).packageJSON?.contributes?.themes ?? []; + const contribution = themes.find( + (t: any) => t.label === themeName || t.id === themeName, + ); + if (!contribution?.path) continue; + const extensionPath: string = (extension as any).extensionPath; + const themePath = path.resolve(extensionPath, contribution.path); + return readThemeFile(themePath, themeName); + } + return null; +} + +/** + * Read a theme JSON file, resolving `include` chains up to MAX_INCLUDE_DEPTH. + * Cycle detection via a visited-set of resolved paths. + */ +function readThemeFile( + themePath: string, + themeName: string, + depth: number = 0, + visited: Set = new Set(), +): TextMateThemeObject | null { + if (depth > MAX_INCLUDE_DEPTH) return null; + + let realPath: string; + try { + realPath = fs.realpathSync(themePath); + } catch { + return null; + } + + if (visited.has(realPath)) return null; // cycle + visited.add(realPath); + + let themeJson: any; + try { + const content = fs.readFileSync(realPath, "utf8"); + themeJson = JSON.parse(content); + } catch { + return null; + } + + // Resolve include chain + let parentTokenColors: TextMateTokenRule[] = []; + let parentColors: Record = {}; + if (typeof themeJson.include === "string") { + const includePath = path.resolve(path.dirname(realPath), themeJson.include); + const parent = readThemeFile(includePath, themeName, depth + 1, visited); + if (parent) { + parentTokenColors = parent.tokenColors ?? []; + parentColors = parent.colors ?? {}; + } + } + + // Merge: parent tokenColors first, then this theme's tokenColors on top + const tokenColors = [ + ...parentTokenColors, + ...(Array.isArray(themeJson.tokenColors) ? themeJson.tokenColors : []), + ]; + const colors = { ...parentColors, ...(themeJson.colors ?? {}) }; + + // Apply user overrides + const customizations = vscode.workspace + .getConfiguration("editor") + .get("tokenColorCustomizations"); + const userRules: TextMateTokenRule[] = customizations?.textMateRules ?? []; + + const isDark = + vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark || + vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.HighContrast; + + return { + name: themeName, + type: isDark ? "dark" : "light", + colors, + tokenColors: [...tokenColors, ...userRules], + }; +} diff --git a/packages/extension/test/chat_panel.test.ts b/packages/extension/test/chat_panel.test.ts index e46a2dc0..e17966a4 100644 --- a/packages/extension/test/chat_panel.test.ts +++ b/packages/extension/test/chat_panel.test.ts @@ -150,6 +150,7 @@ describe("ChatPanel — Preview bridge relays", () => { expect(html).toContain("preview-visible-children-result"); expect(html).toContain("explorer-icon-theme-request"); expect(html).toContain("explorer-icon-theme"); + expect(html).toContain("syntax-theme"); expect(html).toContain("connect-harmoniqs-provider"); expect(html).toContain("connect-harmoniqs-provider-result"); } @@ -172,6 +173,42 @@ describe("ChatPanel — Preview bridge relays", () => { expect(messages).toContainEqual({ source: "amicode", kind: "theme", colorScheme: "light" }); expect(messages).toContainEqual(expect.objectContaining({ kind: "explorer-icon-theme" })); }); + + it("posts the syntax theme after the iframe declares app-ready", () => { + const cap = capturePanel(); + restore = cap.restore; + created = cap.created; + const originalGetConfiguration = (vscode.workspace as unknown as { getConfiguration: unknown }).getConfiguration; + (vscode.workspace as unknown as { getConfiguration: (section?: string) => unknown }).getConfiguration = (section?: string) => ({ + get: (key: string, defaultValue?: unknown) => { + if (section === "workbench" && key === "colorTheme") return "Default Dark+"; + return defaultValue; + }, + update: () => Promise.resolve(), + }); + + try { + ChatPanel.openOrReveal(fakeCtx(), new URL("http://127.0.0.1:43117/")); + const messages: unknown[] = []; + const panel = created[0] as unknown as { + webview: { + postMessage: (message: unknown) => Promise; + _simulateMessage(message: unknown): void; + }; + }; + panel.webview.postMessage = (message) => { messages.push(message); return Promise.resolve(true); }; + + panel.webview._simulateMessage({ source: "amicode", kind: "app-ready" }); + + expect(messages).toContainEqual({ + source: "amicode", + kind: "syntax-theme", + theme: "dark-plus", + }); + } finally { + (vscode.workspace as unknown as { getConfiguration: unknown }).getConfiguration = originalGetConfiguration; + } + }); }); describe("ChatPanel — onboarding greeting auto-send (#449)", () => { diff --git a/packages/extension/test/syntax_theme_bridge.test.ts b/packages/extension/test/syntax_theme_bridge.test.ts new file mode 100644 index 00000000..4ee2e5b5 --- /dev/null +++ b/packages/extension/test/syntax_theme_bridge.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as vscode from "vscode"; + +import { + resolveSyntaxTheme, + SHIKI_BUILTIN_MAP, + type TextMateThemeObject, +} from "../src/syntax_theme_bridge"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Set up vscode.workspace.getConfiguration to return specific values. */ +function mockConfig(values: Record>) { + (vscode.workspace as any).getConfiguration = (section?: string) => ({ + get: (key: string, defaultValue?: unknown) => { + return values[section ?? ""]?.[key] ?? defaultValue; + }, + update: () => Promise.resolve(), + }); +} + +/** Create a temp directory with a fake VS Code extension contributing a color theme. */ +function createMockThemeExtension( + themeLabel: string, + themeJson: object, + opts?: { includes?: Array<{ relativePath: string; content: object }> }, +): { extensionPath: string; cleanup: () => void } { + const extensionPath = fs.mkdtempSync(path.join(os.tmpdir(), "amicode-test-theme-")); + const themesDir = path.join(extensionPath, "themes"); + fs.mkdirSync(themesDir, { recursive: true }); + fs.writeFileSync(path.join(themesDir, "theme.json"), JSON.stringify(themeJson)); + + for (const inc of opts?.includes ?? []) { + const incPath = path.join(themesDir, inc.relativePath); + fs.mkdirSync(path.dirname(incPath), { recursive: true }); + fs.writeFileSync(incPath, JSON.stringify(inc.content)); + } + + const ext = { + extensionPath, + packageJSON: { + contributes: { + themes: [{ label: themeLabel, path: "./themes/theme.json" }], + }, + }, + }; + (vscode.extensions as any).all = [ext]; + + return { + extensionPath, + cleanup: () => fs.rmSync(extensionPath, { recursive: true, force: true }), + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("syntax_theme_bridge", () => { + const originalGetConfig = (vscode.workspace as any).getConfiguration; + let cleanups: Array<() => void> = []; + + beforeEach(() => { + (vscode.extensions as any).all = []; + cleanups = []; + }); + + afterEach(() => { + (vscode.workspace as any).getConfiguration = originalGetConfig; + for (const fn of cleanups) fn(); + }); + + describe("SHIKI_BUILTIN_MAP", () => { + it("maps common VS Code themes to Shiki built-in names", () => { + expect(SHIKI_BUILTIN_MAP["Default Dark+"]).toBe("dark-plus"); + expect(SHIKI_BUILTIN_MAP["Default Light+"]).toBe("light-plus"); + expect(SHIKI_BUILTIN_MAP["Dracula"]).toBe("dracula"); + expect(SHIKI_BUILTIN_MAP["Monokai"]).toBe("monokai"); + expect(SHIKI_BUILTIN_MAP["Nord"]).toBe("nord"); + }); + }); + + describe("resolveSyntaxTheme", () => { + it("returns a Shiki built-in name for a known VS Code theme", () => { + mockConfig({ + workbench: { colorTheme: "Default Dark+" }, + editor: { tokenColorCustomizations: undefined }, + }); + expect(resolveSyntaxTheme()).toBe("dark-plus"); + }); + + it("returns null when no theme is configured", () => { + mockConfig({ workbench: { colorTheme: undefined } }); + expect(resolveSyntaxTheme()).toBeNull(); + }); + + it("extracts a TextMate theme object from a custom extension", () => { + const themeJson = { + name: "My Custom Theme", + type: "dark", + colors: { "editor.background": "#1e1e1e" }, + tokenColors: [ + { scope: "comment", settings: { foreground: "#6A9955" } }, + { scope: "keyword", settings: { foreground: "#569CD6" } }, + ], + }; + const { cleanup } = createMockThemeExtension("My Custom Theme", themeJson); + cleanups.push(cleanup); + + mockConfig({ + workbench: { colorTheme: "My Custom Theme" }, + editor: { tokenColorCustomizations: undefined }, + }); + + const result = resolveSyntaxTheme(); + expect(result).not.toBeNull(); + expect(typeof result).toBe("object"); + + const theme = result as TextMateThemeObject; + expect(theme.name).toBe("My Custom Theme"); + expect(theme.colors?.["editor.background"]).toBe("#1e1e1e"); + expect(theme.tokenColors).toHaveLength(2); + expect(theme.tokenColors[0].scope).toBe("comment"); + expect(theme.tokenColors[0].settings.foreground).toBe("#6A9955"); + }); + + it("resolves include chains in theme files", () => { + const parentTheme = { + name: "Parent", + tokenColors: [ + { scope: "comment", settings: { foreground: "#888888" } }, + ], + colors: { "editor.background": "#000000" }, + }; + const childTheme = { + include: "./parent.json", + tokenColors: [ + { scope: "keyword", settings: { foreground: "#FF0000" } }, + ], + colors: { "editor.foreground": "#FFFFFF" }, + }; + const { cleanup } = createMockThemeExtension("Child Theme", childTheme, { + includes: [{ relativePath: "parent.json", content: parentTheme }], + }); + cleanups.push(cleanup); + + mockConfig({ + workbench: { colorTheme: "Child Theme" }, + editor: { tokenColorCustomizations: undefined }, + }); + + const result = resolveSyntaxTheme() as TextMateThemeObject; + expect(result).not.toBeNull(); + // Parent tokenColors come first, child on top + expect(result.tokenColors).toHaveLength(2); + expect(result.tokenColors[0].scope).toBe("comment"); + expect(result.tokenColors[0].settings.foreground).toBe("#888888"); + expect(result.tokenColors[1].scope).toBe("keyword"); + // Colors are merged: parent + child + expect(result.colors?.["editor.background"]).toBe("#000000"); + expect(result.colors?.["editor.foreground"]).toBe("#FFFFFF"); + }); + + it("detects circular includes and returns null", () => { + // Create a theme that includes itself + const selfRef = { include: "./theme.json", tokenColors: [] }; + const { cleanup } = createMockThemeExtension("Circular Theme", selfRef); + cleanups.push(cleanup); + + mockConfig({ + workbench: { colorTheme: "Circular Theme" }, + editor: { tokenColorCustomizations: undefined }, + }); + + const result = resolveSyntaxTheme() as TextMateThemeObject; + // Should still return a result (the self-referencing include is skipped, + // but the theme itself is still valid with empty tokenColors) + expect(result).not.toBeNull(); + expect(result.tokenColors).toHaveLength(0); + }); + + it("merges user tokenColorCustomizations on top", () => { + const themeJson = { + tokenColors: [ + { scope: "comment", settings: { foreground: "#888888" } }, + ], + }; + const { cleanup } = createMockThemeExtension("Customized Theme", themeJson); + cleanups.push(cleanup); + + mockConfig({ + workbench: { colorTheme: "Customized Theme" }, + editor: { + tokenColorCustomizations: { + textMateRules: [ + { scope: "comment", settings: { foreground: "#FF0000" } }, + ], + }, + }, + }); + + const result = resolveSyntaxTheme() as TextMateThemeObject; + expect(result).not.toBeNull(); + // Base theme rule + user override rule (user last, so it wins at render time) + expect(result.tokenColors).toHaveLength(2); + expect(result.tokenColors[0].settings.foreground).toBe("#888888"); + expect(result.tokenColors[1].settings.foreground).toBe("#FF0000"); + }); + + it("returns null for an unknown theme with no contributing extension", () => { + mockConfig({ + workbench: { colorTheme: "Some Unknown Theme" }, + editor: { tokenColorCustomizations: undefined }, + }); + (vscode.extensions as any).all = []; + + expect(resolveSyntaxTheme()).toBeNull(); + }); + }); +});