diff --git a/CHANGELOG.md b/CHANGELOG.md index 35e76c4..859a3a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/dreamcoder_theme/opencode_colors.py b/src/dreamcoder_theme/opencode_colors.py new file mode 100644 index 0000000..cc587bc --- /dev/null +++ b/src/dreamcoder_theme/opencode_colors.py @@ -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) diff --git a/src/dreamcoder_theme/sync.py b/src/dreamcoder_theme/sync.py index 167a4de..f85ad66 100644 --- a/src/dreamcoder_theme/sync.py +++ b/src/dreamcoder_theme/sync.py @@ -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, @@ -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( diff --git a/tests/test_opencode_agent_colors.py b/tests/test_opencode_agent_colors.py new file mode 100644 index 0000000..ead2879 --- /dev/null +++ b/tests/test_opencode_agent_colors.py @@ -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()