Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ with upstream); `arduino-mcp-extension/` is ours; `electron-app/` packages both.

Fork identity (About dialog, Arduino's IDE updater switched off) is overridden
from `arduino-mcp-extension/src/browser/agent-branding.ts` via rebinds, not by
editing vendored files. `arduino-ide-extension` emits no `.d.ts`, so its
editing vendored files. Colors likewise: `agent-theme.ts` registers Arduino's
theme JSONs (still under Arduino's theme ids) after `agent-palette.ts` moves
their teal to indigo, so syncing new upstream theme JSONs needs no manual
recoloring; check `yarn test:palette` still passes. `arduino-ide-extension` emits no `.d.ts`, so its
classes are `any` in our extension: no `override`, no inherited fields.

## Build
Expand Down
1 change: 1 addition & 0 deletions arduino-mcp-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"clean": "rimraf lib",
"build": "tsc",
"test:bridge": "node --test test/bridge/bridge.test.js",
"test:palette": "node --test test/theme/agent-palette.test.js",
"build:dev": "yarn build",
"watch": "tsc -w"
}
Expand Down
95 changes: 95 additions & 0 deletions arduino-mcp-extension/src/browser/agent-palette.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* The color math behind agent-theme.ts, free of Theia imports so it can be
* unit-tested: teal (OKLCH hue 160-240) moves to the icon's indigo at the
* same lightness; accents gain some chroma, tinted neutrals keep theirs.
*/

const TEAL_HUES: [number, number] = [160, 240];
const ACCENT_HUE = 277; // the icon's #4F46E5
const NEUTRAL_HUE = 275;
const ACCENT_MIN_CHROMA = 0.05;
const ACCENT_CHROMA_GAIN = 1.6;
const MAX_CHROMA = 0.2;

/** A deep copy of a VS Code color theme with every color passed through agentColor(). */
export function toAgentPalette<T>(theme: T): T {
const visit = (value: unknown): unknown => {
if (typeof value === 'string') {
return /^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(value) ? agentColor(value) : value;
}
if (Array.isArray(value)) {
return value.map(visit);
}
if (value && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, visit(v)]));
}
return value;
};
return visit(theme) as T;
}

/** Moves a teal color to indigo (see the file header); other colors pass through. */
export function agentColor(hex: string): string {
const alpha = hex.slice(7);
const [L, C, H] = rgbToOklch(hexToRgb(hex));
if (C < 0.003 || H < TEAL_HUES[0] || H > TEAL_HUES[1]) {
return hex;
}
const accent = C >= ACCENT_MIN_CHROMA;
const hue = accent ? ACCENT_HUE : NEUTRAL_HUE;
let chroma = accent ? Math.min(C * ACCENT_CHROMA_GAIN, MAX_CHROMA) : C;
// Reduce chroma until the color fits in sRGB.
let rgb = oklchToRgb(L, chroma, hue);
while (!inGamut(rgb) && chroma > 0) {
chroma = Math.max(0, chroma - 0.005);
rgb = oklchToRgb(L, chroma, hue);
}
return rgbToHex(rgb) + alpha;
}

type RGB = [number, number, number];

function hexToRgb(hex: string): RGB {
return [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255) as RGB;
}

function rgbToHex(rgb: RGB): string {
return (
'#' +
rgb
.map((c) => Math.round(Math.min(1, Math.max(0, c)) * 255).toString(16).padStart(2, '0'))
.join('')
);
}

function inGamut(rgb: RGB): boolean {
return rgb.every((c) => c >= -0.0005 && c <= 1.0005);
}

const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
const fromLinear = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * Math.sign(c) * Math.abs(c) ** (1 / 2.4) - 0.055);

function rgbToOklch(rgb: RGB): [number, number, number] {
const [r, g, b] = rgb.map(toLinear);
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
const bb = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
const H = ((Math.atan2(bb, a) * 180) / Math.PI + 360) % 360;
return [L, Math.hypot(a, bb), H];
}

function oklchToRgb(L: number, C: number, H: number): RGB {
const a = C * Math.cos((H * Math.PI) / 180);
const b = C * Math.sin((H * Math.PI) / 180);
const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
return [
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
].map(fromLinear) as RGB;
}
110 changes: 110 additions & 0 deletions arduino-mcp-extension/src/browser/agent-theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* Arduino Agent's colors: Arduino's light and dark themes with their teal moved
* to the indigo of the app icon, so the IDE looks like its own app.
*
* The themes keep Arduino's ids (`arduino-theme`, `arduino-theme-dark`): the
* vendored settings dialog lists exactly those as the built-in Light/Dark, and
* users' saved `workbench.colorTheme` keeps working. Only the content changes,
* computed from Arduino's theme JSON at registration so upstream theme updates
* carry over:
*
* - accents (teal hues, chroma >= 0.05 in OKLCH) take the icon's indigo hue,
* with somewhat more chroma, at their original lightness;
* - neutrals with a slight teal tint keep their lightness and tiny chroma but
* lean toward indigo instead of teal;
* - everything else (whites, greys, the yellow/red highlights) is unchanged.
*
* Keeping lightness keeps the contrast Arduino tuned. A few rules in
* arduino-ide-extension's CSS hardcode teal; AGENT_THEME_CSS overrides them in
* terms of the theme's branding color.
*/

import { injectable } from '@theia/core/shared/inversify';
import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application-contribution';
import { ColorContribution } from '@theia/core/lib/browser/color-application-contribution';
import { ColorRegistry } from '@theia/core/lib/browser/color-registry';
import { MonacoThemingService } from 'arduino-ide-extension/lib/browser/theia/monaco/monaco-theming-service';
import { ArduinoThemes } from 'arduino-ide-extension/lib/browser/theia/core/theming';
import { toAgentPalette } from './agent-palette';

/** The inherited Theia method used below (the vendored base class is untyped here). */
interface ParsedThemeRegistry {
registerParsedTheme(theme: { id: string; label: string; uiTheme: 'vs' | 'vs-dark'; json: unknown }): unknown;
}

@injectable()
export class AgentMonacoThemingService extends MonacoThemingService {
/** Replaces arduino-ide-extension's registration (called from its restore()). */
registerArduinoThemes(): void {
const { light, dark } = ArduinoThemes;
const registry = this as unknown as ParsedThemeRegistry;
registry.registerParsedTheme({
id: light.id,
label: light.label,
uiTheme: 'vs',
json: toAgentPalette(require('arduino-ide-extension/src/browser/data/default.color-theme.json')),
});
registry.registerParsedTheme({
id: dark.id,
label: dark.label,
uiTheme: 'vs-dark',
json: toAgentPalette(require('arduino-ide-extension/src/browser/data/dark.color-theme.json')),
});
}
}

/**
* Registers `arduino.branding.primary`/`secondary`. Arduino's themes set them and
* its CSS uses `var(--theia-arduino-branding-primary)` (boards dialog, library and
* board lists, serial monitor scrollbar, progress bar), but upstream never
* registers them, so the variable was undefined and those accents never showed.
* Also installs the CSS overrides for the teal hardcoded in arduino-ide-extension's
* stylesheets.
*/
@injectable()
export class AgentThemeStyles implements FrontendApplicationContribution, ColorContribution {
registerColors(colors: ColorRegistry): void {
colors.register(
{
id: 'arduino.branding.primary',
defaults: { light: '#5d64c6', dark: '#757ff4', hcLight: 'focusBorder', hcDark: 'focusBorder' },
description: 'Arduino Agent accent color (links, highlights, the serial monitor scrollbar).',
},
{
id: 'arduino.branding.secondary',
defaults: { light: '#bfc3d2', dark: '#bfc3d2', hcLight: 'foreground', hcDark: 'foreground' },
description: 'Arduino Agent secondary branding color.',
}
);
}

initialize(): void {
const style = document.createElement('style');
style.id = 'arduino-agent-theme';
style.textContent = AGENT_THEME_CSS;
document.head.appendChild(style);
}
}

const brand = (percent: number) =>
`color-mix(in srgb, var(--theia-arduino-branding-primary) ${percent}%, transparent)`;

const AGENT_THEME_CSS = `
:root {
--arduino-shadow-focus: 0 0 0 3px ${brand(25)};
}
#theia-statusBar .area .element.arduino-selected-port,
#theia-statusBar .area .element.arduino-selected-board {
background: ${brand(10)};
}
#theia-statusBar .area .element.arduino-selected-port:hover,
#theia-statusBar .area .element.arduino-selected-board:hover {
background: ${brand(20)};
}
.p-Widget.dialogOverlay .dialogBlock {
border-color: ${brand(10)};
}
.component-list-item {
border-bottom-color: ${brand(8)};
}
`;
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,18 @@
import { ContainerModule } from '@theia/core/shared/inversify';
import { WebSocketConnectionProvider } from '@theia/core/lib/browser/messaging/ws-connection-provider';
import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application-contribution';
import { ColorContribution } from '@theia/core/lib/browser/color-application-contribution';
import { CommandContribution } from '@theia/core/lib/common/command';
import { MenuContribution } from '@theia/core/lib/common/menu';
import { About } from 'arduino-ide-extension/lib/browser/contributions/about';
import { CheckForIDEUpdates } from 'arduino-ide-extension/lib/browser/contributions/check-for-ide-updates';
import { MonacoThemingService } from 'arduino-ide-extension/lib/browser/theia/monaco/monaco-theming-service';
import { bindMCPPreferences, MCPPreferences } from './mcp-preferences';
import { MCPService, MCPServicePath } from '../common/mcp-service';
import { MCPFrontendContribution } from './mcp-frontend-contribution';
import { AgentAbout, DisabledIDEUpdateCheck, RemoveIDEUpdaterCommand } from './agent-branding';
import { ConnectAgentContribution } from './connect-agent';
import { AgentMonacoThemingService, AgentThemeStyles } from './agent-theme';

export default new ContainerModule((bind, unbind, isBound, rebind) => {
// Bind MCP preferences to add settings to the IDE preferences panel
Expand All @@ -31,6 +34,12 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
bind(CommandContribution).toService(RemoveIDEUpdaterCommand);
bind(MenuContribution).toService(RemoveIDEUpdaterCommand);

// Arduino Agent colors: Arduino's themes recolored teal -> indigo (agent-theme.ts)
rebind(MonacoThemingService).to(AgentMonacoThemingService).inSingletonScope();
bind(AgentThemeStyles).toSelf().inSingletonScope();
bind(FrontendApplicationContribution).toService(AgentThemeStyles);
bind(ColorContribution).toService(AgentThemeStyles);

// Bind the MCP service proxy to communicate with backend
bind(MCPService).toDynamicValue(ctx => {
const connection = ctx.container.get(WebSocketConnectionProvider);
Expand Down
71 changes: 71 additions & 0 deletions arduino-mcp-extension/test/theme/agent-palette.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Tests for the teal -> indigo recoloring of Arduino's themes (src/browser/agent-palette.ts).
*
* yarn test:palette (from arduino-mcp-extension, after `yarn build`)
*/
'use strict';

const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const { agentColor, toAgentPalette } = require('../../lib/browser/agent-palette');

const THEMES = path.join(__dirname, '..', '..', '..', 'arduino-ide-extension', 'src', 'browser', 'data');

// Minimal OKLCH lightness/hue for assertions (same math as the module).
function oklch(hex) {
const lin = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
const [r, g, b] = [1, 3, 5].map((i) => lin(parseInt(hex.slice(i, i + 2), 16) / 255));
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
return { L, C: Math.hypot(A, B), H: ((Math.atan2(B, A) * 180) / Math.PI + 360) % 360 };
}

test("Arduino's teal accents move to the icon's indigo at the same lightness", () => {
for (const teal of ['#008184', '#0ca1a6', '#005c5f', '#7fcbcd', '#00979d']) {
const before = oklch(teal);
const after = oklch(agentColor(teal));
assert.ok(Math.abs(after.H - 277) < 3, `${teal} -> hue ${after.H.toFixed(0)}`);
assert.ok(Math.abs(after.L - before.L) < 0.01, `${teal} lightness ${before.L.toFixed(3)} -> ${after.L.toFixed(3)}`);
assert.ok(after.C >= before.C, `${teal} keeps at least its chroma`);
}
});

test('teal-tinted neutrals stay neutral', () => {
for (const grey of ['#dae3e3', '#1f272a', '#4e5b61', '#171e21']) {
const before = oklch(grey);
const after = oklch(agentColor(grey));
assert.ok(Math.abs(after.L - before.L) < 0.01, `${grey} lightness kept`);
assert.ok(after.C < 0.03, `${grey} -> chroma ${after.C.toFixed(3)} stays low`);
}
});

test('non-teal colors and alpha suffixes are untouched', () => {
for (const color of ['#ffffff', '#000000', '#bfbfbf', '#ff0000', '#f1c40f', '#df7365', '#d35400']) {
assert.strictEqual(agentColor(color), color);
}
assert.match(agentColor('#dae3e366'), /^#[0-9a-f]{6}66$/);
});

test("both of Arduino's themes convert without leftover teal accents, and the input is not mutated", () => {
for (const file of ['default.color-theme.json', 'dark.color-theme.json']) {
const original = JSON.parse(fs.readFileSync(path.join(THEMES, file), 'utf8'));
const snapshot = JSON.stringify(original);
const agent = toAgentPalette(original);
assert.strictEqual(JSON.stringify(original), snapshot, `${file} input mutated`);
const colors = [
...Object.values(agent.colors),
...(agent.tokenColors || []).flatMap((t) => Object.values(t.settings || {})),
].filter((v) => /^#[0-9a-f]{6}/i.test(v));
const teal = colors.filter((c) => {
const { C, H } = oklch(c.slice(0, 7));
return C >= 0.05 && H >= 160 && H <= 240;
});
assert.deepStrictEqual(teal, [], `${file} still has teal accents`);
}
});
Loading