mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix: CLI/UX batch — ChatConsole errors, curses scroll, skin-aware banner, git state banner (#5974)
* fix(cli): route error messages through ChatConsole inside patch_stdout Cherry-pick of PR #5798 by @icn5381. Replace self.console.print() with ChatConsole().print() for 11 error/status messages reachable during the interactive session. Inside patch_stdout, self.console (plain Rich Console) writes raw ANSI escapes that StdoutProxy mangles into garbled text. ChatConsole uses prompt_toolkit's native print_formatted_text which renders correctly. Same class of bug as #2262 — that fix covered agent output but missed these error paths in _ensure_runtime_credentials, _init_agent, quick commands, skill loading, and plan mode. * fix(model-picker): add scrolling viewport to curses provider menu Cherry-pick of PR #5790 by @Lempkey. Fixes #5755. _curses_prompt_choice rendered items starting unconditionally from index 0 with no scroll offset. The 'More providers' submenu has 13 entries. On terminals shorter than ~16 rows, items past the fold were never drawn. When UP-arrow wrapped cursor from 0 to the last item (Cancel, index 12), the highlight rendered off-screen — appearing as if only Cancel existed. Adds scroll_offset tracking that adjusts each frame to keep the cursor inside the visible window. * feat(cli): skin-aware compact banner + git state in startup banner Combined salvage of PR #5922 by @ASRagab and PR #5877 by @xinbenlv. Compact banner changes (from #5922): - Read active skin colors and branding instead of hardcoding gold/NOUS HERMES - Default skin preserves backward-compatible legacy branding - Non-default skins use their own agent_name and colors Git state in banner (from #5877): - New format_banner_version_label() shows upstream/local git hashes - Full banner title now includes git state (upstream hash, carried commits) - Compact banner line2 shows the version label with git state - Widen compact banner max width from 64 to 88 to fit version info Both the full Rich banner and compact fallback are now skin-aware and show git state.
This commit is contained in:
parent
f3c59321af
commit
9692b3c28a
6 changed files with 456 additions and 26 deletions
67
cli.py
67
cli.py
|
|
@ -63,7 +63,7 @@ from agent.usage_pricing import (
|
||||||
format_duration_compact,
|
format_duration_compact,
|
||||||
format_token_count_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 = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
|
_COMMAND_SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
|
||||||
|
|
||||||
|
|
@ -1036,21 +1036,44 @@ COMPACT_BANNER = """
|
||||||
|
|
||||||
def _build_compact_banner() -> str:
|
def _build_compact_banner() -> str:
|
||||||
"""Build a compact banner that fits the current terminal width."""
|
"""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:
|
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
|
inner = w - 2 # inside the box border
|
||||||
bar = "═" * w
|
bar = "═" * w
|
||||||
line1 = "⚕ NOUS HERMES - AI Agent Framework"
|
content_width = inner - 2
|
||||||
line2 = "Messenger of the Digital Gods · Nous Research"
|
|
||||||
# Truncate and pad to fit
|
# Truncate and pad to fit
|
||||||
line1 = line1[:inner - 2].ljust(inner - 2)
|
line1 = line1[:content_width].ljust(content_width)
|
||||||
line2 = line2[:inner - 2].ljust(inner - 2)
|
line2 = version_line[:content_width].ljust(content_width)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
f"\n[bold #FFD700]╔{bar}╗[/]\n"
|
f"\n[bold {border_color}]╔{bar}╗[/]\n"
|
||||||
f"[bold #FFD700]║[/] [#FFBF00]{line1}[/] [bold #FFD700]║[/]\n"
|
f"[bold {border_color}]║[/] [{title_color}]{line1}[/] [bold {border_color}]║[/]\n"
|
||||||
f"[bold #FFD700]║[/] [dim #B8860B]{line2}[/] [bold #FFD700]║[/]\n"
|
f"[bold {border_color}]║[/] [dim {dim_color}]{line2}[/] [bold {border_color}]║[/]\n"
|
||||||
f"[bold #FFD700]╚{bar}╝[/]\n"
|
f"[bold {border_color}]╚{bar}╝[/]\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2163,7 +2186,7 @@ class HermesCLI:
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
message = format_runtime_provider_error(exc)
|
message = format_runtime_provider_error(exc)
|
||||||
self.console.print(f"[bold red]{message}[/]")
|
ChatConsole().print(f"[bold red]{message}[/]")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
api_key = runtime.get("api_key")
|
api_key = runtime.get("api_key")
|
||||||
|
|
@ -2378,7 +2401,7 @@ class HermesCLI:
|
||||||
self._pending_title = None
|
self._pending_title = None
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
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
|
return False
|
||||||
|
|
||||||
def show_banner(self):
|
def show_banner(self):
|
||||||
|
|
@ -4530,13 +4553,13 @@ class HermesCLI:
|
||||||
if output:
|
if output:
|
||||||
self.console.print(_rich_text_from_ansi(output))
|
self.console.print(_rich_text_from_ansi(output))
|
||||||
else:
|
else:
|
||||||
self.console.print("[dim]Command returned no output[/]")
|
ChatConsole().print("[dim]Command returned no output[/]")
|
||||||
except subprocess.TimeoutExpired:
|
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:
|
except Exception as e:
|
||||||
self.console.print(f"[bold red]Quick command error: {e}[/]")
|
ChatConsole().print(f"[bold red]Quick command error: {e}[/]")
|
||||||
else:
|
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":
|
elif qcmd.get("type") == "alias":
|
||||||
target = qcmd.get("target", "").strip()
|
target = qcmd.get("target", "").strip()
|
||||||
if target:
|
if target:
|
||||||
|
|
@ -4545,9 +4568,9 @@ class HermesCLI:
|
||||||
aliased_command = f"{target} {user_args}".strip()
|
aliased_command = f"{target} {user_args}".strip()
|
||||||
return self.process_command(aliased_command)
|
return self.process_command(aliased_command)
|
||||||
else:
|
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:
|
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
|
# Check for plugin-registered slash commands
|
||||||
elif base_cmd.lstrip("/") in _get_plugin_cmd_handler_names():
|
elif base_cmd.lstrip("/") in _get_plugin_cmd_handler_names():
|
||||||
from hermes_cli.plugins import get_plugin_command_handler
|
from hermes_cli.plugins import get_plugin_command_handler
|
||||||
|
|
@ -4572,7 +4595,7 @@ class HermesCLI:
|
||||||
if hasattr(self, '_pending_input'):
|
if hasattr(self, '_pending_input'):
|
||||||
self._pending_input.put(msg)
|
self._pending_input.put(msg)
|
||||||
else:
|
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:
|
else:
|
||||||
# Prefix matching: if input uniquely identifies one command, execute it.
|
# Prefix matching: if input uniquely identifies one command, execute it.
|
||||||
# Matches against both built-in COMMANDS and installed skill commands so
|
# Matches against both built-in COMMANDS and installed skill commands so
|
||||||
|
|
@ -4633,14 +4656,14 @@ class HermesCLI:
|
||||||
)
|
)
|
||||||
|
|
||||||
if not msg:
|
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
|
return
|
||||||
|
|
||||||
_cprint(f" 📝 Plan mode queued via skill. Markdown plan target: {plan_path}")
|
_cprint(f" 📝 Plan mode queued via skill. Markdown plan target: {plan_path}")
|
||||||
if hasattr(self, '_pending_input'):
|
if hasattr(self, '_pending_input'):
|
||||||
self._pending_input.put(msg)
|
self._pending_input.put(msg)
|
||||||
else:
|
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):
|
def _handle_background_command(self, cmd: str):
|
||||||
"""Handle /background <prompt> — run a prompt in a separate background session.
|
"""Handle /background <prompt> — run a prompt in a separate background session.
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ Pure display functions with no HermesCLI state dependency.
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
|
|
@ -189,6 +190,79 @@ def check_for_updates() -> Optional[int]:
|
||||||
return behind
|
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
|
# 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")
|
border_color = _skin_color("banner_border", "#CD7F32")
|
||||||
outer_panel = Panel(
|
outer_panel = Panel(
|
||||||
layout_table,
|
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,
|
border_style=border_color,
|
||||||
padding=(0, 2),
|
padding=(0, 2),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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(1, curses.COLOR_GREEN, -1)
|
||||||
curses.init_pair(2, curses.COLOR_YELLOW, -1)
|
curses.init_pair(2, curses.COLOR_YELLOW, -1)
|
||||||
cursor = default
|
cursor = default
|
||||||
|
scroll_offset = 0
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
stdscr.clear()
|
stdscr.clear()
|
||||||
max_y, max_x = stdscr.getmaxyx()
|
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:
|
try:
|
||||||
stdscr.addnstr(
|
stdscr.addnstr(
|
||||||
0,
|
0,
|
||||||
|
|
@ -436,12 +448,12 @@ def _curses_prompt_choice(question: str, choices: list, default: int = 0) -> int
|
||||||
except curses.error:
|
except curses.error:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
for i, choice in enumerate(choices):
|
for row, i in enumerate(range(scroll_offset, min(scroll_offset + visible, len(choices)))):
|
||||||
y = i + 2
|
y = row + 2
|
||||||
if y >= max_y - 1:
|
if y >= max_y - 1:
|
||||||
break
|
break
|
||||||
arrow = "→" if i == cursor else " "
|
arrow = "→" if i == cursor else " "
|
||||||
line = f" {arrow} {choice}"
|
line = f" {arrow} {choices[i]}"
|
||||||
attr = curses.A_NORMAL
|
attr = curses.A_NORMAL
|
||||||
if i == cursor:
|
if i == cursor:
|
||||||
attr = curses.A_BOLD
|
attr = curses.A_BOLD
|
||||||
|
|
|
||||||
63
tests/hermes_cli/test_banner_git_state.py
Normal file
63
tests/hermes_cli/test_banner_git_state.py
Normal file
|
|
@ -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}
|
||||||
140
tests/test_cli_skin_integration.py
Normal file
140
tests/test_cli_skin_integration.py
Normal file
|
|
@ -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"
|
||||||
118
tests/test_model_picker_scroll.py
Normal file
118
tests/test_model_picker_scroll.py
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue