diff --git a/cli.py b/cli.py index 5c2e6365d6..b4358a163c 100644 --- a/cli.py +++ b/cli.py @@ -63,7 +63,7 @@ from agent.usage_pricing import ( format_duration_compact, format_token_count_compact, ) -from hermes_cli.banner import _format_context_length +from hermes_cli.banner import _format_context_length, format_banner_version_label _COMMAND_SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏") @@ -1036,21 +1036,44 @@ COMPACT_BANNER = """ def _build_compact_banner() -> str: """Build a compact banner that fits the current terminal width.""" - w = min(shutil.get_terminal_size().columns - 2, 64) + try: + from hermes_cli.skin_engine import get_active_skin + _skin = get_active_skin() + except Exception: + _skin = None + + skin_name = getattr(_skin, "name", "default") if _skin else "default" + border_color = _skin.get_color("banner_border", "#FFD700") if _skin else "#FFD700" + title_color = _skin.get_color("banner_title", "#FFBF00") if _skin else "#FFBF00" + dim_color = _skin.get_color("banner_dim", "#B8860B") if _skin else "#B8860B" + + if skin_name == "default": + line1 = "⚕ NOUS HERMES - AI Agent Framework" + tiny_line = "⚕ NOUS HERMES" + else: + agent_name = _skin.get_branding("agent_name", "Hermes Agent") if _skin else "Hermes Agent" + line1 = f"{agent_name} - AI Agent Framework" + tiny_line = agent_name + + version_line = format_banner_version_label() + + w = min(shutil.get_terminal_size().columns - 2, 88) if w < 30: - return "\n[#FFBF00]⚕ NOUS HERMES[/] [dim #B8860B]- Nous Research[/]\n" + return f"\n[{title_color}]{tiny_line}[/] [dim {dim_color}]- Nous Research[/]\n" + inner = w - 2 # inside the box border bar = "═" * w - line1 = "⚕ NOUS HERMES - AI Agent Framework" - line2 = "Messenger of the Digital Gods · Nous Research" + content_width = inner - 2 + # Truncate and pad to fit - line1 = line1[:inner - 2].ljust(inner - 2) - line2 = line2[:inner - 2].ljust(inner - 2) + line1 = line1[:content_width].ljust(content_width) + line2 = version_line[:content_width].ljust(content_width) + return ( - f"\n[bold #FFD700]╔{bar}╗[/]\n" - f"[bold #FFD700]║[/] [#FFBF00]{line1}[/] [bold #FFD700]║[/]\n" - f"[bold #FFD700]║[/] [dim #B8860B]{line2}[/] [bold #FFD700]║[/]\n" - f"[bold #FFD700]╚{bar}╝[/]\n" + f"\n[bold {border_color}]╔{bar}╗[/]\n" + f"[bold {border_color}]║[/] [{title_color}]{line1}[/] [bold {border_color}]║[/]\n" + f"[bold {border_color}]║[/] [dim {dim_color}]{line2}[/] [bold {border_color}]║[/]\n" + f"[bold {border_color}]╚{bar}╝[/]\n" ) diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index b9701d5471..03712c272d 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -5,6 +5,7 @@ Pure display functions with no HermesCLI state dependency. import json import logging +import os import shutil import subprocess import threading @@ -189,6 +190,79 @@ def check_for_updates() -> Optional[int]: return behind +def _resolve_repo_dir() -> Optional[Path]: + """Return the active Hermes git checkout, or None if this isn't a git install.""" + hermes_home = get_hermes_home() + repo_dir = hermes_home / "hermes-agent" + if not (repo_dir / ".git").exists(): + repo_dir = Path(__file__).parent.parent.resolve() + return repo_dir if (repo_dir / ".git").exists() else None + + +def _git_short_hash(repo_dir: Path, rev: str) -> Optional[str]: + """Resolve a git revision to an 8-character short hash.""" + try: + result = subprocess.run( + ["git", "rev-parse", "--short=8", rev], + capture_output=True, + text=True, + timeout=5, + cwd=str(repo_dir), + ) + except Exception: + return None + if result.returncode != 0: + return None + value = (result.stdout or "").strip() + return value or None + + +def get_git_banner_state(repo_dir: Optional[Path] = None) -> Optional[dict]: + """Return upstream/local git hashes for the startup banner.""" + repo_dir = repo_dir or _resolve_repo_dir() + if repo_dir is None: + return None + + upstream = _git_short_hash(repo_dir, "origin/main") + local = _git_short_hash(repo_dir, "HEAD") + if not upstream or not local: + return None + + ahead = 0 + try: + result = subprocess.run( + ["git", "rev-list", "--count", "origin/main..HEAD"], + capture_output=True, + text=True, + timeout=5, + cwd=str(repo_dir), + ) + if result.returncode == 0: + ahead = int((result.stdout or "0").strip() or "0") + except Exception: + ahead = 0 + + return {"upstream": upstream, "local": local, "ahead": max(ahead, 0)} + + +def format_banner_version_label() -> str: + """Return the version label shown in the startup banner title.""" + base = f"Hermes Agent v{VERSION} ({RELEASE_DATE})" + state = get_git_banner_state() + if not state: + return base + + upstream = state["upstream"] + local = state["local"] + ahead = int(state.get("ahead") or 0) + + if ahead <= 0 or upstream == local: + return f"{base} · upstream {upstream}" + + carried_word = "commit" if ahead == 1 else "commits" + return f"{base} · upstream {upstream} · local {local} (+{ahead} carried {carried_word})" + + # ========================================================================= # Non-blocking update check # ========================================================================= @@ -448,7 +522,7 @@ def build_welcome_banner(console: Console, model: str, cwd: str, border_color = _skin_color("banner_border", "#CD7F32") outer_panel = Panel( layout_table, - title=f"[bold {title_color}]{agent_name} v{VERSION} ({RELEASE_DATE})[/]", + title=f"[bold {title_color}]{format_banner_version_label()}[/]", border_style=border_color, padding=(0, 2), ) diff --git a/tests/hermes_cli/test_banner_git_state.py b/tests/hermes_cli/test_banner_git_state.py new file mode 100644 index 0000000000..6556145e8f --- /dev/null +++ b/tests/hermes_cli/test_banner_git_state.py @@ -0,0 +1,63 @@ +from unittest.mock import MagicMock, patch + + +def test_format_banner_version_label_without_git_state(): + from hermes_cli import banner + + with patch.object(banner, "get_git_banner_state", return_value=None): + value = banner.format_banner_version_label() + + assert value == f"Hermes Agent v{banner.VERSION} ({banner.RELEASE_DATE})" + + +def test_format_banner_version_label_on_upstream_main(): + from hermes_cli import banner + + with patch.object( + banner, + "get_git_banner_state", + return_value={"upstream": "b2f477a3", "local": "b2f477a3", "ahead": 0}, + ): + value = banner.format_banner_version_label() + + assert value.endswith("· upstream b2f477a3") + assert "local" not in value + + +def test_format_banner_version_label_with_carried_commits(): + from hermes_cli import banner + + with patch.object( + banner, + "get_git_banner_state", + return_value={"upstream": "b2f477a3", "local": "af8aad31", "ahead": 3}, + ): + value = banner.format_banner_version_label() + + assert "upstream b2f477a3" in value + assert "local af8aad31" in value + assert "+3 carried commits" in value + + +def test_get_git_banner_state_reads_origin_and_head(tmp_path): + from hermes_cli import banner + + repo_dir = tmp_path / "repo" + (repo_dir / ".git").mkdir(parents=True) + + results = { + ("git", "rev-parse", "--short=8", "origin/main"): MagicMock(returncode=0, stdout="b2f477a3\n"), + ("git", "rev-parse", "--short=8", "HEAD"): MagicMock(returncode=0, stdout="af8aad31\n"), + ("git", "rev-list", "--count", "origin/main..HEAD"): MagicMock(returncode=0, stdout="3\n"), + } + + def fake_run(cmd, **kwargs): + key = tuple(cmd) + if key not in results: + raise AssertionError(f"unexpected command: {cmd}") + return results[key] + + with patch("hermes_cli.banner.subprocess.run", side_effect=fake_run): + state = banner.get_git_banner_state(repo_dir) + + assert state == {"upstream": "b2f477a3", "local": "af8aad31", "ahead": 3} diff --git a/tests/test_cli_skin_integration.py b/tests/test_cli_skin_integration.py new file mode 100644 index 0000000000..272a7bc5b1 --- /dev/null +++ b/tests/test_cli_skin_integration.py @@ -0,0 +1,140 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from cli import HermesCLI, _build_compact_banner, _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._voice_recording = False + cli._voice_processing = False + cli._voice_mode = 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 TestCompactBannerSkinIntegration: + def test_default_compact_banner_keeps_legacy_nous_hermes_branding(self): + set_active_skin("default") + + with patch("cli.shutil.get_terminal_size", return_value=SimpleNamespace(columns=90)), \ + patch("cli.format_banner_version_label", return_value="Hermes Agent v0.1.0 (test)"): + banner = _build_compact_banner() + + assert "NOUS HERMES" in banner + + def test_poseidon_compact_banner_uses_skin_branding_instead_of_nous_hermes(self): + set_active_skin("poseidon") + + with patch("cli.shutil.get_terminal_size", return_value=SimpleNamespace(columns=90)), \ + patch("cli.format_banner_version_label", return_value="Hermes Agent v0.1.0 (test)"): + banner = _build_compact_banner() + + assert "Poseidon Agent" in banner + assert "NOUS HERMES" not in banner + + def test_poseidon_compact_banner_uses_skin_colors(self): + set_active_skin("poseidon") + skin = get_active_skin() + + with patch("cli.shutil.get_terminal_size", return_value=SimpleNamespace(columns=90)), \ + patch("cli.format_banner_version_label", return_value="Hermes Agent v0.1.0 (test)"): + banner = _build_compact_banner() + + assert skin.get_color("banner_border") in banner + assert skin.get_color("banner_title") in banner + assert skin.get_color("banner_dim") in banner + + def test_compact_banner_shows_version_label(self): + set_active_skin("default") + + with patch("cli.shutil.get_terminal_size", return_value=SimpleNamespace(columns=90)), \ + patch("cli.format_banner_version_label", return_value="Hermes Agent v1.0 (test) · upstream abc12345"): + banner = _build_compact_banner() + + assert "upstream abc12345" in banner + + +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"