From e955e50d44b2da5c0c64fa4959aa52bfa25514e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 25 Apr 2026 20:23:51 +0200 Subject: [PATCH 1/3] fix(agent): accept common color names --- .../src/cli/cmd/tui/context/local.tsx | 20 +++++++++-- packages/opencode/src/config/agent.ts | 16 ++++++++- .../opencode/test/config/agent-color.test.ts | 2 ++ packages/sdk/js/src/v2/gen/types.gen.ts | 36 +++++++++++++++++-- packages/web/src/content/docs/agents.mdx | 2 +- 5 files changed, 70 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index 910483764188..3e5e5b7d00ba 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -13,6 +13,21 @@ import { useSDK } from "./sdk" import { RGBA } from "@opentui/core" import { Filesystem } from "@/util" +const namedAgentColors: Record = { + black: "#000000", + white: "#ffffff", + red: "#ef4444", + orange: "#f97316", + yellow: "#eab308", + green: "#22c55e", + blue: "#3b82f6", + purple: "#a855f7", + pink: "#ec4899", + cyan: "#06b6d4", + gray: "#6b7280", + grey: "#6b7280", +} + export function parseModel(model: string) { const [providerID, ...rest] = model.split("/") return { @@ -92,8 +107,9 @@ 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 named = namedAgentColors[color] + if (named) return RGBA.fromHex(named) + return (theme[color as keyof typeof theme] as RGBA | undefined) ?? colors()[index % colors().length] } return colors()[index % colors().length] }, diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index 1d1c66a131f7..e8eabf6fa47e 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -18,6 +18,20 @@ 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"]), + Schema.Literals([ + "black", + "white", + "red", + "orange", + "yellow", + "green", + "blue", + "purple", + "pink", + "cyan", + "gray", + "grey", + ]), ]) const AgentSchema = Schema.StructWithRest( @@ -40,7 +54,7 @@ const AgentSchema = Schema.StructWithRest( }), 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)", + description: "Hex color code (e.g., #FF5733), theme color (e.g., primary), or common named color (e.g., blue)", }), 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..b57ea236c5f1 100644 --- a/packages/opencode/test/config/agent-color.test.ts +++ b/packages/opencode/test/config/agent-color.test.ts @@ -22,6 +22,7 @@ test("agent color parsed from project config", async () => { agent: { build: { color: "#FFA500" }, plan: { color: "primary" }, + general: { color: "blue" }, }, }), ) @@ -33,6 +34,7 @@ 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") }, }) }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 40e661b46a2d..77ddb1953dab 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1255,9 +1255,29 @@ export type AgentConfig = { [key: string]: unknown } /** - * Hex color code (e.g., #FF5733) or theme color (e.g., primary) + * Hex color code (e.g., #FF5733), theme color (e.g., primary), or common named color (e.g., blue) */ - color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" + color?: + | string + | "primary" + | "secondary" + | "accent" + | "success" + | "warning" + | "error" + | "info" + | "black" + | "white" + | "red" + | "orange" + | "yellow" + | "green" + | "blue" + | "purple" + | "pink" + | "cyan" + | "gray" + | "grey" /** * Maximum number of agentic iterations before forcing text-only response */ @@ -1289,6 +1309,18 @@ export type AgentConfig = { | "warning" | "error" | "info" + | "black" + | "white" + | "red" + | "orange" + | "yellow" + | "green" + | "blue" + | "purple" + | "pink" + | "cyan" + | "gray" + | "grey" | number | PermissionConfig | undefined diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx index 5522f77aae61..805be4c1c61d 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 common named color like `blue`, `green`, or `purple`. ```json title="opencode.json" { From 7d16977fb2ab59c86dbe02abc2541863d00db5e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 26 Apr 2026 11:24:54 +0200 Subject: [PATCH 2/3] fix(agent): tolerate unsupported color values --- .../src/cli/cmd/tui/context/local.tsx | 23 ++-------- packages/opencode/src/config/agent.ts | 24 ++-------- .../opencode/test/config/agent-color.test.ts | 25 +++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 44 +------------------ packages/web/src/content/docs/agents.mdx | 2 +- 5 files changed, 35 insertions(+), 83 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index 3e5e5b7d00ba..359a12465e1f 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -13,21 +13,6 @@ import { useSDK } from "./sdk" import { RGBA } from "@opentui/core" import { Filesystem } from "@/util" -const namedAgentColors: Record = { - black: "#000000", - white: "#ffffff", - red: "#ef4444", - orange: "#f97316", - yellow: "#eab308", - green: "#22c55e", - blue: "#3b82f6", - purple: "#a855f7", - pink: "#ec4899", - cyan: "#06b6d4", - gray: "#6b7280", - grey: "#6b7280", -} - export function parseModel(model: string) { const [providerID, ...rest] = model.split("/") return { @@ -106,10 +91,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ if (agent?.color) { const color = agent.color - if (color.startsWith("#")) return RGBA.fromHex(color) - const named = namedAgentColors[color] - if (named) return RGBA.fromHex(named) - return (theme[color as keyof typeof theme] as RGBA | undefined) ?? colors()[index % colors().length] + 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 e8eabf6fa47e..62ddfbe971c7 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -15,25 +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"]), - Schema.Literals([ - "black", - "white", - "red", - "orange", - "yellow", - "green", - "blue", - "purple", - "pink", - "cyan", - "gray", - "grey", - ]), -]) - const AgentSchema = Schema.StructWithRest( Schema.Struct({ model: Schema.optional(ConfigModelID), @@ -53,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), theme color (e.g., primary), or common named color (e.g., blue)", + 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 b57ea236c5f1..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" @@ -23,6 +24,7 @@ test("agent color parsed from project config", async () => { build: { color: "#FFA500" }, plan: { color: "primary" }, general: { color: "blue" }, + explore: { color: "not-a-real-color" }, }, }), ) @@ -35,6 +37,29 @@ test("agent color parsed from project config", async () => { 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 77ddb1953dab..f063878d0b03 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1255,29 +1255,9 @@ export type AgentConfig = { [key: string]: unknown } /** - * Hex color code (e.g., #FF5733), theme color (e.g., primary), or common named color (e.g., blue) + * 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" - | "black" - | "white" - | "red" - | "orange" - | "yellow" - | "green" - | "blue" - | "purple" - | "pink" - | "cyan" - | "gray" - | "grey" + color?: string /** * Maximum number of agentic iterations before forcing text-only response */ @@ -1301,26 +1281,6 @@ export type AgentConfig = { | { [key: string]: unknown } - | string - | "primary" - | "secondary" - | "accent" - | "success" - | "warning" - | "error" - | "info" - | "black" - | "white" - | "red" - | "orange" - | "yellow" - | "green" - | "blue" - | "purple" - | "pink" - | "cyan" - | "gray" - | "grey" | number | PermissionConfig | undefined diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx index 805be4c1c61d..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`), a theme color (`primary`, `secondary`, `accent`, `success`, `warning`, `error`, `info`), or a common named color like `blue`, `green`, or `purple`. +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" { From ec5d305534139bfb6b7ff911544f3dc2bf4a7185 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 26 Apr 2026 11:33:30 +0200 Subject: [PATCH 3/3] fix(app): normalize agent custom colors --- packages/app/src/utils/agent.test.ts | 44 ++++++++++++++++++++++++++++ packages/app/src/utils/agent.ts | 20 ++++++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 packages/app/src/utils/agent.test.ts 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()) }