diff --git a/packages/app/src/utils/agent.test.ts b/packages/app/src/utils/agent.test.ts new file mode 100644 index 000000000000..002e7d480b7b --- /dev/null +++ b/packages/app/src/utils/agent.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { agentColor } from "./agent" + +const originalCss = globalThis.CSS + +describe("agentColor", () => { + afterEach(() => { + Object.defineProperty(globalThis, "CSS", { + value: originalCss, + configurable: true, + writable: true, + }) + }) + + test("maps theme tokens to app theme colors", () => { + expect(agentColor("custom", "primary")).toBe("var(--syntax-info)") + expect(agentColor("custom", "secondary")).toBe("var(--syntax-property)") + expect(agentColor("custom", "error")).toBe("var(--text-diff-delete-base)") + }) + + test("keeps supported css colors", () => { + Object.defineProperty(globalThis, "CSS", { + value: { + supports: (property: string, value: string) => property === "color" && value === "rebeccapurple", + }, + configurable: true, + writable: true, + }) + + expect(agentColor("custom", "rebeccapurple")).toBe("rebeccapurple") + }) + + test("falls back to automatic palette for unsupported colors", () => { + Object.defineProperty(globalThis, "CSS", { + value: { + supports: () => false, + }, + configurable: true, + writable: true, + }) + + expect(agentColor("custom", "not-a-real-color")).toBe("var(--icon-agent-build-base)") + }) +}) diff --git a/packages/app/src/utils/agent.ts b/packages/app/src/utils/agent.ts index 59da53af102a..129b57b2952e 100644 --- a/packages/app/src/utils/agent.ts +++ b/packages/app/src/utils/agent.ts @@ -5,6 +5,16 @@ const defaults: Record = { plan: "var(--icon-agent-plan-base)", } +const themeColors: Record = { + primary: "var(--syntax-info)", + secondary: "var(--syntax-property)", + accent: "var(--syntax-info)", + success: "var(--syntax-success)", + warning: "var(--syntax-warning)", + error: "var(--text-diff-delete-base)", + info: "var(--syntax-info)", +} + const palette = [ "var(--icon-agent-ask-base)", "var(--icon-agent-build-base)", @@ -26,8 +36,16 @@ function tone(name: string) { return palette[hash % palette.length] } +function resolveCustomColor(custom?: string) { + if (!custom) return + const token = themeColors[custom.toLowerCase()] + if (token) return token + if (globalThis.CSS?.supports?.("color", custom)) return custom +} + export function agentColor(name: string, custom?: string) { - if (custom) return custom + const resolved = resolveCustomColor(custom) + if (resolved) return resolved return defaults[name] ?? defaults[name.toLowerCase()] ?? tone(name.toLowerCase()) } diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index af06a2bf295d..e9d7a845e61e 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -91,9 +91,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ if (agent?.color) { const color = agent.color - if (color.startsWith("#")) return RGBA.fromHex(color) - // already validated by config, just satisfying TS here - return theme[color as keyof typeof theme] as RGBA + const themed = theme[color as keyof typeof theme] + if (themed instanceof RGBA) return themed + const hex = Bun.color(color, "hex") + if (hex) return RGBA.fromHex(hex) } return colors()[index % colors().length] }, diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index 1d1c66a131f7..62ddfbe971c7 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -15,11 +15,6 @@ import { ConfigPermission } from "./permission" const log = Log.create({ service: "config" }) -const Color = Schema.Union([ - Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)), - Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]), -]) - const AgentSchema = Schema.StructWithRest( Schema.Struct({ model: Schema.optional(ConfigModelID), @@ -39,8 +34,9 @@ const AgentSchema = Schema.StructWithRest( description: "Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)", }), options: Schema.optional(Schema.Record(Schema.String, Schema.Any)), - color: Schema.optional(Color).annotate({ - description: "Hex color code (e.g., #FF5733) or theme color (e.g., primary)", + color: Schema.optional(Schema.String).annotate({ + description: + "Visual color hint. Hex colors, theme color tokens, and CSS color names are applied when supported; unsupported values fall back to the automatic palette.", }), steps: Schema.optional(PositiveInt).annotate({ description: "Maximum number of agentic iterations before forcing text-only response", diff --git a/packages/opencode/test/config/agent-color.test.ts b/packages/opencode/test/config/agent-color.test.ts index bfa948619bb1..8360f73fd626 100644 --- a/packages/opencode/test/config/agent-color.test.ts +++ b/packages/opencode/test/config/agent-color.test.ts @@ -1,6 +1,7 @@ import { test, expect } from "bun:test" import { Effect } from "effect" import path from "path" +import { mkdir } from "fs/promises" import { provideInstance, tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { Config } from "../../src/config" @@ -22,6 +23,8 @@ test("agent color parsed from project config", async () => { agent: { build: { color: "#FFA500" }, plan: { color: "primary" }, + general: { color: "blue" }, + explore: { color: "not-a-real-color" }, }, }), ) @@ -33,6 +36,30 @@ test("agent color parsed from project config", async () => { const cfg = await load() expect(cfg.agent?.["build"]?.color).toBe("#FFA500") expect(cfg.agent?.["plan"]?.color).toBe("primary") + expect(cfg.agent?.["general"]?.color).toBe("blue") + expect(cfg.agent?.["explore"]?.color).toBe("not-a-real-color") + }, + }) +}) + +test("agent frontmatter tolerates unsupported color values", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await mkdir(path.join(dir, ".opencode", "agents", "testing"), { recursive: true }) + await Bun.write( + path.join(dir, ".opencode", "agents", "testing", "testing-tool-evaluator.md"), + `--- +color: not-a-real-color +--- +Testing prompt`, + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const cfg = await load() + expect(cfg.agent?.["testing/testing-tool-evaluator"]?.color).toBe("not-a-real-color") }, }) }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 40e661b46a2d..f063878d0b03 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1255,9 +1255,9 @@ export type AgentConfig = { [key: string]: unknown } /** - * Hex color code (e.g., #FF5733) or theme color (e.g., primary) + * Visual color hint. Hex colors, theme color tokens, and CSS color names are applied when supported; unsupported values fall back to the automatic palette. */ - color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" + color?: string /** * Maximum number of agentic iterations before forcing text-only response */ @@ -1281,14 +1281,6 @@ export type AgentConfig = { | { [key: string]: unknown } - | string - | "primary" - | "secondary" - | "accent" - | "success" - | "warning" - | "error" - | "info" | number | PermissionConfig | undefined diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx index 5522f77aae61..20da75d5476d 100644 --- a/packages/web/src/content/docs/agents.mdx +++ b/packages/web/src/content/docs/agents.mdx @@ -605,7 +605,7 @@ Users can always invoke any subagent directly via the `@` autocomplete menu, eve Customize the agent's visual appearance in the UI with the `color` option. This affects how the agent appears in the interface. -Use a valid hex color (e.g., `#FF5733`) or theme color: `primary`, `secondary`, `accent`, `success`, `warning`, `error`, `info`. +Use a valid hex color (e.g., `#FF5733`), a theme color (`primary`, `secondary`, `accent`, `success`, `warning`, `error`, `info`), or a CSS color name like `blue`. Unsupported values fall back to the automatic palette instead of blocking agent loading. ```json title="opencode.json" {