Merge pull request #1282 from NousResearch/hermes/hermes-cc060dd9
fix(cli): make TUI prompt and accent output skin-aware
This commit is contained in:
225
cli.py
225
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:
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
95
tests/test_cli_skin_integration.py
Normal file
95
tests/test_cli_skin_integration.py
Normal file
@@ -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"
|
||||
Reference in New Issue
Block a user