diff --git a/bot_bottle/backend/docker/bottle.py b/bot_bottle/backend/docker/bottle.py index 7294051..81ab4a1 100644 --- a/bot_bottle/backend/docker/bottle.py +++ b/bot_bottle/backend/docker/bottle.py @@ -3,6 +3,7 @@ from __future__ import annotations import subprocess +import shlex from typing import Callable from typing import cast @@ -22,12 +23,14 @@ class DockerBottle(Bottle): *, agent_command: str = "claude", agent_prompt_mode: PromptMode = "append_file", + terminal_title: str = "", ): self.name = container self._teardown = teardown self.prompt_path = prompt_path_in_container self._agent_prompt_mode = agent_prompt_mode self.agent_command = agent_command + self.terminal_title = terminal_title self.agent_provider_template = ( "codex" if agent_command == "codex" else "claude" ) @@ -47,9 +50,14 @@ class DockerBottle(Bottle): return cmd def exec_agent(self, argv: list[str], *, tty: bool = True) -> int: - return subprocess.run( - self.agent_argv(argv, tty=tty), check=False, - ).returncode + agent_argv = self.agent_argv(argv, tty=tty) + if self.terminal_title and tty: + shell_script = ( + f"printf '\\033]0;%s\\007' {shlex.quote(self.terminal_title)}; " + f"exec {shlex.join(agent_argv)}" + ) + return subprocess.run(["sh", "-lc", shell_script], check=False).returncode + return subprocess.run(agent_argv, check=False).returncode def exec(self, script: str, *, user: str = "node") -> ExecResult: # Pipe via stdin to `sh -s` so the caller never has to worry diff --git a/bot_bottle/backend/docker/launch.py b/bot_bottle/backend/docker/launch.py index 11380d4..517fdf4 100644 --- a/bot_bottle/backend/docker/launch.py +++ b/bot_bottle/backend/docker/launch.py @@ -175,6 +175,7 @@ def launch( None, agent_command=plan.agent_command, agent_prompt_mode=plan.agent_prompt_mode, + terminal_title=plan.spec.label or plan.spec.agent_name, ) bottle.prompt_path = provision(plan, bottle) diff --git a/bot_bottle/backend/smolmachines/bottle.py b/bot_bottle/backend/smolmachines/bottle.py index 81e0ee6..1538992 100644 --- a/bot_bottle/backend/smolmachines/bottle.py +++ b/bot_bottle/backend/smolmachines/bottle.py @@ -20,6 +20,7 @@ from __future__ import annotations import subprocess import sys import time +import shlex from typing import Mapping, cast from ...agent_provider import PromptMode, prompt_args @@ -68,6 +69,7 @@ class SmolmachinesBottle(Bottle): guest_env: Mapping[str, str] | None = None, agent_command: str = "claude", agent_prompt_mode: PromptMode = "append_file", + terminal_title: str = "", ) -> None: self.name = machine_name # In-VM path to the agent's prompt file. None when the @@ -81,6 +83,7 @@ class SmolmachinesBottle(Bottle): self._guest_env = dict(guest_env or {}) self._agent_prompt_mode = agent_prompt_mode self.agent_command = agent_command + self.terminal_title = terminal_title self.agent_provider_template = ( "codex" if agent_command == "codex" else "claude" ) @@ -128,9 +131,14 @@ class SmolmachinesBottle(Bottle): UID switches via `runuser -u node --` (not `-l`) so we avoid login-shell wiring. HOME / USER come from `smolvm -e` instead, which sets them on the process env.""" - return subprocess.run( - self.agent_argv(argv, tty=tty), check=False, - ).returncode + agent_argv = self.agent_argv(argv, tty=tty) + if self.terminal_title and tty: + shell_script = ( + f"printf '\\033]0;%s\\007' {shlex.quote(self.terminal_title)}; " + f"exec {shlex.join(agent_argv)}" + ) + return subprocess.run(["sh", "-lc", shell_script], check=False).returncode + return subprocess.run(agent_argv, check=False).returncode # smolvm/libkrun can SIGKILL an otherwise-normal exec during # early-VM provisioning. Retry once after a short settle so diff --git a/bot_bottle/backend/smolmachines/launch.py b/bot_bottle/backend/smolmachines/launch.py index cdcb843..7337f3b 100644 --- a/bot_bottle/backend/smolmachines/launch.py +++ b/bot_bottle/backend/smolmachines/launch.py @@ -103,6 +103,7 @@ def launch( guest_env=plan.guest_env, agent_command=plan.agent_command, agent_prompt_mode=plan.agent_prompt_mode, + terminal_title=plan.spec.label or plan.spec.agent_name, ) bottle.prompt_path = provision(plan, bottle) diff --git a/bot_bottle/contrib/claude/agent_provider.py b/bot_bottle/contrib/claude/agent_provider.py index 9b1bd5d..113849b 100644 --- a/bot_bottle/contrib/claude/agent_provider.py +++ b/bot_bottle/contrib/claude/agent_provider.py @@ -17,9 +17,11 @@ from typing import TYPE_CHECKING from ...agent_provider import ( AgentProvider, AgentProviderRuntime, + AgentProvisionDir, AgentProvisionFile, AgentProvisionPlan, ) +from ...backend.docker import util as docker_mod from ...egress import EgressRoute from ...log import die, info, warn @@ -39,24 +41,68 @@ def _prompt_path(guest_home: str) -> str: return f"{guest_home}/.bot-bottle-prompt.txt" -def _display_identity_prompt(label: str, color: str) -> str: - lines: list[str] = [] - if label: - lines.append(f"Name: {label}") - if color: - lines.append(f"Color: {color}") - if not lines: - return "" - return "Bot-bottle agent display identity:\n" + "\n".join(lines) +_STATUS_LINE_COLORS = { + "black": "\033[30m", + "red": "\033[31m", + "green": "\033[32m", + "yellow": "\033[33m", + "blue": "\033[34m", + "magenta": "\033[35m", + "cyan": "\033[36m", + "white": "\033[37m", + "bright-black": "\033[90m", + "bright-red": "\033[91m", + "bright-green": "\033[92m", + "bright-yellow": "\033[93m", + "bright-blue": "\033[94m", + "bright-magenta": "\033[95m", + "bright-cyan": "\033[96m", + "bright-white": "\033[97m", +} + +_CLAUDE_THEME_COLORS = { + "black": "black", + "red": "red", + "green": "green", + "yellow": "yellow", + "blue": "blue", + "magenta": "magenta", + "cyan": "cyan", + "white": "white", + "bright-black": "blackBright", + "bright-red": "redBright", + "bright-green": "greenBright", + "bright-yellow": "yellowBright", + "bright-blue": "blueBright", + "bright-magenta": "magentaBright", + "bright-cyan": "cyanBright", + "bright-white": "whiteBright", +} -def _prepend_display_identity(prompt_file: Path, label: str, color: str) -> bool: - identity = _display_identity_prompt(label, color) - original = prompt_file.read_text() if prompt_file.exists() else "" - if not identity: - return bool(original) - prompt_file.write_text(f"{identity}\n\n{original}" if original else f"{identity}\n") - return True +def _status_line_script(label: str, color: str) -> str: + if not label: + return "#!/bin/sh\nprintf '\\n'\n" + label_q = shlex.quote(label) + if color and color in _STATUS_LINE_COLORS: + return ( + "#!/bin/sh\n" + f"printf '%b%s%b\\n' '{_STATUS_LINE_COLORS[color]}' {label_q} '\\033[0m'\n" + ) + return f"#!/bin/sh\nprintf '%s\\n' {label_q}\n" + + +def _custom_theme_payload(color: str) -> dict[str, object] | None: + theme_color = _CLAUDE_THEME_COLORS.get(color) + if not theme_color: + return None + return { + "name": f"Bot-bottle {color}", + "base": "dark", + "overrides": { + "claude": f"ansi:{theme_color}", + }, + } _RUNTIME = AgentProviderRuntime( @@ -99,6 +145,10 @@ class ClaudeAgentProvider(AgentProvider): "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1", "DISABLE_ERROR_REPORTING": "1", } + dirs = ( + AgentProvisionDir(f"{guest_home}/.claude"), + AgentProvisionDir(f"{guest_home}/.claude/themes"), + ) claude_config = state_dir / "claude.json" claude_projects = {guest_home: {"hasTrustDialogAccepted": True}} claude_projects[trusted_path] = {"hasTrustDialogAccepted": True} @@ -108,15 +158,45 @@ class ClaudeAgentProvider(AgentProvider): "bypassPermissionsModeAccepted": True, "projects": claude_projects, } - if label: - payload["name"] = label - if color: - payload["color"] = color claude_config.write_text(json.dumps(payload, indent=2) + "\n") claude_config.chmod(0o600) - files = ( + files = [ AgentProvisionFile(claude_config, f"{guest_home}/.claude.json"), - ) + ] + + claude_settings = state_dir / "claude-settings.json" + claude_settings_payload: dict[str, object] = {} + if label or color: + statusline_script = state_dir / "claude-statusline.sh" + statusline_script.write_text(_status_line_script(label, color)) + statusline_script.chmod(0o755) + files.append(AgentProvisionFile( + statusline_script, + f"{guest_home}/.claude/statusline.sh", + mode="755", + )) + claude_settings_payload["statusLine"] = { + "type": "command", + "command": "~/.claude/statusline.sh", + } + theme_payload = _custom_theme_payload(color) + if theme_payload is not None: + theme_name = f"bot-bottle-{docker_mod.slugify(label or color)}" + theme_file = state_dir / f"{theme_name}.json" + theme_file.write_text(json.dumps(theme_payload, indent=2) + "\n") + theme_file.chmod(0o644) + files.append(AgentProvisionFile( + theme_file, + f"{guest_home}/.claude/themes/{theme_name}.json", + )) + claude_settings_payload["theme"] = f"custom:{theme_name}" + if claude_settings_payload: + claude_settings.write_text(json.dumps(claude_settings_payload, indent=2) + "\n") + claude_settings.chmod(0o600) + files.append(AgentProvisionFile( + claude_settings, + f"{guest_home}/.claude/settings.json", + )) egress_routes = (EgressRoute( host="api.anthropic.com", auth_scheme="Bearer" if auth_token else "", @@ -127,7 +207,7 @@ class ClaudeAgentProvider(AgentProvider): env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder" hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"}) - has_prompt = _prepend_display_identity(prompt_file, label, color) + has_prompt = prompt_file.exists() and bool(prompt_file.read_text()) return AgentProvisionPlan( template=_RUNTIME.template, command=_RUNTIME.command, @@ -140,7 +220,8 @@ class ClaudeAgentProvider(AgentProvider): env_vars=env_vars, guest_env=resolved_guest_env, has_prompt=has_prompt, - files=files, + dirs=dirs, + files=tuple(files), egress_routes=egress_routes, hidden_env_names=hidden_env_names, ) diff --git a/bot_bottle/contrib/codex/agent_provider.py b/bot_bottle/contrib/codex/agent_provider.py index 894e50a..8deb6d5 100644 --- a/bot_bottle/contrib/codex/agent_provider.py +++ b/bot_bottle/contrib/codex/agent_provider.py @@ -18,8 +18,8 @@ from ...agent_provider import ( CODEX_HOST_CREDENTIAL_HOSTS, AgentProvider, AgentProviderRuntime, - AgentProvisionCommand, AgentProvisionDir, + AgentProvisionCommand, AgentProvisionFile, AgentProvisionPlan, ) @@ -47,26 +47,6 @@ def _prompt_path(guest_home: str) -> str: return f"{guest_home}/.bot-bottle-prompt.txt" -def _display_identity_prompt(label: str, color: str) -> str: - lines: list[str] = [] - if label: - lines.append(f"Name: {label}") - if color: - lines.append(f"Color: {color}") - if not lines: - return "" - return "Bot-bottle agent display identity:\n" + "\n".join(lines) - - -def _prepend_display_identity(prompt_file: Path, label: str, color: str) -> bool: - identity = _display_identity_prompt(label, color) - original = prompt_file.read_text() if prompt_file.exists() else "" - if not identity: - return bool(original) - prompt_file.write_text(f"{identity}\n\n{original}" if original else f"{identity}\n") - return True - - _RUNTIME = AgentProviderRuntime( template="codex", command="codex", @@ -98,7 +78,7 @@ class CodexAgentProvider(AgentProvider): label: str = "", color: str = "", ) -> AgentProvisionPlan: - del auth_token # Claude-only knob + del auth_token, label, color # Claude-only / title-only knobs resolved_guest_env = dict(guest_env or {}) guest_home = self.guest_home trusted_path = trusted_project_path or guest_home @@ -122,6 +102,11 @@ class CodexAgentProvider(AgentProvider): config_file.write_text( f'[projects."{toml_path}"]\n' 'trust_level = "trusted"\n' + "\n" + "[tui]\n" + 'status_line = ["model", "cwd"]\n' + 'terminal_title = ["spinner", "project"]\n' + 'theme = "dark-ansi"\n' ) config_file.chmod(0o600) files.append(AgentProvisionFile(config_file, config_path)) @@ -164,7 +149,7 @@ class CodexAgentProvider(AgentProvider): "guest, but Codex did not accept it" ))) - has_prompt = _prepend_display_identity(prompt_file, label, color) + has_prompt = prompt_file.exists() and bool(prompt_file.read_text()) return AgentProvisionPlan( template=_RUNTIME.template, command=_RUNTIME.command, diff --git a/tests/unit/test_agent_provider.py b/tests/unit/test_agent_provider.py index 6e05ac2..0701619 100644 --- a/tests/unit/test_agent_provider.py +++ b/tests/unit/test_agent_provider.py @@ -62,7 +62,7 @@ class TestAgentProviderRuntime(unittest.TestCase): config = Path(tmp, "codex-config.toml").read_text() self.assertIn('[projects."/home/node/workspace"]', config) - def test_codex_injects_name_and_color_into_prompt(self): + def test_codex_writes_tui_settings_without_mutating_prompt(self): with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: prompt_file = Path(tmp) / "prompt.txt" prompt_file.write_text("Existing instructions.\n") @@ -76,11 +76,12 @@ class TestAgentProviderRuntime(unittest.TestCase): color="bright-cyan", ) prompt = prompt_file.read_text() + config = Path(tmp, "codex-config.toml").read_text() self.assertTrue(plan.has_prompt) - self.assertIn("Bot-bottle agent display identity:", prompt) - self.assertIn("Name: review-api", prompt) - self.assertIn("Color: bright-cyan", prompt) - self.assertTrue(prompt.endswith("Existing instructions.\n")) + self.assertEqual("Existing instructions.\n", prompt) + self.assertIn("[tui]", config) + self.assertIn('status_line = ["model", "cwd"]', config) + self.assertIn('terminal_title = ["spinner", "project"]', config) def test_codex_forward_host_credentials_adds_auth_and_verify(self): with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: @@ -146,7 +147,7 @@ class TestAgentProviderRuntime(unittest.TestCase): self.assertIn("/home/node", config["projects"]) self.assertIn("/home/node/workspace", config["projects"]) - def test_claude_injects_name_and_color_into_prompt(self): + def test_claude_writes_statusline_and_theme_without_mutating_prompt(self): with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: prompt_file = Path(tmp) / "prompt.txt" prompt_file.write_text("Existing instructions.\n") @@ -160,11 +161,11 @@ class TestAgentProviderRuntime(unittest.TestCase): color="green", ) prompt = prompt_file.read_text() + settings = json.loads(Path(tmp, "claude-settings.json").read_text()) self.assertTrue(plan.has_prompt) - self.assertIn("Bot-bottle agent display identity:", prompt) - self.assertIn("Name: research-ui", prompt) - self.assertIn("Color: green", prompt) - self.assertTrue(prompt.endswith("Existing instructions.\n")) + self.assertEqual("Existing instructions.\n", prompt) + self.assertEqual("~/.claude/statusline.sh", settings["statusLine"]["command"]) + self.assertEqual("custom:bot-bottle-research-ui", settings["theme"]) def test_codex_forward_host_credentials_populates_egress_routes(self): with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: diff --git a/tests/unit/test_contrib_claude_provider.py b/tests/unit/test_contrib_claude_provider.py index 08a0813..820e6b6 100644 --- a/tests/unit/test_contrib_claude_provider.py +++ b/tests/unit/test_contrib_claude_provider.py @@ -8,6 +8,8 @@ either side are expected to diverge the implementations.""" from __future__ import annotations +import json +import tempfile import unittest from pathlib import Path from unittest.mock import MagicMock, patch @@ -127,7 +129,7 @@ class TestClaudeProvisionPrompt(unittest.TestCase): self.assertIsNone(r) bottle.cp_in.assert_called_once() - def test_returns_path_when_provider_prompt_has_identity(self): + def test_returns_path_when_provider_prompt_exists(self): bottle = _make_bottle() provision = AgentProvisionPlan( template="claude", command="claude", prompt_mode="append_file", @@ -261,6 +263,35 @@ class TestClaudeProvision(unittest.TestCase): _plan(agent_provision=provision), bottle, ) + +class TestClaudeUiProvision(unittest.TestCase): + def test_writes_statusline_and_custom_theme_files(self): + with tempfile.TemporaryDirectory(prefix="bb-claude-ui.") as tmp: + state_dir = Path(tmp) + prompt_file = state_dir / "prompt.txt" + prompt_file.write_text("Existing instructions.\n") + plan = ClaudeAgentProvider().provision_plan( + dockerfile="", + state_dir=state_dir, + instance_name="bot-bottle-demo-abc12", + prompt_file=prompt_file, + label="research-ui", + color="bright-cyan", + ) + settings = json.loads((state_dir / "claude-settings.json").read_text()) + statusline = (state_dir / "claude-statusline.sh").read_text() + theme = json.loads((state_dir / "bot-bottle-research-ui.json").read_text()) + prompt_text = prompt_file.read_text() + self.assertTrue(plan.has_prompt) + self.assertEqual("Existing instructions.\n", prompt_text) + self.assertEqual("command", settings["statusLine"]["type"]) + self.assertEqual("~/.claude/statusline.sh", settings["statusLine"]["command"]) + self.assertEqual("custom:bot-bottle-research-ui", settings["theme"]) + self.assertIn("research-ui", statusline) + self.assertIn("\x1b[96m", statusline) + self.assertEqual("dark", theme["base"]) + self.assertEqual("ansi:cyanBright", theme["overrides"]["claude"]) + def test_runs_verify_commands(self): provision = AgentProvisionPlan( template="claude", command="claude", prompt_mode="append_file", diff --git a/tests/unit/test_contrib_codex_provider.py b/tests/unit/test_contrib_codex_provider.py index cf847bb..881bb33 100644 --- a/tests/unit/test_contrib_codex_provider.py +++ b/tests/unit/test_contrib_codex_provider.py @@ -9,6 +9,7 @@ no claude equivalent.""" from __future__ import annotations import unittest +import tempfile from pathlib import Path from unittest.mock import MagicMock, patch @@ -130,7 +131,7 @@ class TestCodexProvisionPrompt(unittest.TestCase): self.assertIsNone(r) bottle.cp_in.assert_called_once() - def test_returns_path_when_provider_prompt_has_identity(self): + def test_returns_path_when_provider_prompt_exists(self): bottle = _make_bottle() provision = AgentProvisionPlan( template="codex", command="codex", @@ -146,6 +147,28 @@ class TestCodexProvisionPrompt(unittest.TestCase): ) self.assertEqual("/home/node/.bot-bottle-prompt.txt", r) + def test_writes_tui_settings_into_codex_config(self): + with tempfile.TemporaryDirectory(prefix="bb-codex-ui.") as tmp: + state_dir = Path(tmp) + prompt_file = state_dir / "prompt.txt" + prompt_file.write_text("Existing instructions.\n") + plan = CodexAgentProvider().provision_plan( + dockerfile="", + state_dir=state_dir, + instance_name="bot-bottle-demo-abc12", + prompt_file=prompt_file, + label="research-ui", + color="bright-cyan", + ) + config = (state_dir / "codex-config.toml").read_text() + prompt_text = prompt_file.read_text() + self.assertTrue(plan.has_prompt) + self.assertEqual("Existing instructions.\n", prompt_text) + self.assertIn("[tui]", config) + self.assertIn('status_line = ["model", "cwd"]', config) + self.assertIn('terminal_title = ["spinner", "project"]', config) + self.assertIn('theme = "dark-ansi"', config) + class TestCodexProvisionSkills(unittest.TestCase): def test_noop_when_agent_has_no_skills(self):