Skip to content
Closed
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
44 changes: 44 additions & 0 deletions packages/app/src/utils/agent.test.ts
Original file line number Diff line number Diff line change
@@ -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)")
})
})
20 changes: 19 additions & 1 deletion packages/app/src/utils/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ const defaults: Record<string, string> = {
plan: "var(--icon-agent-plan-base)",
}

const themeColors: Record<string, string> = {
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)",
Expand All @@ -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())
}

Expand Down
7 changes: 4 additions & 3 deletions packages/opencode/src/cli/cmd/tui/context/local.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]
},
Expand Down
10 changes: 3 additions & 7 deletions packages/opencode/src/config/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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",
Expand Down
27 changes: 27 additions & 0 deletions packages/opencode/test/config/agent-color.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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" },
},
}),
)
Expand All @@ -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")
},
})
})
Expand Down
12 changes: 2 additions & 10 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -1281,14 +1281,6 @@ export type AgentConfig = {
| {
[key: string]: unknown
}
| string
| "primary"
| "secondary"
| "accent"
| "success"
| "warning"
| "error"
| "info"
| number
| PermissionConfig
| undefined
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/content/docs/agents.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
{
Expand Down
Loading