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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
### Fixed

- OpenCode surfaces follow the active mode with readable syntax and interface states.
- Managed OpenCode agent colors follow the active mode while preserving unrelated configuration.
- Dark Starship diagnostic styles resolve through the palette.
- Repository sync regression tests derive the current upstream pin instead of relying on a historical hash.
- Zsh sources Bun completions only from nonempty regular files, preserving support for symlinks to regular files.
Expand Down
116 changes: 116 additions & 0 deletions src/dreamcoder_theme/opencode_colors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""Resolve explicitly configured agent colors for OpenCode's hex-only TUI.

OpenCode 2.1.4 createAgent.color passes agent.color directly to RGBA.fromHex,
not through the theme-name resolver. Use the renderer as the role authority.
Only gentle-orchestrator (primary) and dangerous-gentleman (accent) recognize
previous canonical hex: a manual identical hex on these IDs is unavoidably
indistinguishable from managed output. Other agents' hex values are never owned.
"""

from __future__ import annotations

import json
import re
from collections.abc import Mapping
from copy import deepcopy
from pathlib import Path
from typing import Any

from .palette_tokens import VARIANTS
from .renderers_opencode import opencode_content
from .writers import write_if_changed

MANAGED_ROLES = {"gentle-orchestrator": "primary", "dangerous-gentleman": "accent"}
_HEX = re.compile(r"#[0-9a-fA-F]{6}\Z")


def resolve_agent_color(
value: Any,
agent_id: str,
theme: Mapping[str, str],
prior_themes: list[Mapping[str, str]],
) -> Any:
"""Pure resolution; only explicit role names or known managed hex opt in."""
if not isinstance(value, str):
return value
resolved = theme.get(value)
if isinstance(resolved, str) and _HEX.fullmatch(resolved):
return resolved
role = MANAGED_ROLES.get(agent_id)
if role and _HEX.fullmatch(value):
prior_values = {prior[role].lower() for prior in prior_themes}
if value.lower() in prior_values:
return theme[role]
return value


def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
"""Reject duplicate keys rather than silently lose user configuration."""
result: dict[str, Any] = {}
for key, value in pairs:
if key in result:
raise ValueError("Duplicate JSON key")
result[key] = value
return result


def update_opencode_agent_colors(
path: Path,
active: dict[str, str],
variants: Mapping[str, dict[str, str]] = VARIANTS,
) -> bool:
"""Patch only agent color fields in an existing valid JSON object.

False means missing, rejected, or unchanged; invalid documents are never
rewritten. Prefer a unique value patch, verified against the intended JSON;
ambiguous formatting falls back to serialization without semantic changes.
Canonical defaults plus loaded variants recognize prior base-mode output.
"""
try:
original = path.read_text()
data = json.loads(original, object_pairs_hook=_unique_object)
except (OSError, ValueError):
return False
if not isinstance(data, dict):
return False
agents = data.get("agent", {})
if not isinstance(agents, dict) or any(not isinstance(a, dict) for a in agents.values()):
return False

theme = json.loads(opencode_content(active, transparent_background=True))["theme"]
prior_themes = [
json.loads(opencode_content(palette, transparent_background=True))["theme"]
for palette in (*VARIANTS.values(), *variants.values())
]
intended = deepcopy(data)
replacements = []
for agent_id, agent in agents.items():
if "color" not in agent:
continue
old = agent["color"]
new = resolve_agent_color(old, agent_id, theme, prior_themes)
if new != old:
intended["agent"][agent_id]["color"] = new
replacements.append((old, new))
if not replacements:
return False

patched = original
for old, new in replacements:
pattern = re.compile(r'("color"\s*:\s*)' + re.escape(json.dumps(old)))
if len(pattern.findall(patched)) != 1:
patched = json.dumps(intended, indent=2)
break

def replace_color(match: re.Match[str], value: str = json.dumps(new)) -> str:
return match[1] + value

patched = pattern.sub(replace_color, patched)
# A text match is not a parser: ensure prompts and all unrelated fields survive.
try:
verified = json.loads(patched, object_pairs_hook=_unique_object)
except ValueError:
verified = None
if verified != intended:
patched = json.dumps(intended, indent=2)
return write_if_changed(path, patched)
4 changes: 4 additions & 0 deletions src/dreamcoder_theme/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from typing import Any, NamedTuple

from .herdr_contract import SUPPORTED_PROFILES, HerdrProfile
from .opencode_colors import update_opencode_agent_colors
from .palette import (
adaptive_palette,
load_guardrails,
Expand Down Expand Up @@ -279,6 +280,9 @@ def sync_active_targets(
paths.opencode, opencode_content(active, transparent_background=True)
),
"opencode_tui": write_opencode_tui(paths.opencode_tui),
"opencode_agent_colors": update_opencode_agent_colors(
paths.opencode_tui.parent / "opencode.json", active, variants
),
"opencode_cleanup": cleanup_opencode_themes(paths.opencode),
"codex_theme": write_if_changed(paths.codex_theme, codex_tmtheme_content(active)),
"bat_theme": write_if_changed(
Expand Down
205 changes: 205 additions & 0 deletions tests/test_opencode_agent_colors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
"""Agent hex compatibility for OpenCode 2.1.4; no live configuration access.

Known managed IDs cannot distinguish a manually chosen canonical accent hex
from a previous sync result. Only those IDs accept that ambiguity; every other
agent's explicit hex remains custom, even when identical to a canonical color.
"""

from __future__ import annotations

import json
from dataclasses import fields, replace
from pathlib import Path
from tempfile import TemporaryDirectory

import pytest

from dreamcoder_theme import sync
from dreamcoder_theme.opencode_colors import update_opencode_agent_colors
from dreamcoder_theme.palette_tokens import VARIANTS
from dreamcoder_theme.renderers_opencode import opencode_content
from dreamcoder_theme.settings import ThemePaths


@pytest.fixture
def config_path():
with TemporaryDirectory(prefix="dreamcoder-agent-colors-") as directory:
yield Path(directory) / "opencode.json"


def update(path, mode="dark"):
return update_opencode_agent_colors(path, VARIANTS[mode], VARIANTS)


@pytest.mark.parametrize(
"agent,role",
[
("gentle-orchestrator", "primary"),
("dangerous-gentleman", "accent"),
],
)
def test_managed_primary_follows_repeated_mode_changes(config_path, agent, role):
config_path.write_text(json.dumps({"agent": {agent: {"color": role}}}))
for mode in ("dark", "light", "dusk", "dark"):
assert update(config_path, mode) is True
assert json.loads(config_path.read_text()) == {
"agent": {agent: {"color": VARIANTS[mode]["accent"]}}
}
assert update(config_path, mode) is False


@pytest.mark.parametrize(
"role",
[
"primary",
"secondary",
"accent",
"error",
"warning",
"success",
"info",
"textMuted",
"textSubtle",
"textPlaceholder",
"borderFocus",
"syntaxFunction",
],
)
def test_explicit_symbolic_roles_match_transparent_renderer(config_path, role):
config_path.write_text(json.dumps({"agent": {"other": {"color": role}}}))
assert update(config_path) is True
expected = json.loads(opencode_content(VARIANTS["dark"], True))["theme"][role]
assert json.loads(config_path.read_text())["agent"]["other"]["color"] == expected


@pytest.mark.parametrize("value", ["unknown", "#123456", "#abc", "none", 42, None])
def test_unknown_custom_or_noncolor_values_are_unchanged(config_path, value):
original = json.dumps({"agent": {"gentle-orchestrator": {"color": value}}})
config_path.write_text(original)
assert update(config_path) is False
assert config_path.read_text() == original


@pytest.mark.parametrize("mode", list(VARIANTS))
def test_other_agents_canonical_hex_is_always_custom(config_path, mode):
original = json.dumps({"agent": {"other": {"color": VARIANTS[mode]["accent"]}}})
config_path.write_text(original)
assert update(config_path, "light" if mode == "dark" else "dark") is False
assert config_path.read_text() == original


def test_known_ids_canonical_hex_has_documented_managed_ambiguity(config_path):
# A manual hex equal to the established primary/accent role is intentionally
# indistinguishable from managed output, but unrelated token hex is not.
config_path.write_text(
json.dumps(
{
"agent": {
"gentle-orchestrator": {"color": VARIANTS["dark"]["accent"]},
"dangerous-gentleman": {"color": VARIANTS["dark"]["error"]},
}
}
)
)
assert update(config_path, "light") is True
assert json.loads(config_path.read_text())["agent"] == {
"gentle-orchestrator": {"color": VARIANTS["light"]["accent"]},
"dangerous-gentleman": {"color": VARIANTS["dark"]["error"]},
}


@pytest.mark.parametrize(
"original",
[
"{",
"[]",
"null",
'{"agent": []}',
'{"agent": null}',
'{"agent": {"bad": null, "good": {"color": "primary"}}}',
'{"agent": {"bad": "primary"}}',
'{"agent": {}, "agent": {"good": {"color": "primary"}}}',
],
)
def test_malformed_or_wrong_shape_is_unchanged(config_path, original):
config_path.write_text(original)
assert update(config_path) is False
assert config_path.read_text() == original


def test_missing_file_is_not_created(config_path):
assert update(config_path) is False
assert not config_path.exists()


@pytest.mark.parametrize("original", ["{}", '{"agent": {"no-color": {"model": "test"}}}'])
def test_no_agent_color_is_invented(config_path, original):
config_path.write_text(original)
assert update(config_path) is False
assert config_path.read_text() == original


def test_preserves_unrelated_fields_and_unique_value_formatting(config_path):
original = (
'{\n "model": "keep", "permission": {"edit": "ask"},\n'
' "agent": {"gentle-orchestrator": {"color" : "primary",'
' "prompt": "color: primary", "model": "other", "tools": {}}}\n}\n'
)
config_path.write_text(original)
assert update(config_path) is True
assert config_path.read_text() == original.replace(
'"primary"', '"' + VARIANTS["dark"]["accent"] + '"'
)


def test_ambiguous_text_patch_preserves_semantics(config_path):
data = {
"color": "primary",
"agent": {
"one": {"color": "primary"},
"two": {"color": "primary"},
"no-color": {"prompt": '"color": "primary"'},
},
}
config_path.write_text(json.dumps(data))
assert update(config_path) is True
for agent in ("one", "two"):
data["agent"][agent]["color"] = VARIANTS["dark"]["accent"]
assert json.loads(config_path.read_text()) == data


def test_public_sync_uses_tui_config_directory_and_preserves_theme(config_path, monkeypatch):
# All output paths are isolated; neither home paths nor repository artifacts
# can be reached by the public orchestrator in this fixture.
root = config_path.parent
optional = {field.name: None for field in fields(ThemePaths) if field.default is None}
paths = ThemePaths(
**{
field.name: root / field.name
for field in fields(ThemePaths)
if field.name not in optional
},
**optional,
)
paths = replace(
paths,
opencode=root / "elsewhere/themes/dreamcoder.json",
opencode_tui=root / "config/tui.json",
)
target = paths.opencode_tui.parent / "opencode.json"
target.parent.mkdir()
target.write_text('{"agent":{"gentle-orchestrator":{"color":"primary"}},"model":"keep"}')
monkeypatch.setenv("DREAMCODER_CLEAN_OPENCODE_THEMES", "0")
for mode in ("dark", "light", "dusk"):
# Public modes stay dark/light; also exercise the legacy dusk palette
# as active input without adding a new public mode to the orchestrator.
base = "dark" if mode == "dark" else "light"
result = sync.sync_active_targets(paths, VARIANTS[mode], base, VARIANTS)
assert result["opencode_agent_colors"] is True
assert json.loads(target.read_text()) == {
"agent": {"gentle-orchestrator": {"color": VARIANTS[mode]["accent"]}},
"model": "keep",
}
assert paths.opencode.read_text() == opencode_content(VARIANTS[mode], True)
assert json.loads(paths.opencode_tui.read_text())["theme"] == "dreamcoder"
assert not (paths.opencode.parent / "opencode.json").exists()
Loading