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 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ from typing import cast
|
|||||||
|
|
||||||
from ...agent_provider import PromptMode, prompt_args
|
from ...agent_provider import PromptMode, prompt_args
|
||||||
from .. import Bottle, ExecResult
|
from .. import Bottle, ExecResult
|
||||||
|
from ..terminal import exec_shell_script
|
||||||
|
|
||||||
|
|
||||||
class DockerBottle(Bottle):
|
class DockerBottle(Bottle):
|
||||||
@@ -24,6 +25,7 @@ class DockerBottle(Bottle):
|
|||||||
agent_command: str = "claude",
|
agent_command: str = "claude",
|
||||||
agent_prompt_mode: PromptMode = "append_file",
|
agent_prompt_mode: PromptMode = "append_file",
|
||||||
terminal_title: str = "",
|
terminal_title: str = "",
|
||||||
|
terminal_color: str = "",
|
||||||
):
|
):
|
||||||
self.name = container
|
self.name = container
|
||||||
self._teardown = teardown
|
self._teardown = teardown
|
||||||
@@ -31,6 +33,7 @@ class DockerBottle(Bottle):
|
|||||||
self._agent_prompt_mode = agent_prompt_mode
|
self._agent_prompt_mode = agent_prompt_mode
|
||||||
self.agent_command = agent_command
|
self.agent_command = agent_command
|
||||||
self.terminal_title = terminal_title
|
self.terminal_title = terminal_title
|
||||||
|
self.terminal_color = terminal_color
|
||||||
self.agent_provider_template = (
|
self.agent_provider_template = (
|
||||||
"codex" if agent_command == "codex" else "claude"
|
"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:
|
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int:
|
||||||
agent_argv = self.agent_argv(argv, tty=tty)
|
agent_argv = self.agent_argv(argv, tty=tty)
|
||||||
if self.terminal_title and tty:
|
script = exec_shell_script(agent_argv, self.terminal_title, self.terminal_color) if tty else None
|
||||||
shell_script = (
|
if script is None:
|
||||||
f"printf '\\033]0;%s\\007' {shlex.quote(self.terminal_title)}; "
|
return subprocess.run(agent_argv, check=False).returncode
|
||||||
f"exec {shlex.join(agent_argv)}"
|
return subprocess.run(["sh", "-lc", script], check=False).returncode
|
||||||
)
|
|
||||||
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:
|
def exec(self, script: str, *, user: str = "node") -> ExecResult:
|
||||||
# Pipe via stdin to `sh -s` so the caller never has to worry
|
# Pipe via stdin to `sh -s` so the caller never has to worry
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ def launch(
|
|||||||
agent_command=plan.agent_command,
|
agent_command=plan.agent_command,
|
||||||
agent_prompt_mode=plan.agent_prompt_mode,
|
agent_prompt_mode=plan.agent_prompt_mode,
|
||||||
terminal_title=plan.spec.label or plan.spec.agent_name,
|
terminal_title=plan.spec.label or plan.spec.agent_name,
|
||||||
|
terminal_color=plan.spec.color,
|
||||||
)
|
)
|
||||||
bottle.prompt_path = provision(plan, bottle)
|
bottle.prompt_path = provision(plan, bottle)
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from typing import Mapping, cast
|
|||||||
|
|
||||||
from ...agent_provider import PromptMode, prompt_args
|
from ...agent_provider import PromptMode, prompt_args
|
||||||
from .. import Bottle, ExecResult
|
from .. import Bottle, ExecResult
|
||||||
|
from ..terminal import exec_shell_script
|
||||||
from . import pty_resize as _pty_resize
|
from . import pty_resize as _pty_resize
|
||||||
from . import smolvm as _smolvm
|
from . import smolvm as _smolvm
|
||||||
|
|
||||||
@@ -70,6 +71,7 @@ class SmolmachinesBottle(Bottle):
|
|||||||
agent_command: str = "claude",
|
agent_command: str = "claude",
|
||||||
agent_prompt_mode: PromptMode = "append_file",
|
agent_prompt_mode: PromptMode = "append_file",
|
||||||
terminal_title: str = "",
|
terminal_title: str = "",
|
||||||
|
terminal_color: str = "",
|
||||||
) -> None:
|
) -> None:
|
||||||
self.name = machine_name
|
self.name = machine_name
|
||||||
# In-VM path to the agent's prompt file. None when the
|
# 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_prompt_mode = agent_prompt_mode
|
||||||
self.agent_command = agent_command
|
self.agent_command = agent_command
|
||||||
self.terminal_title = terminal_title
|
self.terminal_title = terminal_title
|
||||||
|
self.terminal_color = terminal_color
|
||||||
self.agent_provider_template = (
|
self.agent_provider_template = (
|
||||||
"codex" if agent_command == "codex" else "claude"
|
"codex" if agent_command == "codex" else "claude"
|
||||||
)
|
)
|
||||||
@@ -132,13 +135,10 @@ class SmolmachinesBottle(Bottle):
|
|||||||
avoid login-shell wiring. HOME / USER come from `smolvm
|
avoid login-shell wiring. HOME / USER come from `smolvm
|
||||||
-e` instead, which sets them on the process env."""
|
-e` instead, which sets them on the process env."""
|
||||||
agent_argv = self.agent_argv(argv, tty=tty)
|
agent_argv = self.agent_argv(argv, tty=tty)
|
||||||
if self.terminal_title and tty:
|
script = exec_shell_script(agent_argv, self.terminal_title, self.terminal_color) if tty else None
|
||||||
shell_script = (
|
if script is None:
|
||||||
f"printf '\\033]0;%s\\007' {shlex.quote(self.terminal_title)}; "
|
return subprocess.run(agent_argv, check=False).returncode
|
||||||
f"exec {shlex.join(agent_argv)}"
|
return subprocess.run(["sh", "-lc", script], check=False).returncode
|
||||||
)
|
|
||||||
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
|
# smolvm/libkrun can SIGKILL an otherwise-normal exec during
|
||||||
# early-VM provisioning. Retry once after a short settle so
|
# early-VM provisioning. Retry once after a short settle so
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ def launch(
|
|||||||
agent_command=plan.agent_command,
|
agent_command=plan.agent_command,
|
||||||
agent_prompt_mode=plan.agent_prompt_mode,
|
agent_prompt_mode=plan.agent_prompt_mode,
|
||||||
terminal_title=plan.spec.label or plan.spec.agent_name,
|
terminal_title=plan.spec.label or plan.spec.agent_name,
|
||||||
|
terminal_color=plan.spec.color,
|
||||||
)
|
)
|
||||||
bottle.prompt_path = provision(plan, bottle)
|
bottle.prompt_path = provision(plan, bottle)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -104,9 +104,9 @@ class CodexAgentProvider(AgentProvider):
|
|||||||
'trust_level = "trusted"\n'
|
'trust_level = "trusted"\n'
|
||||||
"\n"
|
"\n"
|
||||||
"[tui]\n"
|
"[tui]\n"
|
||||||
'status_line = ["model", "cwd"]\n'
|
'status_line = ["model-with-reasoning"]\n'
|
||||||
'terminal_title = ["spinner", "project"]\n'
|
'terminal_title = ["spinner", "project"]\n'
|
||||||
'theme = "dark-ansi"\n'
|
'theme = "ansi"\n'
|
||||||
)
|
)
|
||||||
config_file.chmod(0o600)
|
config_file.chmod(0o600)
|
||||||
files.append(AgentProvisionFile(config_file, config_path))
|
files.append(AgentProvisionFile(config_file, config_path))
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
self.assertTrue(plan.has_prompt)
|
self.assertTrue(plan.has_prompt)
|
||||||
self.assertEqual("Existing instructions.\n", prompt)
|
self.assertEqual("Existing instructions.\n", prompt)
|
||||||
self.assertIn("[tui]", config)
|
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('terminal_title = ["spinner", "project"]', config)
|
||||||
|
|
||||||
def test_codex_forward_host_credentials_adds_auth_and_verify(self):
|
def test_codex_forward_host_credentials_adds_auth_and_verify(self):
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -165,9 +165,9 @@ class TestCodexProvisionPrompt(unittest.TestCase):
|
|||||||
self.assertTrue(plan.has_prompt)
|
self.assertTrue(plan.has_prompt)
|
||||||
self.assertEqual("Existing instructions.\n", prompt_text)
|
self.assertEqual("Existing instructions.\n", prompt_text)
|
||||||
self.assertIn("[tui]", config)
|
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('terminal_title = ["spinner", "project"]', config)
|
||||||
self.assertIn('theme = "dark-ansi"', config)
|
self.assertIn('theme = "ansi"', config)
|
||||||
|
|
||||||
|
|
||||||
class TestCodexProvisionSkills(unittest.TestCase):
|
class TestCodexProvisionSkills(unittest.TestCase):
|
||||||
|
|||||||
Reference in New Issue
Block a user