diff --git a/cli.py b/cli.py index 2dce0827c..b4358a163 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" ) @@ -2163,7 +2186,7 @@ class HermesCLI: ) except Exception as exc: message = format_runtime_provider_error(exc) - self.console.print(f"[bold red]{message}[/]") + ChatConsole().print(f"[bold red]{message}[/]") return False api_key = runtime.get("api_key") @@ -2378,7 +2401,7 @@ class HermesCLI: self._pending_title = None return True except Exception as e: - self.console.print(f"[bold red]Failed to initialize agent: {e}[/]") + ChatConsole().print(f"[bold red]Failed to initialize agent: {e}[/]") return False def show_banner(self): @@ -4530,13 +4553,13 @@ class HermesCLI: if output: self.console.print(_rich_text_from_ansi(output)) else: - self.console.print("[dim]Command returned no output[/]") + ChatConsole().print("[dim]Command returned no output[/]") except subprocess.TimeoutExpired: - self.console.print("[bold red]Quick command timed out (30s)[/]") + ChatConsole().print("[bold red]Quick command timed out (30s)[/]") except Exception as e: - self.console.print(f"[bold red]Quick command error: {e}[/]") + ChatConsole().print(f"[bold red]Quick command error: {e}[/]") else: - self.console.print(f"[bold red]Quick command '{base_cmd}' has no command defined[/]") + ChatConsole().print(f"[bold red]Quick command '{base_cmd}' has no command defined[/]") elif qcmd.get("type") == "alias": target = qcmd.get("target", "").strip() if target: @@ -4545,9 +4568,9 @@ class HermesCLI: aliased_command = f"{target} {user_args}".strip() return self.process_command(aliased_command) else: - self.console.print(f"[bold red]Quick command '{base_cmd}' has no target defined[/]") + ChatConsole().print(f"[bold red]Quick command '{base_cmd}' has no target defined[/]") else: - self.console.print(f"[bold red]Quick command '{base_cmd}' has unsupported type (supported: 'exec', 'alias')[/]") + ChatConsole().print(f"[bold red]Quick command '{base_cmd}' has unsupported type (supported: 'exec', 'alias')[/]") # Check for plugin-registered slash commands elif base_cmd.lstrip("/") in _get_plugin_cmd_handler_names(): from hermes_cli.plugins import get_plugin_command_handler @@ -4572,7 +4595,7 @@ class HermesCLI: if hasattr(self, '_pending_input'): self._pending_input.put(msg) else: - self.console.print(f"[bold red]Failed to load skill for {base_cmd}[/]") + ChatConsole().print(f"[bold red]Failed to load skill for {base_cmd}[/]") else: # Prefix matching: if input uniquely identifies one command, execute it. # Matches against both built-in COMMANDS and installed skill commands so @@ -4633,14 +4656,14 @@ class HermesCLI: ) if not msg: - self.console.print("[bold red]Failed to load the bundled /plan skill[/]") + ChatConsole().print("[bold red]Failed to load the bundled /plan skill[/]") return _cprint(f" 📝 Plan mode queued via skill. Markdown plan target: {plan_path}") if hasattr(self, '_pending_input'): self._pending_input.put(msg) else: - self.console.print("[bold red]Plan mode unavailable: input queue not initialized[/]") + ChatConsole().print("[bold red]Plan mode unavailable: input queue not initialized[/]") def _handle_background_command(self, cmd: str): """Handle /background — run a prompt in a separate background session. diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index b9701d547..03712c272 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/hermes_cli/setup.py b/hermes_cli/setup.py index ea2e57a93..2407ca275 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -421,10 +421,22 @@ def _curses_prompt_choice(question: str, choices: list, default: int = 0) -> int curses.init_pair(1, curses.COLOR_GREEN, -1) curses.init_pair(2, curses.COLOR_YELLOW, -1) cursor = default + scroll_offset = 0 while True: stdscr.clear() max_y, max_x = stdscr.getmaxyx() + + # Rows available for list items: rows 2..(max_y-2) inclusive. + visible = max(1, max_y - 3) + + # Scroll the viewport so the cursor is always visible. + if cursor < scroll_offset: + scroll_offset = cursor + elif cursor >= scroll_offset + visible: + scroll_offset = cursor - visible + 1 + scroll_offset = max(0, min(scroll_offset, max(0, len(choices) - visible))) + try: stdscr.addnstr( 0, @@ -436,12 +448,12 @@ def _curses_prompt_choice(question: str, choices: list, default: int = 0) -> int except curses.error: pass - for i, choice in enumerate(choices): - y = i + 2 + for row, i in enumerate(range(scroll_offset, min(scroll_offset + visible, len(choices)))): + y = row + 2 if y >= max_y - 1: break arrow = "→" if i == cursor else " " - line = f" {arrow} {choice}" + line = f" {arrow} {choices[i]}" attr = curses.A_NORMAL if i == cursor: attr = curses.A_BOLD 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 000000000..6556145e8 --- /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 000000000..272a7bc5b --- /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" diff --git a/tests/test_model_picker_scroll.py b/tests/test_model_picker_scroll.py new file mode 100644 index 000000000..e20c330ea --- /dev/null +++ b/tests/test_model_picker_scroll.py @@ -0,0 +1,118 @@ +"""Tests for the scrolling viewport logic in _curses_prompt_choice (issue #5755). + +The "More providers" submenu has 13 entries (11 extended + custom + cancel). +Before the fix, _curses_prompt_choice rendered items starting unconditionally +from index 0 with no scroll offset. On terminals shorter than ~16 rows, items +near the bottom were never drawn. When the cursor wrapped from 0 to the last +item (Cancel) via UP-arrow, the highlight rendered off-screen, leaving the menu +looking like only "Cancel" existed. + +The fix adds a scroll_offset that tracks the cursor so the highlighted item +is always within the visible window. These tests exercise that logic in +isolation without requiring a real TTY. +""" + +import sys +import os +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + + +# --------------------------------------------------------------------------- +# Pure scroll-offset logic extracted from _curses_menu for unit testing +# --------------------------------------------------------------------------- + +def _compute_scroll_offset(cursor: int, scroll_offset: int, visible: int, n_choices: int) -> int: + """Mirror of the scroll adjustment block inside _curses_menu.""" + if cursor < scroll_offset: + scroll_offset = cursor + elif cursor >= scroll_offset + visible: + scroll_offset = cursor - visible + 1 + scroll_offset = max(0, min(scroll_offset, max(0, n_choices - visible))) + return scroll_offset + + +def _visible_indices(cursor: int, scroll_offset: int, visible: int, n_choices: int): + """Return the list indices that would be rendered for the given state.""" + scroll_offset = _compute_scroll_offset(cursor, scroll_offset, visible, n_choices) + return list(range(scroll_offset, min(scroll_offset + visible, n_choices))) + + +# --------------------------------------------------------------------------- +# Tests: scroll offset calculation +# --------------------------------------------------------------------------- + +class TestScrollOffsetLogic: + N = 13 # typical extended-providers list length + + def test_cursor_at_zero_no_scroll(self): + """Start position: offset stays 0, first items visible.""" + assert _compute_scroll_offset(0, 0, 8, self.N) == 0 + + def test_cursor_within_window_unchanged(self): + """Cursor inside the current window: offset unchanged.""" + assert _compute_scroll_offset(5, 0, 8, self.N) == 0 + + def test_cursor_at_last_item_scrolls_down(self): + """Cursor on Cancel (index 12) with 8-row window: offset = 12 - 8 + 1 = 5.""" + offset = _compute_scroll_offset(12, 0, 8, self.N) + assert offset == 5 + assert 12 in _visible_indices(12, 0, 8, self.N) + + def test_cursor_wraps_to_cancel_via_up(self): + """UP from index 0 wraps to last item; last item must be visible.""" + wrapped_cursor = (0 - 1) % self.N # == 12 + indices = _visible_indices(wrapped_cursor, 0, 8, self.N) + assert wrapped_cursor in indices + + def test_cursor_above_window_scrolls_up(self): + """Cursor above current window: offset tracks cursor.""" + # window currently shows [5..12], cursor moves to 3 + offset = _compute_scroll_offset(3, 5, 8, self.N) + assert offset == 3 + assert 3 in _visible_indices(3, 5, 8, self.N) + + def test_visible_window_never_exceeds_list(self): + """Offset is clamped so the window never starts past the list end.""" + offset = _compute_scroll_offset(12, 0, 20, self.N) # window larger than list + assert offset == 0 + + def test_single_item_list(self): + """Edge case: one choice, cursor 0.""" + assert _compute_scroll_offset(0, 0, 8, 1) == 0 + + def test_list_fits_in_window_no_scroll_needed(self): + """If all choices fit in the visible window, offset is always 0.""" + for cursor in range(self.N): + offset = _compute_scroll_offset(cursor, 0, 20, self.N) + assert offset == 0, f"cursor={cursor} should not scroll when window > list" + + def test_cursor_always_in_visible_range(self): + """Invariant: cursor is always within the rendered window after adjustment.""" + visible = 5 + for cursor in range(self.N): + indices = _visible_indices(cursor, 0, visible, self.N) + assert cursor in indices, f"cursor={cursor} not in visible={indices}" + + def test_full_navigation_down_cursor_always_visible(self): + """Simulate pressing DOWN through all items; cursor always in view.""" + visible = 6 + scroll_offset = 0 + cursor = 0 + for _ in range(self.N + 2): # wrap around twice + scroll_offset = _compute_scroll_offset(cursor, scroll_offset, visible, self.N) + rendered = list(range(scroll_offset, min(scroll_offset + visible, self.N))) + assert cursor in rendered, f"cursor={cursor} not in rendered={rendered}" + cursor = (cursor + 1) % self.N + + def test_full_navigation_up_cursor_always_visible(self): + """Simulate pressing UP through all items; cursor always in view.""" + visible = 6 + scroll_offset = 0 + cursor = 0 + for _ in range(self.N + 2): + scroll_offset = _compute_scroll_offset(cursor, scroll_offset, visible, self.N) + rendered = list(range(scroll_offset, min(scroll_offset + visible, self.N))) + assert cursor in rendered, f"cursor={cursor} not in rendered={rendered}" + cursor = (cursor - 1) % self.N