Skip to content
Merged
21 changes: 19 additions & 2 deletions packages/extension/src/chat_panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -127,13 +128,15 @@ export class ChatPanel {
colorScheme: themeKindToScheme(t.kind),
});
this.postExplorerIconTheme();
this.postSyntaxTheme();
},
null,
this.disposables,
);
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,
Expand All @@ -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();
Expand Down Expand Up @@ -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:<password>"), from
* serverAuthToken(). The app adopts it for its authenticated-fetch path and
Expand Down Expand Up @@ -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});
}
Expand Down Expand Up @@ -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);
}
Expand Down
205 changes: 205 additions & 0 deletions packages/extension/src/syntax_theme_bridge.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
// 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<string, string>;
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<string>("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<string> = 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<string, string> = {};
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<any>("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],
};
}
37 changes: 37 additions & 0 deletions packages/extension/test/chat_panel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand All @@ -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<boolean>;
_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)", () => {
Expand Down
Loading
Loading