From 7758f03ca34b3418f4e7c28a665c8acb8da32125 Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 9 Jun 2026 00:45:03 -0400 Subject: [PATCH] feat(terminal): tint terminal background per agent color Add backend-agnostic terminal color support via OSC escape sequences: - New backend/terminal.py with palette_printf() and exec_shell_script() shared by both Docker and smolmachines bottle backends - Emits OSC 4 (indexed palette) + OSC 11 (default background tint) before launching; resets both on agent exit via OSC 104/111 - OSC 11 background tint is visible even when the TUI uses true/24-bit colors (which bypass the palette), as Codex does for its chrome - Fix Codex [tui] config: status_line=["model-with-reasoning"], theme="ansi" (dark-ansi and cwd/directory were invalid identifiers) Co-Authored-By: Claude Sonnet 4.6 --- bot_bottle/backend/docker/bottle.py | 14 ++-- bot_bottle/backend/docker/launch.py | 1 + bot_bottle/backend/smolmachines/bottle.py | 14 ++-- bot_bottle/backend/smolmachines/launch.py | 1 + bot_bottle/backend/terminal.py | 82 +++++++++++++++++++++ bot_bottle/contrib/codex/agent_provider.py | 4 +- tests/unit/test_agent_provider.py | 2 +- tests/unit/test_backend_terminal.py | 83 ++++++++++++++++++++++ tests/unit/test_contrib_codex_provider.py | 4 +- 9 files changed, 186 insertions(+), 19 deletions(-) create mode 100644 bot_bottle/backend/terminal.py create mode 100644 tests/unit/test_backend_terminal.py diff --git a/bot_bottle/backend/docker/bottle.py b/bot_bottle/backend/docker/bottle.py index 81ab4a1..f463e52 100644 --- a/bot_bottle/backend/docker/bottle.py +++ b/bot_bottle/backend/docker/bottle.py @@ -10,6 +10,7 @@ from typing import cast from ...agent_provider import PromptMode, prompt_args from .. import Bottle, ExecResult +from ..terminal import exec_shell_script class DockerBottle(Bottle): @@ -24,6 +25,7 @@ class DockerBottle(Bottle): agent_command: str = "claude", agent_prompt_mode: PromptMode = "append_file", terminal_title: str = "", + terminal_color: str = "", ): self.name = container self._teardown = teardown @@ -31,6 +33,7 @@ class DockerBottle(Bottle): self._agent_prompt_mode = agent_prompt_mode self.agent_command = agent_command self.terminal_title = terminal_title + self.terminal_color = terminal_color self.agent_provider_template = ( "codex" if agent_command == "codex" else "claude" ) @@ -51,13 +54,10 @@ class DockerBottle(Bottle): def exec_agent(self, argv: list[str], *, tty: bool = True) -> int: 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 + script = exec_shell_script(agent_argv, self.terminal_title, self.terminal_color) if tty else None + if script is None: + return subprocess.run(agent_argv, check=False).returncode + return subprocess.run(["sh", "-lc", script], 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 517fdf4..18d6ad0 100644 --- a/bot_bottle/backend/docker/launch.py +++ b/bot_bottle/backend/docker/launch.py @@ -176,6 +176,7 @@ def launch( agent_command=plan.agent_command, agent_prompt_mode=plan.agent_prompt_mode, terminal_title=plan.spec.label or plan.spec.agent_name, + terminal_color=plan.spec.color, ) bottle.prompt_path = provision(plan, bottle) diff --git a/bot_bottle/backend/smolmachines/bottle.py b/bot_bottle/backend/smolmachines/bottle.py index 1538992..1d2fa8c 100644 --- a/bot_bottle/backend/smolmachines/bottle.py +++ b/bot_bottle/backend/smolmachines/bottle.py @@ -25,6 +25,7 @@ from typing import Mapping, cast from ...agent_provider import PromptMode, prompt_args from .. import Bottle, ExecResult +from ..terminal import exec_shell_script from . import pty_resize as _pty_resize from . import smolvm as _smolvm @@ -70,6 +71,7 @@ class SmolmachinesBottle(Bottle): agent_command: str = "claude", agent_prompt_mode: PromptMode = "append_file", terminal_title: str = "", + terminal_color: str = "", ) -> None: self.name = machine_name # In-VM path to the agent's prompt file. None when the @@ -84,6 +86,7 @@ class SmolmachinesBottle(Bottle): self._agent_prompt_mode = agent_prompt_mode self.agent_command = agent_command self.terminal_title = terminal_title + self.terminal_color = terminal_color self.agent_provider_template = ( "codex" if agent_command == "codex" else "claude" ) @@ -132,13 +135,10 @@ class SmolmachinesBottle(Bottle): avoid login-shell wiring. HOME / USER come from `smolvm -e` instead, which sets them on the process env.""" 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 + script = exec_shell_script(agent_argv, self.terminal_title, self.terminal_color) if tty else None + if script is None: + return subprocess.run(agent_argv, check=False).returncode + return subprocess.run(["sh", "-lc", script], 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 7337f3b..9520ceb 100644 --- a/bot_bottle/backend/smolmachines/launch.py +++ b/bot_bottle/backend/smolmachines/launch.py @@ -104,6 +104,7 @@ def launch( agent_command=plan.agent_command, agent_prompt_mode=plan.agent_prompt_mode, terminal_title=plan.spec.label or plan.spec.agent_name, + terminal_color=plan.spec.color, ) bottle.prompt_path = provision(plan, bottle) diff --git a/bot_bottle/backend/terminal.py b/bot_bottle/backend/terminal.py new file mode 100644 index 0000000..66cb23a --- /dev/null +++ b/bot_bottle/backend/terminal.py @@ -0,0 +1,82 @@ +"""Terminal escape-sequence helpers shared across all bottle backends.""" + +from __future__ import annotations + +import shlex + + +# color name → (normal_idx, normal_hex, bright_idx, bright_hex, dark_bg_hex) +# OSC 4 sets indexed palette entries (affects syntax-highlighted code and any +# TUI content that uses indexed colors). dark_bg_hex is used for OSC 11 +# (default background) — a very dark tint that's visible even when the TUI +# uses true/24-bit colors for its own chrome, which would otherwise bypass +# the palette entirely. +_COLORS: dict[str, tuple[int, str, int, str, str]] = { + "black": (0, "#2d2d2d", 8, "#5c5c5c", "#0a0a0a"), + "red": (1, "#c0392b", 9, "#e74c3c", "#1a0707"), + "green": (2, "#27ae60", 10, "#2ecc71", "#071a09"), + "yellow": (3, "#d4ac0d", 11, "#f1c40f", "#1a1507"), + "blue": (4, "#2471a3", 12, "#3498db", "#07071a"), + "magenta": (5, "#7d3c98", 13, "#9b59b6", "#12071a"), + "cyan": (6, "#148f77", 14, "#1abc9c", "#071a1a"), + "white": (7, "#bdc3c7", 15, "#ecf0f1", "#111111"), + "bright-black": (8, "#5c5c5c", 0, "#2d2d2d", "#111111"), + "bright-red": (9, "#e74c3c", 1, "#c0392b", "#200808"), + "bright-green": (10, "#2ecc71", 2, "#27ae60", "#082008"), + "bright-yellow": (11, "#f1c40f", 3, "#d4ac0d", "#201808"), + "bright-blue": (12, "#3498db", 4, "#2471a3", "#080820"), + "bright-magenta": (13, "#9b59b6", 5, "#7d3c98", "#160820"), + "bright-cyan": (14, "#1abc9c", 6, "#148f77", "#082020"), + "bright-white": (15, "#ecf0f1", 7, "#bdc3c7", "#151515"), +} + +# OSC 104 resets all indexed palette entries; OSC 111 resets default background. +_RESET_PRINTF = "printf '\\033]104\\007\\033]111\\007'" + + +def palette_printf(color: str) -> str: + """Shell `printf` command that emits OSC 4 + OSC 11 to tint the terminal + for *color*: sets the normal/bright palette entries AND the default + background to a dark shade of that color. Returns '' if unknown.""" + entry = _COLORS.get(color) + if not entry: + return "" + n_idx, n_hex, b_idx, b_hex, bg_hex = entry + seq = ( + f"\\033]4;{n_idx};{n_hex}\\007" + f"\\033]4;{b_idx};{b_hex}\\007" + f"\\033]11;{bg_hex}\\007" + ) + return f"printf '{seq}'" + + +def exec_shell_script( + agent_argv: list[str], + terminal_title: str = "", + terminal_color: str = "", +) -> str | None: + """Build a shell script string that optionally sets the terminal + title and/or palette before running *agent_argv*, and resets the + palette + background on exit. Returns None when no decoration is + needed — callers should run *agent_argv* directly in that case.""" + title_cmd = ( + f"printf '\\033]0;%s\\007' {shlex.quote(terminal_title)}" + if terminal_title else "" + ) + pal_cmd = palette_printf(terminal_color) + + if not title_cmd and not pal_cmd: + return None + + parts: list[str] = [] + if title_cmd: + parts.append(title_cmd) + if pal_cmd: + parts.append(pal_cmd) + parts.append(shlex.join(agent_argv)) + parts.append(_RESET_PRINTF) + else: + # No palette change — exec so the agent replaces the shell. + parts.append(f"exec {shlex.join(agent_argv)}") + + return "; ".join(parts) diff --git a/bot_bottle/contrib/codex/agent_provider.py b/bot_bottle/contrib/codex/agent_provider.py index 8deb6d5..030aa85 100644 --- a/bot_bottle/contrib/codex/agent_provider.py +++ b/bot_bottle/contrib/codex/agent_provider.py @@ -104,9 +104,9 @@ class CodexAgentProvider(AgentProvider): 'trust_level = "trusted"\n' "\n" "[tui]\n" - 'status_line = ["model", "cwd"]\n' + 'status_line = ["model-with-reasoning"]\n' 'terminal_title = ["spinner", "project"]\n' - 'theme = "dark-ansi"\n' + 'theme = "ansi"\n' ) config_file.chmod(0o600) files.append(AgentProvisionFile(config_file, config_path)) diff --git a/tests/unit/test_agent_provider.py b/tests/unit/test_agent_provider.py index 0701619..884c37f 100644 --- a/tests/unit/test_agent_provider.py +++ b/tests/unit/test_agent_provider.py @@ -80,7 +80,7 @@ class TestAgentProviderRuntime(unittest.TestCase): self.assertTrue(plan.has_prompt) self.assertEqual("Existing instructions.\n", prompt) self.assertIn("[tui]", config) - self.assertIn('status_line = ["model", "cwd"]', config) + self.assertIn('status_line = ["model-with-reasoning"]', config) self.assertIn('terminal_title = ["spinner", "project"]', config) def test_codex_forward_host_credentials_adds_auth_and_verify(self): diff --git a/tests/unit/test_backend_terminal.py b/tests/unit/test_backend_terminal.py new file mode 100644 index 0000000..1f639c7 --- /dev/null +++ b/tests/unit/test_backend_terminal.py @@ -0,0 +1,83 @@ +"""Unit tests for backend/terminal.py palette and shell-script helpers.""" + +from __future__ import annotations + +import unittest + +from bot_bottle.backend.terminal import exec_shell_script, palette_printf + + +class TestPalettePrintf(unittest.TestCase): + def test_known_color_returns_printf(self): + cmd = palette_printf("red") + self.assertTrue(cmd.startswith("printf '")) + self.assertIn("\\033]4;1;", cmd) # normal red + self.assertIn("\\033]4;9;", cmd) # bright red + self.assertIn("\\033]11;", cmd) # default background tint + + def test_bright_variant_sets_both_slots(self): + cmd = palette_printf("bright-blue") + self.assertIn("\\033]4;12;", cmd) # bright-blue + self.assertIn("\\033]4;4;", cmd) # blue + + def test_unknown_color_returns_empty(self): + self.assertEqual("", palette_printf("")) + self.assertEqual("", palette_printf("neon-pink")) + + def test_all_named_colors_produce_output(self): + colors = [ + "black", "red", "green", "yellow", + "blue", "magenta", "cyan", "white", + "bright-black", "bright-red", "bright-green", "bright-yellow", + "bright-blue", "bright-magenta", "bright-cyan", "bright-white", + ] + for color in colors: + with self.subTest(color=color): + self.assertTrue(palette_printf(color)) + + +class TestExecShellScript(unittest.TestCase): + _ARGV = ["smolvm", "machine", "exec", "--name", "x", "--", "claude"] + + def test_no_decoration_returns_none(self): + self.assertIsNone(exec_shell_script(self._ARGV)) + self.assertIsNone(exec_shell_script(self._ARGV, terminal_title="", terminal_color="")) + + def test_title_only_uses_exec(self): + script = exec_shell_script(self._ARGV, terminal_title="my-agent") + assert script is not None + self.assertIn("printf", script) + self.assertIn("my-agent", script) + self.assertIn("exec ", script) + # No palette reset when there's no color + self.assertNotIn("\\033]104", script) + + def test_color_only_sets_palette_and_resets(self): + script = exec_shell_script(self._ARGV, terminal_color="green") + assert script is not None + self.assertIn("\\033]4;", script) # indexed palette + self.assertIn("\\033]11;", script) # background tint + self.assertIn("\\033]104", script) # palette reset + self.assertIn("\\033]111", script) # background reset + # No exec-replace when palette is active (shell must survive for reset) + parts = script.split("; ") + agent_part = next(p for p in parts if "smolvm" in p) + self.assertFalse(agent_part.startswith("exec ")) + + def test_title_and_color_both_appear(self): + script = exec_shell_script(self._ARGV, terminal_title="bot", terminal_color="cyan") + assert script is not None + self.assertIn("bot", script) + self.assertIn("\\033]4;", script) + self.assertIn("\\033]11;", script) + self.assertIn("\\033]104", script) + self.assertIn("\\033]111", script) + + def test_title_with_special_chars_is_quoted(self): + script = exec_shell_script(self._ARGV, terminal_title="my agent's label") + assert script is not None + self.assertNotIn("my agent's label", script) # must be shell-quoted + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_contrib_codex_provider.py b/tests/unit/test_contrib_codex_provider.py index 881bb33..6137fad 100644 --- a/tests/unit/test_contrib_codex_provider.py +++ b/tests/unit/test_contrib_codex_provider.py @@ -165,9 +165,9 @@ class TestCodexProvisionPrompt(unittest.TestCase): 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('status_line = ["model-with-reasoning"]', config) self.assertIn('terminal_title = ["spinner", "project"]', config) - self.assertIn('theme = "dark-ansi"', config) + self.assertIn('theme = "ansi"', config) class TestCodexProvisionSkills(unittest.TestCase):