diff --git a/cli.py b/cli.py index 3f124f5af..253cdd085 100755 --- a/cli.py +++ b/cli.py @@ -404,8 +404,10 @@ except Exception: from rich import box as rich_box from rich.console import Console +from rich.markup import escape as _escape from rich.panel import Panel from rich.table import Table +from rich.text import Text as _RichText import fire @@ -696,6 +698,24 @@ _BOLD = "\033[1m" _DIM = "\033[2m" _RST = "\033[0m" +def _accent_hex() -> str: + """Return the active skin accent color for legacy CLI output lines.""" + try: + from hermes_cli.skin_engine import get_active_skin + return get_active_skin().get_color("ui_accent", "#FFBF00") + except Exception: + return "#FFBF00" + + +def _rich_text_from_ansi(text: str) -> _RichText: + """Safely render assistant/tool output that may contain ANSI escapes. + + Using Rich Text.from_ansi preserves literal bracketed text like + ``[not markup]`` while still interpreting real ANSI color codes. + """ + return _RichText.from_ansi(text or "") + + def _cprint(text: str): """Print ANSI-colored text through prompt_toolkit's native renderer. @@ -718,7 +738,12 @@ class ChatConsole: def __init__(self): from io import StringIO self._buffer = StringIO() - self._inner = Console(file=self._buffer, force_terminal=True, highlight=False) + self._inner = Console( + file=self._buffer, + force_terminal=True, + color_system="truecolor", + highlight=False, + ) def print(self, *args, **kwargs): self._buffer.seek(0) @@ -1472,13 +1497,16 @@ class HermesCLI: title_part = "" if session_meta.get("title"): title_part = f" \"{session_meta['title']}\"" - _cprint( - f"{_GOLD}↻ Resumed session {_BOLD}{self.session_id}{_RST}{_GOLD}{title_part} " - f"({msg_count} user message{'s' if msg_count != 1 else ''}, " - f"{len(restored)} total messages){_RST}" + ChatConsole().print( + f"[bold {_accent_hex()}]↻ Resumed session[/] " + f"[bold]{_escape(self.session_id)}[/]" + f"[bold {_accent_hex()}]{_escape(title_part)}[/] " + f"({msg_count} user message{'s' if msg_count != 1 else ''}, {len(restored)} total messages)" ) else: - _cprint(f"{_GOLD}Session {self.session_id} found but has no messages. Starting fresh.{_RST}") + ChatConsole().print( + f"[bold {_accent_hex()}]Session {_escape(self.session_id)} found but has no messages. Starting fresh.[/]" + ) # Re-open the session (clear ended_at so it's active again) try: self._session_db._conn.execute( @@ -1738,6 +1766,19 @@ class HermesCLI: from rich.panel import Panel from rich.text import Text + try: + from hermes_cli.skin_engine import get_active_skin + _skin = get_active_skin() + _history_text_c = _skin.get_color("banner_text", "#FFF8DC") + _session_label_c = _skin.get_color("session_label", "#DAA520") + _session_border_c = _skin.get_color("session_border", "#8B8682") + _assistant_label_c = _skin.get_color("ui_ok", "#8FBC8F") + except Exception: + _history_text_c = "#FFF8DC" + _session_label_c = "#DAA520" + _session_border_c = "#8B8682" + _assistant_label_c = "#8FBC8F" + lines = Text() if skipped: lines.append( @@ -1747,14 +1788,14 @@ class HermesCLI: for i, (role, text) in enumerate(entries): if role == "user": - lines.append(" ● You: ", style="dim bold #DAA520") + lines.append(" ● You: ", style=f"dim bold {_session_label_c}") # Show first line inline, indent rest msg_lines = text.splitlines() lines.append(msg_lines[0] + "\n", style="dim") for ml in msg_lines[1:]: lines.append(f" {ml}\n", style="dim") else: - lines.append(" ◆ Hermes: ", style="dim bold #8FBC8F") + lines.append(" ◆ Hermes: ", style=f"dim bold {_assistant_label_c}") msg_lines = text.splitlines() lines.append(msg_lines[0] + "\n", style="dim") for ml in msg_lines[1:]: @@ -1764,9 +1805,10 @@ class HermesCLI: panel = Panel( lines, - title="[dim #DAA520]Previous Conversation[/]", - border_style="dim #8B8682", + title=f"[dim {_session_label_c}]Previous Conversation[/]", + border_style=f"dim {_session_border_c}", padding=(0, 1), + style=_history_text_c, ) self.console.print(panel) @@ -1976,19 +2018,30 @@ class HermesCLI: """Display help information with categorized commands.""" from hermes_cli.commands import COMMANDS_BY_CATEGORY - _cprint(f"\n{_BOLD}+{'-' * 55}+{_RST}") - _cprint(f"{_BOLD}|{' ' * 14}(^_^)? Available Commands{' ' * 15}|{_RST}") - _cprint(f"{_BOLD}+{'-' * 55}+{_RST}") + try: + from hermes_cli.skin_engine import get_active_help_header + header = get_active_help_header("(^_^)? Available Commands") + except Exception: + header = "(^_^)? Available Commands" + header = (header or "").strip() or "(^_^)? Available Commands" + inner_width = 55 + if len(header) > inner_width: + header = header[:inner_width] + _cprint(f"\n{_BOLD}+{'-' * inner_width}+{_RST}") + _cprint(f"{_BOLD}|{header:^{inner_width}}|{_RST}") + _cprint(f"{_BOLD}+{'-' * inner_width}+{_RST}") for category, commands in COMMANDS_BY_CATEGORY.items(): _cprint(f"\n {_BOLD}── {category} ──{_RST}") for cmd, desc in commands.items(): - _cprint(f" {_GOLD}{cmd:<15}{_RST} {_DIM}-{_RST} {desc}") + ChatConsole().print(f" [bold {_accent_hex()}]{cmd:<15}[/] [dim]-[/] {_escape(desc)}") if _skill_commands: _cprint(f"\n ⚡ {_BOLD}Skill Commands{_RST} ({len(_skill_commands)} installed):") for cmd, info in sorted(_skill_commands.items()): - _cprint(f" {_GOLD}{cmd:<22}{_RST} {_DIM}-{_RST} {info['description']}") + ChatConsole().print( + f" [bold {_accent_hex()}]{cmd:<22}[/] [dim]-[/] {_escape(info['description'])}" + ) _cprint(f"\n {_DIM}Tip: Just type your message to chat with Hermes!{_RST}") _cprint(f" {_DIM}Multi-line: Alt+Enter for a new line{_RST}") @@ -2981,8 +3034,7 @@ class HermesCLI: ) output = result.stdout.strip() or result.stderr.strip() if output: - from rich.text import Text as _RichText - self.console.print(_RichText.from_ansi(output)) + self.console.print(_rich_text_from_ansi(output)) else: self.console.print("[dim]Command returned no output[/]") except subprocess.TimeoutExpired: @@ -3076,27 +3128,29 @@ class HermesCLI: # Display result in the CLI (thread-safe via patch_stdout) print() - _cprint(f"{_GOLD}{'─' * 40}{_RST}") + ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]") _cprint(f" ✅ Background task #{task_num} complete") _cprint(f" Prompt: \"{prompt[:60]}{'...' if len(prompt) > 60 else ''}\"") - _cprint(f"{_GOLD}{'─' * 40}{_RST}") + ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]") if response: try: from hermes_cli.skin_engine import get_active_skin _skin = get_active_skin() label = _skin.get_branding("response_label", "⚕ Hermes") _resp_color = _skin.get_color("response_border", "#CD7F32") + _resp_text = _skin.get_color("banner_text", "#FFF8DC") except Exception: label = "⚕ Hermes" _resp_color = "#CD7F32" + _resp_text = "#FFF8DC" - from rich.text import Text as _RichText _chat_console = ChatConsole() _chat_console.print(Panel( - _RichText.from_ansi(response), - title=f"[bold]{label} (background #{task_num})[/bold]", + _rich_text_from_ansi(response), + title=f"[{_resp_color} bold]{label} (background #{task_num})[/]", title_align="left", border_style=_resp_color, + style=_resp_text, box=rich_box.HORIZONTALS, padding=(1, 2), )) @@ -3156,6 +3210,8 @@ class HermesCLI: else: print(f" Skin set to: {new_skin}") print(" Note: banner colors will update on next session start.") + if self._apply_tui_skin_style(): + print(" Prompt + TUI colors updated.") def _toggle_verbose(self): """Cycle tool progress mode: off → new → all → verbose → off.""" @@ -3689,8 +3745,8 @@ class HermesCLI: # Add user message to history self.conversation_history.append({"role": "user", "content": message}) - - _cprint(f"{_GOLD}{'─' * 40}{_RST}") + + ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]") print(flush=True) try: @@ -3803,17 +3859,19 @@ class HermesCLI: _skin = get_active_skin() label = _skin.get_branding("response_label", "⚕ Hermes") _resp_color = _skin.get_color("response_border", "#CD7F32") + _resp_text = _skin.get_color("banner_text", "#FFF8DC") except Exception: label = "⚕ Hermes" _resp_color = "#CD7F32" + _resp_text = "#FFF8DC" - from rich.text import Text as _RichText _chat_console = ChatConsole() _chat_console.print(Panel( - _RichText.from_ansi(response), - title=f"[bold]{label}[/bold]", + _rich_text_from_ansi(response), + title=f"[{_resp_color} bold]{label}[/]", title_align="left", border_style=_resp_color, + style=_resp_text, box=rich_box.HORIZONTALS, padding=(1, 2), )) @@ -3869,7 +3927,80 @@ class HermesCLI: print(f"Duration: {duration_str}") print(f"Messages: {msg_count} ({user_msgs} user, {tool_calls} tool calls)") else: - print("Goodbye! ⚕") + try: + from hermes_cli.skin_engine import get_active_goodbye + goodbye = get_active_goodbye("Goodbye! ⚕") + except Exception: + goodbye = "Goodbye! ⚕" + print(goodbye) + + def _get_tui_prompt_symbols(self) -> tuple[str, str]: + """Return ``(normal_prompt, state_suffix)`` for the active skin. + + ``normal_prompt`` is the full ``branding.prompt_symbol``. + ``state_suffix`` is what special states (sudo/secret/approval/agent) + should render after their leading icon. + """ + try: + from hermes_cli.skin_engine import get_active_prompt_symbol + symbol = get_active_prompt_symbol("❯ ") + except Exception: + symbol = "❯ " + + symbol = (symbol or "❯ ").rstrip() + " " + stripped = symbol.rstrip() + if not stripped: + return "❯ ", "❯ " + + parts = stripped.split() + candidate = parts[-1] if parts else "" + arrow_chars = ("❯", ">", "$", "#", "›", "»", "→") + if any(ch in candidate for ch in arrow_chars): + return symbol, candidate.rstrip() + " " + + # Icon-only custom prompts should still remain visible in special states. + return symbol, symbol + + def _get_tui_prompt_fragments(self): + """Return the prompt_toolkit fragments for the current interactive state.""" + symbol, state_suffix = self._get_tui_prompt_symbols() + if self._sudo_state: + return [("class:sudo-prompt", f"🔐 {state_suffix}")] + if self._secret_state: + return [("class:sudo-prompt", f"🔑 {state_suffix}")] + if self._approval_state: + return [("class:prompt-working", f"⚠ {state_suffix}")] + if self._clarify_freetext: + return [("class:clarify-selected", f"✎ {state_suffix}")] + if self._clarify_state: + return [("class:prompt-working", f"? {state_suffix}")] + if self._command_running: + return [("class:prompt-working", f"{self._command_spinner_frame()} {state_suffix}")] + if self._agent_running: + return [("class:prompt-working", f"⚕ {state_suffix}")] + return [("class:prompt", symbol)] + + def _get_tui_prompt_text(self) -> str: + """Return the visible prompt text for width calculations.""" + return "".join(text for _, text in self._get_tui_prompt_fragments()) + + def _build_tui_style_dict(self) -> dict[str, str]: + """Layer the active skin's prompt_toolkit colors over the base TUI style.""" + style_dict = dict(getattr(self, "_tui_style_base", {}) or {}) + try: + from hermes_cli.skin_engine import get_prompt_toolkit_style_overrides + style_dict.update(get_prompt_toolkit_style_overrides()) + except Exception: + pass + return style_dict + + def _apply_tui_skin_style(self) -> bool: + """Refresh prompt_toolkit styling for a running interactive TUI.""" + if not getattr(self, "_app", None) or not getattr(self, "_tui_style_base", None): + return False + self._app.style = PTStyle.from_dict(self._build_tui_style_dict()) + self._invalidate(min_interval=0.0) + return True def run(self): """Run the interactive CLI loop with persistent input at bottom.""" @@ -4241,21 +4372,7 @@ class HermesCLI: cli_ref = self def get_prompt(): - if cli_ref._sudo_state: - return [('class:sudo-prompt', '🔐 ❯ ')] - if cli_ref._secret_state: - return [('class:sudo-prompt', '🔑 ❯ ')] - if cli_ref._approval_state: - return [('class:prompt-working', '⚠ ❯ ')] - if cli_ref._clarify_freetext: - return [('class:clarify-selected', '✎ ❯ ')] - if cli_ref._clarify_state: - return [('class:prompt-working', '? ❯ ')] - if cli_ref._command_running: - return [('class:prompt-working', f"{cli_ref._command_spinner_frame()} ❯ ")] - if cli_ref._agent_running: - return [('class:prompt-working', '⚕ ❯ ')] - return [('class:prompt', '❯ ')] + return cli_ref._get_tui_prompt_fragments() # Create the input area with multiline (shift+enter), autocomplete, and paste handling input_area = TextArea( @@ -4272,11 +4389,11 @@ class HermesCLI: # Dynamic height: accounts for both explicit newlines AND visual # wrapping of long lines so the input area always fits its content. - # The prompt characters ("❯ " etc.) consume ~4 columns. def _input_height(): try: doc = input_area.buffer.document - available_width = shutil.get_terminal_size().columns - 4 # subtract prompt width + prompt_width = max(2, len(self._get_tui_prompt_text())) + available_width = shutil.get_terminal_size().columns - prompt_width if available_width < 10: available_width = 40 visual_lines = 0 @@ -4717,7 +4834,7 @@ class HermesCLI: ) # Style for the application - style = PTStyle.from_dict({ + self._tui_style_base = { 'input-area': '#FFF8DC', 'placeholder': '#555555 italic', 'prompt': '#FFF8DC', @@ -4752,7 +4869,8 @@ class HermesCLI: 'approval-cmd': '#AAAAAA italic', 'approval-choice': '#AAAAAA', 'approval-selected': '#FFD700 bold', - }) + } + style = PTStyle.from_dict(self._build_tui_style_dict()) # Create the application app = Application( @@ -4815,20 +4933,25 @@ class HermesCLI: full_text = paste_path.read_text(encoding="utf-8") line_count = full_text.count('\n') + 1 print() - _cprint(f"{_GOLD}●{_RST} {_BOLD}[Pasted text: {line_count} lines]{_RST}") + ChatConsole().print( + f"[bold {_accent_hex()}]●[/] [bold]{_escape(f'[Pasted text: {line_count} lines]')}[/]" + ) user_input = full_text else: print() - _cprint(f"{_GOLD}●{_RST} {_BOLD}{user_input}{_RST}") + ChatConsole().print(f"[bold {_accent_hex()}]●[/] [bold]{_escape(user_input)}[/]") else: if '\n' in user_input: first_line = user_input.split('\n')[0] line_count = user_input.count('\n') + 1 print() - _cprint(f"{_GOLD}●{_RST} {_BOLD}{first_line}{_RST} {_DIM}(+{line_count - 1} lines){_RST}") + ChatConsole().print( + f"[bold {_accent_hex()}]●[/] [bold]{_escape(first_line)}[/] " + f"[dim](+{line_count - 1} lines)[/]" + ) else: print() - _cprint(f"{_GOLD}●{_RST} {_BOLD}{user_input}{_RST}") + ChatConsole().print(f"[bold {_accent_hex()}]●[/] [bold]{_escape(user_input)}[/]") # Show image attachment count if submit_images: diff --git a/hermes_cli/skin_engine.py b/hermes_cli/skin_engine.py index 6b9cb3c86..e73e73de5 100644 --- a/hermes_cli/skin_engine.py +++ b/hermes_cli/skin_engine.py @@ -628,3 +628,88 @@ def init_skin_from_config(config: dict) -> None: set_active_skin(skin_name.strip()) else: set_active_skin("default") + + +# ============================================================================= +# Convenience helpers for CLI modules +# ============================================================================= + + +def get_active_prompt_symbol(fallback: str = "❯ ") -> str: + """Get the interactive prompt symbol from the active skin.""" + try: + return get_active_skin().get_branding("prompt_symbol", fallback) + except Exception: + return fallback + + + +def get_active_help_header(fallback: str = "(^_^)? Available Commands") -> str: + """Get the /help header from the active skin.""" + try: + return get_active_skin().get_branding("help_header", fallback) + except Exception: + return fallback + + + +def get_active_goodbye(fallback: str = "Goodbye! ⚕") -> str: + """Get the goodbye line from the active skin.""" + try: + return get_active_skin().get_branding("goodbye", fallback) + except Exception: + return fallback + + + +def get_prompt_toolkit_style_overrides() -> Dict[str, str]: + """Return prompt_toolkit style overrides derived from the active skin. + + These are layered on top of the CLI's base TUI style so /skin can refresh + the live prompt_toolkit UI immediately without rebuilding the app. + """ + try: + skin = get_active_skin() + except Exception: + return {} + + prompt = skin.get_color("prompt", "#FFF8DC") + input_rule = skin.get_color("input_rule", "#CD7F32") + title = skin.get_color("banner_title", "#FFD700") + text = skin.get_color("banner_text", prompt) + dim = skin.get_color("banner_dim", "#555555") + label = skin.get_color("ui_label", title) + warn = skin.get_color("ui_warn", "#FF8C00") + error = skin.get_color("ui_error", "#FF6B6B") + + return { + "input-area": prompt, + "placeholder": f"{dim} italic", + "prompt": prompt, + "prompt-working": f"{dim} italic", + "hint": f"{dim} italic", + "input-rule": input_rule, + "image-badge": f"{label} bold", + "completion-menu": f"bg:#1a1a2e {text}", + "completion-menu.completion": f"bg:#1a1a2e {text}", + "completion-menu.completion.current": f"bg:#333355 {title}", + "completion-menu.meta.completion": f"bg:#1a1a2e {dim}", + "completion-menu.meta.completion.current": f"bg:#333355 {label}", + "clarify-border": input_rule, + "clarify-title": f"{title} bold", + "clarify-question": f"{text} bold", + "clarify-choice": dim, + "clarify-selected": f"{title} bold", + "clarify-active-other": f"{title} italic", + "clarify-countdown": input_rule, + "sudo-prompt": f"{error} bold", + "sudo-border": input_rule, + "sudo-title": f"{error} bold", + "sudo-text": text, + "approval-border": input_rule, + "approval-title": f"{warn} bold", + "approval-desc": f"{text} bold", + "approval-cmd": f"{dim} italic", + "approval-choice": dim, + "approval-selected": f"{title} bold", + } diff --git a/tests/hermes_cli/test_skin_engine.py b/tests/hermes_cli/test_skin_engine.py index 7de90b32c..6a5a032f1 100644 --- a/tests/hermes_cli/test_skin_engine.py +++ b/tests/hermes_cli/test_skin_engine.py @@ -60,6 +60,9 @@ class TestBuiltinSkins: assert skin.name == "ares" assert skin.tool_prefix == "╎" assert skin.get_color("banner_border") == "#9F1C1C" + assert skin.get_color("response_border") == "#C7A96B" + assert skin.get_color("session_label") == "#C7A96B" + assert skin.get_color("session_border") == "#6E584B" assert skin.get_branding("agent_name") == "Ares Agent" def test_ares_has_spinner_customization(self): @@ -230,3 +233,82 @@ class TestDisplayIntegration: from agent.display import get_cute_tool_message msg = get_cute_tool_message("terminal", {"command": "ls"}, 0.5) assert msg.startswith("┊") + + +class TestCliBrandingHelpers: + def test_active_prompt_symbol_default(self): + from hermes_cli.skin_engine import get_active_prompt_symbol + + assert get_active_prompt_symbol() == "❯ " + + def test_active_prompt_symbol_ares(self): + from hermes_cli.skin_engine import set_active_skin, get_active_prompt_symbol + + set_active_skin("ares") + assert get_active_prompt_symbol() == "⚔ ❯ " + + def test_active_help_header_ares(self): + from hermes_cli.skin_engine import set_active_skin, get_active_help_header + + set_active_skin("ares") + assert get_active_help_header() == "(⚔) Available Commands" + + def test_active_goodbye_ares(self): + from hermes_cli.skin_engine import set_active_skin, get_active_goodbye + + set_active_skin("ares") + assert get_active_goodbye() == "Farewell, warrior! ⚔" + + def test_prompt_toolkit_style_overrides_cover_tui_classes(self): + from hermes_cli.skin_engine import set_active_skin, get_prompt_toolkit_style_overrides + + set_active_skin("ares") + overrides = get_prompt_toolkit_style_overrides() + required = { + "input-area", + "placeholder", + "prompt", + "prompt-working", + "hint", + "input-rule", + "image-badge", + "completion-menu", + "completion-menu.completion", + "completion-menu.completion.current", + "completion-menu.meta.completion", + "completion-menu.meta.completion.current", + "clarify-border", + "clarify-title", + "clarify-question", + "clarify-choice", + "clarify-selected", + "clarify-active-other", + "clarify-countdown", + "sudo-prompt", + "sudo-border", + "sudo-title", + "sudo-text", + "approval-border", + "approval-title", + "approval-desc", + "approval-cmd", + "approval-choice", + "approval-selected", + } + assert required.issubset(overrides.keys()) + + def test_prompt_toolkit_style_overrides_use_skin_colors(self): + from hermes_cli.skin_engine import ( + set_active_skin, + get_active_skin, + get_prompt_toolkit_style_overrides, + ) + + set_active_skin("ares") + skin = get_active_skin() + overrides = get_prompt_toolkit_style_overrides() + assert overrides["prompt"] == skin.get_color("prompt") + assert overrides["input-rule"] == skin.get_color("input_rule") + assert overrides["clarify-title"] == f"{skin.get_color('banner_title')} bold" + assert overrides["sudo-prompt"] == f"{skin.get_color('ui_error')} bold" + assert overrides["approval-title"] == f"{skin.get_color('ui_warn')} bold" diff --git a/tests/test_cli_skin_integration.py b/tests/test_cli_skin_integration.py new file mode 100644 index 000000000..ef4ddb38d --- /dev/null +++ b/tests/test_cli_skin_integration.py @@ -0,0 +1,95 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from cli import HermesCLI, _rich_text_from_ansi +from hermes_cli.skin_engine import get_active_skin, set_active_skin + + +def _make_cli_stub(): + cli = HermesCLI.__new__(HermesCLI) + cli._sudo_state = None + cli._secret_state = None + cli._approval_state = None + cli._clarify_state = None + cli._clarify_freetext = False + cli._command_running = False + cli._agent_running = False + cli._command_spinner_frame = lambda: "⟳" + cli._tui_style_base = { + "prompt": "#fff", + "input-area": "#fff", + "input-rule": "#aaa", + "prompt-working": "#888 italic", + } + cli._app = SimpleNamespace(style=None) + cli._invalidate = MagicMock() + return cli + + +class TestCliSkinPromptIntegration: + def test_default_prompt_fragments_use_default_symbol(self): + cli = _make_cli_stub() + + set_active_skin("default") + assert cli._get_tui_prompt_fragments() == [("class:prompt", "❯ ")] + + def test_ares_prompt_fragments_use_skin_symbol(self): + cli = _make_cli_stub() + + set_active_skin("ares") + assert cli._get_tui_prompt_fragments() == [("class:prompt", "⚔ ❯ ")] + + def test_secret_prompt_fragments_preserve_secret_state(self): + cli = _make_cli_stub() + cli._secret_state = {"response_queue": object()} + + set_active_skin("ares") + assert cli._get_tui_prompt_fragments() == [("class:sudo-prompt", "🔑 ❯ ")] + + def test_icon_only_skin_symbol_still_visible_in_special_states(self): + cli = _make_cli_stub() + cli._secret_state = {"response_queue": object()} + + with patch("hermes_cli.skin_engine.get_active_prompt_symbol", return_value="⚔ "): + assert cli._get_tui_prompt_fragments() == [("class:sudo-prompt", "🔑 ⚔ ")] + + def test_build_tui_style_dict_uses_skin_overrides(self): + cli = _make_cli_stub() + + set_active_skin("ares") + skin = get_active_skin() + style_dict = cli._build_tui_style_dict() + + assert style_dict["prompt"] == skin.get_color("prompt") + assert style_dict["input-rule"] == skin.get_color("input_rule") + assert style_dict["prompt-working"] == f"{skin.get_color('banner_dim')} italic" + assert style_dict["approval-title"] == f"{skin.get_color('ui_warn')} bold" + + def test_apply_tui_skin_style_updates_running_app(self): + cli = _make_cli_stub() + + set_active_skin("ares") + assert cli._apply_tui_skin_style() is True + assert cli._app.style is not None + cli._invalidate.assert_called_once_with(min_interval=0.0) + + def test_handle_skin_command_refreshes_live_tui(self, capsys): + cli = _make_cli_stub() + + with patch("cli.save_config_value", return_value=True): + cli._handle_skin_command("/skin ares") + + output = capsys.readouterr().out + assert "Skin set to: ares (saved)" in output + assert "Prompt + TUI colors updated." in output + assert cli._app.style is not None + + +class TestAnsiRichTextHelper: + def test_preserves_literal_brackets(self): + text = _rich_text_from_ansi("[notatag] literal") + assert text.plain == "[notatag] literal" + + def test_strips_ansi_but_keeps_plain_text(self): + text = _rich_text_from_ansi("\x1b[31mred\x1b[0m") + assert text.plain == "red"