From 954d2ef00f00dc22deeb7d408c898fc371fc94a9 Mon Sep 17 00:00:00 2001 From: Charlie Scherer <27368115+tildesrc@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:46:56 +0000 Subject: [PATCH] Start container claude in the `auto` theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claude has no `--theme` flag and defaults to a fixed dark palette, so a task pane attached from a light terminal is hard to read. Seed `theme: auto` — which follows the terminal's background — into the settings file the launcher already renders, plus claude's legacy global config for builds that resolve it from there (the two values agree, so precedence doesn't matter). Seeded, not enforced: the config dir is a per-task volume that outlives a respawn, so a deliberate `/theme` inside the container survives the next launch, while the hooks and permission pre-accepts are still re-applied every time. Co-Authored-By: Claude Opus 5 --- src/panopticon/container/cli/claude.py | 8 +++++++- src/panopticon/container/hooks.py | 20 +++++++++++++++++++- tests/container/test_claude.py | 13 +++++++++++++ tests/container/test_hooks.py | 24 +++++++++++++++++++++++- 4 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/panopticon/container/cli/claude.py b/src/panopticon/container/cli/claude.py index ddc9b239..67da2600 100644 --- a/src/panopticon/container/cli/claude.py +++ b/src/panopticon/container/cli/claude.py @@ -19,7 +19,7 @@ from panopticon.container.cli.base import AgentCLI, _Client from panopticon.container.config import update_json_config -from panopticon.container.hooks import write_settings +from panopticon.container.hooks import THEME, write_settings from panopticon.container.skills import write_commands, write_operation_commands from panopticon.core.models import Skill @@ -97,6 +97,11 @@ def trust_workspace(self, config_dir: Path, cwd: Path) -> Path: - ``hasAcknowledgedCostThreshold`` — cost-acknowledgment dialog shown when authenticating via ``ANTHROPIC_API_KEY`` (not shown for OAuth tokens). + It also seeds :data:`~panopticon.container.hooks.THEME` here, claude's *legacy* home for the + setting — the settings file :meth:`write_settings` renders is the modern one, and seeding + both covers either resolution order (the values agree). Seeded, not enforced, for the same + reason: an operator's ``/theme`` inside the container must survive a respawn. + Merge-in-place so we don't clobber config claude writes itself, and idempotent. The path encoding is undocumented internals — a safe degradation if it ever drifts is that the dialog reappears, which only matters in an (already attended) interactive re-attach. @@ -105,6 +110,7 @@ def trust_workspace(self, config_dir: Path, cwd: Path) -> Path: with update_json_config(config) as data: data["hasCompletedOnboarding"] = True data["hasAcknowledgedCostThreshold"] = True + data.setdefault("theme", THEME) projects = data.setdefault("projects", {}) projects.setdefault(str(cwd), {})["hasTrustDialogAccepted"] = True return config diff --git a/src/panopticon/container/hooks.py b/src/panopticon/container/hooks.py index 3a32cfd3..da1a7a59 100644 --- a/src/panopticon/container/hooks.py +++ b/src/panopticon/container/hooks.py @@ -10,6 +10,9 @@ *agent* once it's answered. ``AskUserQuestion`` is a mid-turn tool call — it never fires ``Stop`` — so without this the turn would wrongly read *agent* the whole time the question is pending. +It also seeds the two *non-hook* settings a fresh container needs: the Bypass Permissions +pre-accept (see :func:`settings`) and the starting :data:`THEME`. + claude-specific (`.claude/settings.json`); M3 revisits for other CLIs. Pure — the callback the hooks invoke is :mod:`panopticon.container.hook`. `:blocked:` is preserved by construction: the callback only sets the turn, never the block. @@ -25,6 +28,14 @@ #: The command claude runs for each hook event (sets the turn via the task service). HOOK_COMMAND = "python -m panopticon.container.hook" +#: The theme a fresh container's claude starts in. ``auto`` follows the terminal's background, so a +#: pane an operator attaches over `tmux`/`ssh` is readable on a light *or* dark terminal — claude's +#: own default is a fixed ``dark``. claude has no ``--theme`` flag, so this is config-file state: +#: ``theme`` is a *user setting*, i.e. it lives in the settings file :func:`write_settings` writes +#: (:meth:`~panopticon.container.cli.claude.ClaudeAgentCLI.trust_workspace` seeds the same value +#: into claude's legacy global config for older builds that read it from there). +THEME = "auto" + def settings() -> dict[str, Any]: """The `.claude/settings.json` we seed: the turn-flip hooks **and** a pre-accept of Bypass @@ -61,8 +72,15 @@ def run(actor: str, event: str | None = None, *, matcher: str | None = None) -> def write_settings(home: Path) -> Path: - """Merge the turn-flip hooks into ``/.claude/settings.json``; return the path.""" + """Merge the turn-flip hooks into ``/.claude/settings.json``; return the path. + + :data:`THEME` is seeded rather than enforced: the config dir is a **per-task volume** that + outlives a respawn, and the ask is what a claude instance *starts* in — so a deliberate + ``/theme`` change made inside the container survives every later launch, while the hooks and the + permission pre-accept (which the container can't work without) are re-applied each time. + """ path = home / ".claude" / "settings.json" with update_json_config(path) as data: data.update(settings()) + data.setdefault("theme", THEME) return path diff --git a/tests/container/test_claude.py b/tests/container/test_claude.py index 0bd8bbbc..1298d2d8 100644 --- a/tests/container/test_claude.py +++ b/tests/container/test_claude.py @@ -10,6 +10,7 @@ import pytest from panopticon.container.cli.claude import INTERRUPT_PROMPT, ClaudeAgentCLI +from panopticon.container.hooks import THEME class _FakeClient: @@ -199,6 +200,18 @@ def test_trust_workspace_seeds_acceptance_for_a_fresh_config(tmp_path: Path) -> assert data["projects"]["/workspace"]["hasTrustDialogAccepted"] is True assert data["hasCompletedOnboarding"] is True assert data["hasAcknowledgedCostThreshold"] is True # suppresses the API-key cost dialog + assert data["theme"] == THEME # claude's legacy home for the starting theme + + +def test_trust_workspace_keeps_a_theme_the_container_already_chose(tmp_path: Path) -> None: + # Seeded, not enforced — a `/theme` run inside the container survives the next launch. + config_dir = tmp_path / ".claude" + config_dir.mkdir() + (config_dir / ClaudeAgentCLI.CONFIG_FILE).write_text(json.dumps({"theme": "light"})) + ClaudeAgentCLI().trust_workspace(config_dir, Path("/workspace")) + data = json.loads((config_dir / ClaudeAgentCLI.CONFIG_FILE).read_text()) + assert data["theme"] == "light" + assert data["hasCompletedOnboarding"] is True # the pre-accepts are still applied def test_trust_workspace_merges_and_is_idempotent(tmp_path: Path) -> None: diff --git a/tests/container/test_hooks.py b/tests/container/test_hooks.py index 43d0ffce..509cb94c 100644 --- a/tests/container/test_hooks.py +++ b/tests/container/test_hooks.py @@ -9,7 +9,7 @@ import pytest from panopticon.container import hook -from panopticon.container.hooks import settings, write_settings +from panopticon.container.hooks import THEME, settings, write_settings def test_settings_wire_stop_to_user_and_prompt_to_agent() -> None: @@ -41,6 +41,28 @@ def test_write_settings_writes_claude_settings(tmp_path: Path) -> None: assert "Stop" in json.loads(path.read_text())["hooks"] +def test_write_settings_seeds_the_starting_theme(tmp_path: Path) -> None: + # claude has no --theme flag and defaults to a fixed dark; `auto` follows the terminal's + # background, so an attached pane is readable on a light or dark terminal alike. + path = write_settings(tmp_path) + assert THEME == "auto" + assert json.loads(path.read_text())["theme"] == "auto" + + +def test_write_settings_keeps_a_theme_the_container_already_chose(tmp_path: Path) -> None: + # Seeded, not enforced: the config dir outlives a respawn, so a `/theme` run inside the + # container must survive the next launch — while the hooks are still re-applied. + settings_path = tmp_path / ".claude" / "settings.json" + settings_path.parent.mkdir(parents=True) + settings_path.write_text('{"theme": "dark"}') + + write_settings(tmp_path) + + data = json.loads(settings_path.read_text()) + assert data["theme"] == "dark" # the operator's choice wins + assert "Stop" in data["hooks"] + + def test_write_settings_merges_without_clobbering_existing_keys(tmp_path: Path) -> None: # Routed through the read-merge-write helper: any unrelated settings already on disk survive. settings_path = tmp_path / ".claude" / "settings.json"