diff --git a/agent/display.py b/agent/display.py index fbd40f8f2..581cde562 100644 --- a/agent/display.py +++ b/agent/display.py @@ -16,6 +16,47 @@ _RED = "\033[31m" _RESET = "\033[0m" +# ========================================================================= +# Skin-aware helpers (lazy import to avoid circular deps) +# ========================================================================= + +def _get_skin(): + """Get the active skin config, or None if not available.""" + try: + from hermes_cli.skin_engine import get_active_skin + return get_active_skin() + except Exception: + return None + + +def get_skin_faces(key: str, default: list) -> list: + """Get spinner face list from active skin, falling back to default.""" + skin = _get_skin() + if skin: + faces = skin.get_spinner_list(key) + if faces: + return faces + return default + + +def get_skin_verbs() -> list: + """Get thinking verbs from active skin.""" + skin = _get_skin() + if skin: + verbs = skin.get_spinner_list("thinking_verbs") + if verbs: + return verbs + return KawaiiSpinner.THINKING_VERBS + + +def get_skin_tool_prefix() -> str: + """Get tool output prefix character from active skin.""" + skin = _get_skin() + if skin: + return skin.tool_prefix + return "┊" + + # ========================================================================= # Tool preview (one-line summary of a tool call's primary argument) # ========================================================================= @@ -179,13 +220,21 @@ class KawaiiSpinner: pass def _animate(self): + # Cache skin wings at start (avoid per-frame imports) + skin = _get_skin() + wings = skin.get_spinner_wings() if skin else [] + while self.running: if os.getenv("HERMES_SPINNER_PAUSE"): time.sleep(0.1) continue frame = self.spinner_frames[self.frame_idx % len(self.spinner_frames)] elapsed = time.time() - self.start_time - line = f" {frame} {self.message} ({elapsed:.1f}s)" + if wings: + left, right = wings[self.frame_idx % len(wings)] + line = f" {left} {frame} {self.message} {right} ({elapsed:.1f}s)" + else: + line = f" {frame} {self.message} ({elapsed:.1f}s)" pad = max(self.last_line_len - len(line), 0) self._write(f"\r{line}{' ' * pad}", end='', flush=True) self.last_line_len = len(line) @@ -334,6 +383,7 @@ def get_cute_tool_message( """ dur = f"{duration:.1f}s" is_failure, failure_suffix = _detect_tool_failure(tool_name, result) + skin_prefix = get_skin_tool_prefix() def _trunc(s, n=40): s = str(s) @@ -344,7 +394,9 @@ def get_cute_tool_message( return ("..." + p[-(n-3):]) if len(p) > n else p def _wrap(line: str) -> str: - """Append failure suffix when the tool failed.""" + """Apply skin tool prefix and failure suffix.""" + if skin_prefix != "┊": + line = line.replace("┊", skin_prefix, 1) if not is_failure: return line return f"{line}{failure_suffix}" diff --git a/cli.py b/cli.py index f169bcedb..3c2a53c7c 100755 --- a/cli.py +++ b/cli.py @@ -202,6 +202,7 @@ def load_cli_config() -> Dict[str, Any]: "display": { "compact": False, "resume_display": "full", + "skin": "default", }, "clarify": { "timeout": 120, # Seconds to wait for a clarify answer before auto-proceeding @@ -383,6 +384,13 @@ def load_cli_config() -> Dict[str, Any]: # Load configuration at module startup CLI_CONFIG = load_cli_config() +# Initialize the skin engine from config +try: + from hermes_cli.skin_engine import init_skin_from_config + init_skin_from_config(CLI_CONFIG) +except Exception: + pass # Skin engine is optional — default skin used if unavailable + from rich.console import Console from rich.panel import Panel from rich.table import Table @@ -1051,6 +1059,7 @@ class HermesCLI: verbose: bool = False, compact: bool = False, resume: str = None, + checkpoints: bool = False, ): """ Initialize the Hermes CLI. @@ -1132,6 +1141,13 @@ class HermesCLI: if invalid: self.console.print(f"[bold red]Warning: Unknown toolsets: {', '.join(invalid)}[/]") + # Filesystem checkpoints: CLI flag > config + cp_cfg = CLI_CONFIG.get("checkpoints", {}) + if isinstance(cp_cfg, bool): + cp_cfg = {"enabled": cp_cfg} + self.checkpoints_enabled = checkpoints or cp_cfg.get("enabled", False) + self.checkpoint_max_snapshots = cp_cfg.get("max_snapshots", 50) + # Ephemeral system prompt: env var takes precedence, then config self.system_prompt = ( os.getenv("HERMES_EPHEMERAL_SYSTEM_PROMPT", "") @@ -1401,6 +1417,8 @@ class HermesCLI: honcho_session_key=self.session_id, fallback_model=self._fallback_model, thinking_callback=self._on_thinking, + checkpoints_enabled=self.checkpoints_enabled, + checkpoint_max_snapshots=self.checkpoint_max_snapshots, ) # Apply any pending title now that the session exists in the DB if self._pending_title and self._session_db: @@ -1670,6 +1688,55 @@ class HermesCLI: self._image_counter -= 1 return False + def _handle_rollback_command(self, command: str): + """Handle /rollback — list or restore filesystem checkpoints.""" + from tools.checkpoint_manager import CheckpointManager, format_checkpoint_list + + if not hasattr(self, 'agent') or not self.agent: + print(" No active agent session.") + return + + mgr = self.agent._checkpoint_mgr + if not mgr.enabled: + print(" Checkpoints are not enabled.") + print(" Enable with: hermes --checkpoints") + print(" Or in config.yaml: checkpoints: { enabled: true }") + return + + cwd = os.getenv("TERMINAL_CWD", os.getcwd()) + parts = command.split(maxsplit=1) + arg = parts[1].strip() if len(parts) > 1 else "" + + if not arg: + # List checkpoints + checkpoints = mgr.list_checkpoints(cwd) + print(format_checkpoint_list(checkpoints, cwd)) + else: + # Restore by number or hash + checkpoints = mgr.list_checkpoints(cwd) + if not checkpoints: + print(f" No checkpoints found for {cwd}") + return + + target_hash = None + try: + idx = int(arg) - 1 # 1-indexed for user + if 0 <= idx < len(checkpoints): + target_hash = checkpoints[idx]["hash"] + else: + print(f" Invalid checkpoint number. Use 1-{len(checkpoints)}.") + return + except ValueError: + # Try as a git hash + target_hash = arg + + result = mgr.restore(cwd, target_hash) + if result["success"]: + print(f" ✅ Restored to checkpoint {result['restored_to']}: {result['reason']}") + print(f" A pre-rollback snapshot was saved automatically.") + else: + print(f" ❌ {result['error']}") + def _handle_paste_command(self): """Handle /paste — explicitly check clipboard for an image. @@ -2679,6 +2746,10 @@ class HermesCLI: self._handle_paste_command() elif cmd_lower == "/reload-mcp": self._reload_mcp() + elif cmd_lower.startswith("/rollback"): + self._handle_rollback_command(cmd_original) + elif cmd_lower.startswith("/skin"): + self._handle_skin_command(cmd_original) else: # Check for skill slash commands (/gif-search, /axolotl, etc.) base_cmd = cmd_lower.split()[0] @@ -2698,6 +2769,43 @@ class HermesCLI: return True + def _handle_skin_command(self, cmd: str): + """Handle /skin [name] — show or change the display skin.""" + try: + from hermes_cli.skin_engine import list_skins, set_active_skin, get_active_skin_name + except ImportError: + print("Skin engine not available.") + return + + parts = cmd.strip().split(maxsplit=1) + if len(parts) < 2 or not parts[1].strip(): + # Show current skin and list available + current = get_active_skin_name() + skins = list_skins() + print(f"\n Current skin: {current}") + print(f" Available skins:") + for s in skins: + marker = " ●" if s["name"] == current else " " + source = f" ({s['source']})" if s["source"] == "user" else "" + print(f" {marker} {s['name']}{source} — {s['description']}") + print(f"\n Usage: /skin ") + print(f" Custom skins: drop a YAML file in ~/.hermes/skins/\n") + return + + new_skin = parts[1].strip().lower() + available = {s["name"] for s in list_skins()} + if new_skin not in available: + print(f" Unknown skin: {new_skin}") + print(f" Available: {', '.join(sorted(available))}") + return + + set_active_skin(new_skin) + if save_config_value("display.skin", new_skin): + print(f" Skin set to: {new_skin} (saved)") + else: + print(f" Skin set to: {new_skin}") + print(" Note: banner colors will update on next session start.") + def _toggle_verbose(self): """Cycle tool progress mode: off → new → all → verbose → off.""" cycle = ["off", "new", "all", "verbose"] @@ -3169,10 +3277,22 @@ class HermesCLI: if response: w = shutil.get_terminal_size().columns - label = " ⚕ Hermes " + # Use skin branding for response box label + 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", "") + if _resp_color: + _resp_start = f"\033[38;2;{int(_resp_color[1:3], 16)};{int(_resp_color[3:5], 16)};{int(_resp_color[5:7], 16)}m" + else: + _resp_start = _GOLD + except Exception: + label = " ⚕ Hermes " + _resp_start = _GOLD fill = w - 2 - len(label) # 2 for ╭ and ╮ - top = f"{_GOLD}╭─{label}{'─' * max(fill - 1, 0)}╮{_RST}" - bot = f"{_GOLD}╰{'─' * (w - 2)}╯{_RST}" + top = f"{_resp_start}╭─{label}{'─' * max(fill - 1, 0)}╮{_RST}" + bot = f"{_resp_start}╰{'─' * (w - 2)}╯{_RST}" # Render box + response as a single _cprint call so # nothing can interleave between the box borders. @@ -3241,7 +3361,15 @@ class HermesCLI: if self._preload_resumed_session(): self._display_resumed_history() - self.console.print("[#FFF8DC]Welcome to Hermes Agent! Type your message or /help for commands.[/]") + try: + from hermes_cli.skin_engine import get_active_skin + _welcome_skin = get_active_skin() + _welcome_text = _welcome_skin.get_branding("welcome", "Welcome to Hermes Agent! Type your message or /help for commands.") + _welcome_color = _welcome_skin.get_color("banner_text", "#FFF8DC") + except Exception: + _welcome_text = "Welcome to Hermes Agent! Type your message or /help for commands." + _welcome_color = "#FFF8DC" + self.console.print(f"[{_welcome_color}]{_welcome_text}[/]") self.console.print() # State for async operation @@ -4110,6 +4238,7 @@ def main( resume: str = None, worktree: bool = False, w: bool = False, + checkpoints: bool = False, ): """ Hermes Agent CLI - Interactive AI Assistant @@ -4214,6 +4343,7 @@ def main( verbose=verbose, compact=compact, resume=resume, + checkpoints=checkpoints, ) # Inject worktree context into agent's system prompt diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index 395a2381f..8ab4425dc 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -36,6 +36,28 @@ def cprint(text: str): _pt_print(_PT_ANSI(text)) +# ========================================================================= +# Skin-aware color helpers +# ========================================================================= + +def _skin_color(key: str, fallback: str) -> str: + """Get a color from the active skin, or return fallback.""" + try: + from hermes_cli.skin_engine import get_active_skin + return get_active_skin().get_color(key, fallback) + except Exception: + return fallback + + +def _skin_branding(key: str, fallback: str) -> str: + """Get a branding string from the active skin, or return fallback.""" + try: + from hermes_cli.skin_engine import get_active_skin + return get_active_skin().get_branding(key, fallback) + except Exception: + return fallback + + # ========================================================================= # ASCII Art & Branding # ========================================================================= @@ -217,18 +239,24 @@ def build_welcome_banner(console: Console, model: str, cwd: str, layout_table.add_column("left", justify="center") layout_table.add_column("right", justify="left") + # Resolve skin colors once for the entire banner + accent = _skin_color("banner_accent", "#FFBF00") + dim = _skin_color("banner_dim", "#B8860B") + text = _skin_color("banner_text", "#FFF8DC") + session_color = _skin_color("session_border", "#8B8682") + left_lines = ["", HERMES_CADUCEUS, ""] model_short = model.split("/")[-1] if "/" in model else model if len(model_short) > 28: model_short = model_short[:25] + "..." - ctx_str = f" [dim #B8860B]·[/] [dim #B8860B]{_format_context_length(context_length)} context[/]" if context_length else "" - left_lines.append(f"[#FFBF00]{model_short}[/]{ctx_str} [dim #B8860B]·[/] [dim #B8860B]Nous Research[/]") - left_lines.append(f"[dim #B8860B]{cwd}[/]") + ctx_str = f" [dim {dim}]·[/] [dim {dim}]{_format_context_length(context_length)} context[/]" if context_length else "" + left_lines.append(f"[{accent}]{model_short}[/]{ctx_str} [dim {dim}]·[/] [dim {dim}]Nous Research[/]") + left_lines.append(f"[dim {dim}]{cwd}[/]") if session_id: - left_lines.append(f"[dim #8B8682]Session: {session_id}[/]") + left_lines.append(f"[dim {session_color}]Session: {session_id}[/]") left_content = "\n".join(left_lines) - right_lines = ["[bold #FFBF00]Available Tools[/]"] + right_lines = [f"[bold {accent}]Available Tools[/]"] toolsets_dict: Dict[str, list] = {} for tool in tools: @@ -256,7 +284,7 @@ def build_welcome_banner(console: Console, model: str, cwd: str, if name in disabled_tools: colored_names.append(f"[red]{name}[/]") else: - colored_names.append(f"[#FFF8DC]{name}[/]") + colored_names.append(f"[{text}]{name}[/]") tools_str = ", ".join(colored_names) if len(", ".join(sorted(tool_names))) > 45: @@ -275,7 +303,7 @@ def build_welcome_banner(console: Console, model: str, cwd: str, elif name in disabled_tools: colored_names.append(f"[red]{name}[/]") else: - colored_names.append(f"[#FFF8DC]{name}[/]") + colored_names.append(f"[{text}]{name}[/]") tools_str = ", ".join(colored_names) right_lines.append(f"[dim #B8860B]{toolset}:[/] {tools_str}") @@ -306,7 +334,7 @@ def build_welcome_banner(console: Console, model: str, cwd: str, ) right_lines.append("") - right_lines.append("[bold #FFBF00]Available Skills[/]") + right_lines.append(f"[bold {accent}]Available Skills[/]") skills_by_category = get_available_skills() total_skills = sum(len(s) for s in skills_by_category.values()) @@ -320,9 +348,9 @@ def build_welcome_banner(console: Console, model: str, cwd: str, skills_str = ", ".join(skill_names) if len(skills_str) > 50: skills_str = skills_str[:47] + "..." - right_lines.append(f"[dim #B8860B]{category}:[/] [#FFF8DC]{skills_str}[/]") + right_lines.append(f"[dim {dim}]{category}:[/] [{text}]{skills_str}[/]") else: - right_lines.append("[dim #B8860B]No skills installed[/]") + right_lines.append(f"[dim {dim}]No skills installed[/]") right_lines.append("") mcp_connected = sum(1 for s in mcp_status if s["connected"]) if mcp_status else 0 @@ -330,7 +358,7 @@ def build_welcome_banner(console: Console, model: str, cwd: str, if mcp_connected: summary_parts.append(f"{mcp_connected} MCP servers") summary_parts.append("/help for commands") - right_lines.append(f"[dim #B8860B]{' · '.join(summary_parts)}[/]") + right_lines.append(f"[dim {dim}]{' · '.join(summary_parts)}[/]") # Update check — show if behind origin/main try: @@ -347,10 +375,13 @@ def build_welcome_banner(console: Console, model: str, cwd: str, right_content = "\n".join(right_lines) layout_table.add_row(left_content, right_content) + agent_name = _skin_branding("agent_name", "Hermes Agent") + title_color = _skin_color("banner_title", "#FFD700") + border_color = _skin_color("banner_border", "#CD7F32") outer_panel = Panel( layout_table, - title=f"[bold #FFD700]Hermes Agent {VERSION}[/]", - border_style="#CD7F32", + title=f"[bold {title_color}]{agent_name} {VERSION}[/]", + border_style=border_color, padding=(0, 2), ) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 20f01b174..72c9e77c1 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -39,6 +39,8 @@ COMMANDS = { "/insights": "Show usage insights and analytics (last 30 days)", "/paste": "Check clipboard for an image and attach it", "/reload-mcp": "Reload MCP servers from config.yaml", + "/rollback": "List or restore filesystem checkpoints (usage: /rollback [number])", + "/skin": "Show or change the display skin/theme", "/quit": "Exit the CLI (also: /exit, /q)", } diff --git a/hermes_cli/config.py b/hermes_cli/config.py index bb0416b20..0f99aac7a 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -89,6 +89,14 @@ DEFAULT_CONFIG = { "record_sessions": False, # Auto-record browser sessions as WebM videos }, + # Filesystem checkpoints — automatic snapshots before destructive file ops. + # When enabled, the agent takes a snapshot of the working directory once per + # conversation turn (on first write_file/patch call). Use /rollback to restore. + "checkpoints": { + "enabled": False, + "max_snapshots": 50, # Max checkpoints to keep per directory + }, + "compression": { "enabled": True, "threshold": 0.85, @@ -112,8 +120,9 @@ DEFAULT_CONFIG = { "display": { "compact": False, "personality": "kawaii", - "resume_display": "full", # "full" (show previous messages) | "minimal" (one-liner only) - "bell_on_complete": False, # Play terminal bell (\a) when agent finishes a response + "resume_display": "full", + "bell_on_complete": False, + "skin": "default", }, # Text-to-speech configuration @@ -171,7 +180,7 @@ DEFAULT_CONFIG = { "command_allowlist": [], # Config schema version - bump this when adding new required fields - "_config_version": 5, + "_config_version": 6, } # ============================================================================= diff --git a/hermes_cli/skin_engine.py b/hermes_cli/skin_engine.py new file mode 100644 index 000000000..ea97ac38b --- /dev/null +++ b/hermes_cli/skin_engine.py @@ -0,0 +1,341 @@ +"""Hermes CLI skin/theme engine. + +A data-driven skin system that lets users customize the CLI's visual appearance. +Skins are defined as YAML files in ~/.hermes/skins/ or as built-in presets. + +Each skin defines: +- colors: banner and UI color palette (hex values for Rich markup) +- spinner: kawaii faces, thinking verbs, optional wings +- branding: agent name, welcome/goodbye messages, prompt symbol +- tool_prefix: character used for tool output lines (default: ┊) + +Usage: + from hermes_cli.skin_engine import get_active_skin, list_skins, set_active_skin + + skin = get_active_skin() + print(skin.colors["banner_title"]) # "#FFD700" + print(skin.spinner["thinking_verbs"]) # ["pondering", ...] +""" + +import logging +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# Skin data structure +# ============================================================================= + +@dataclass +class SkinConfig: + """Complete skin configuration.""" + name: str + description: str = "" + colors: Dict[str, str] = field(default_factory=dict) + spinner: Dict[str, Any] = field(default_factory=dict) + branding: Dict[str, str] = field(default_factory=dict) + tool_prefix: str = "┊" + + def get_color(self, key: str, fallback: str = "") -> str: + """Get a color value with fallback.""" + return self.colors.get(key, fallback) + + def get_spinner_list(self, key: str) -> List[str]: + """Get a spinner list (faces, verbs, etc.).""" + return self.spinner.get(key, []) + + def get_spinner_wings(self) -> List[Tuple[str, str]]: + """Get spinner wing pairs, or empty list if none.""" + raw = self.spinner.get("wings", []) + result = [] + for pair in raw: + if isinstance(pair, (list, tuple)) and len(pair) == 2: + result.append((str(pair[0]), str(pair[1]))) + return result + + def get_branding(self, key: str, fallback: str = "") -> str: + """Get a branding value with fallback.""" + return self.branding.get(key, fallback) + + +# ============================================================================= +# Built-in skin definitions +# ============================================================================= + +_BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { + "default": { + "name": "default", + "description": "Classic Hermes — gold and kawaii", + "colors": { + "banner_border": "#CD7F32", + "banner_title": "#FFD700", + "banner_accent": "#FFBF00", + "banner_dim": "#B8860B", + "banner_text": "#FFF8DC", + "ui_accent": "#FFBF00", + "ui_label": "#4dd0e1", + "ui_ok": "#4caf50", + "ui_error": "#ef5350", + "ui_warn": "#ffa726", + "prompt": "#FFF8DC", + "input_rule": "#CD7F32", + "response_border": "#FFD700", + "session_label": "#DAA520", + "session_border": "#8B8682", + }, + "spinner": { + # Empty = use hardcoded defaults in display.py + }, + "branding": { + "agent_name": "Hermes Agent", + "welcome": "Welcome to Hermes Agent! Type your message or /help for commands.", + "goodbye": "Goodbye! ⚕", + "response_label": " ⚕ Hermes ", + "prompt_symbol": "❯ ", + "help_header": "(^_^)? Available Commands", + }, + "tool_prefix": "┊", + }, + "ares": { + "name": "ares", + "description": "War-god theme — crimson and bronze", + "colors": { + "banner_border": "#9F1C1C", + "banner_title": "#C7A96B", + "banner_accent": "#DD4A3A", + "banner_dim": "#6B1717", + "banner_text": "#F1E6CF", + "ui_accent": "#DD4A3A", + "ui_label": "#C7A96B", + "ui_ok": "#4caf50", + "ui_error": "#ef5350", + "ui_warn": "#ffa726", + "prompt": "#F1E6CF", + "input_rule": "#9F1C1C", + "response_border": "#C7A96B", + "session_label": "#C7A96B", + "session_border": "#6E584B", + }, + "spinner": { + "waiting_faces": ["(⚔)", "(⛨)", "(▲)", "(<>)", "(/)"], + "thinking_faces": ["(⚔)", "(⛨)", "(▲)", "(⌁)", "(<>)"], + "thinking_verbs": [ + "forging", "marching", "sizing the field", "holding the line", + "hammering plans", "tempering steel", "plotting impact", "raising the shield", + ], + "wings": [ + ["⟪⚔", "⚔⟫"], + ["⟪▲", "▲⟫"], + ["⟪╸", "╺⟫"], + ["⟪⛨", "⛨⟫"], + ], + }, + "branding": { + "agent_name": "Ares Agent", + "welcome": "Welcome to Ares Agent! Type your message or /help for commands.", + "goodbye": "Farewell, warrior! ⚔", + "response_label": " ⚔ Ares ", + "prompt_symbol": "⚔ ❯ ", + "help_header": "(⚔) Available Commands", + }, + "tool_prefix": "╎", + }, + "mono": { + "name": "mono", + "description": "Monochrome — clean grayscale", + "colors": { + "banner_border": "#555555", + "banner_title": "#e6edf3", + "banner_accent": "#aaaaaa", + "banner_dim": "#444444", + "banner_text": "#c9d1d9", + "ui_accent": "#aaaaaa", + "ui_label": "#888888", + "ui_ok": "#888888", + "ui_error": "#cccccc", + "ui_warn": "#999999", + "prompt": "#c9d1d9", + "input_rule": "#444444", + "response_border": "#aaaaaa", + "session_label": "#888888", + "session_border": "#555555", + }, + "spinner": {}, + "branding": { + "agent_name": "Hermes Agent", + "welcome": "Welcome to Hermes Agent! Type your message or /help for commands.", + "goodbye": "Goodbye! ⚕", + "response_label": " ⚕ Hermes ", + "prompt_symbol": "❯ ", + "help_header": "[?] Available Commands", + }, + "tool_prefix": "┊", + }, + "slate": { + "name": "slate", + "description": "Cool blue — developer-focused", + "colors": { + "banner_border": "#4169e1", + "banner_title": "#7eb8f6", + "banner_accent": "#8EA8FF", + "banner_dim": "#4b5563", + "banner_text": "#c9d1d9", + "ui_accent": "#7eb8f6", + "ui_label": "#8EA8FF", + "ui_ok": "#63D0A6", + "ui_error": "#F7A072", + "ui_warn": "#e6a855", + "prompt": "#c9d1d9", + "input_rule": "#4169e1", + "response_border": "#7eb8f6", + "session_label": "#7eb8f6", + "session_border": "#4b5563", + }, + "spinner": {}, + "branding": { + "agent_name": "Hermes Agent", + "welcome": "Welcome to Hermes Agent! Type your message or /help for commands.", + "goodbye": "Goodbye! ⚕", + "response_label": " ⚕ Hermes ", + "prompt_symbol": "❯ ", + "help_header": "(^_^)? Available Commands", + }, + "tool_prefix": "┊", + }, +} + + +# ============================================================================= +# Skin loading and management +# ============================================================================= + +_active_skin: Optional[SkinConfig] = None +_active_skin_name: str = "default" + + +def _skins_dir() -> Path: + """User skins directory.""" + home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) + return home / "skins" + + +def _load_skin_from_yaml(path: Path) -> Optional[Dict[str, Any]]: + """Load a skin definition from a YAML file.""" + try: + import yaml + with open(path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) + if isinstance(data, dict) and "name" in data: + return data + except Exception as e: + logger.debug("Failed to load skin from %s: %s", path, e) + return None + + +def _build_skin_config(data: Dict[str, Any]) -> SkinConfig: + """Build a SkinConfig from a raw dict (built-in or loaded from YAML).""" + # Start with default values as base for missing keys + default = _BUILTIN_SKINS["default"] + colors = dict(default.get("colors", {})) + colors.update(data.get("colors", {})) + spinner = dict(default.get("spinner", {})) + spinner.update(data.get("spinner", {})) + branding = dict(default.get("branding", {})) + branding.update(data.get("branding", {})) + + return SkinConfig( + name=data.get("name", "unknown"), + description=data.get("description", ""), + colors=colors, + spinner=spinner, + branding=branding, + tool_prefix=data.get("tool_prefix", default.get("tool_prefix", "┊")), + ) + + +def list_skins() -> List[Dict[str, str]]: + """List all available skins (built-in + user-installed). + + Returns list of {"name": ..., "description": ..., "source": "builtin"|"user"}. + """ + result = [] + for name, data in _BUILTIN_SKINS.items(): + result.append({ + "name": name, + "description": data.get("description", ""), + "source": "builtin", + }) + + skins_path = _skins_dir() + if skins_path.is_dir(): + for f in sorted(skins_path.glob("*.yaml")): + data = _load_skin_from_yaml(f) + if data: + skin_name = data.get("name", f.stem) + # Skip if it shadows a built-in + if any(s["name"] == skin_name for s in result): + continue + result.append({ + "name": skin_name, + "description": data.get("description", ""), + "source": "user", + }) + + return result + + +def load_skin(name: str) -> SkinConfig: + """Load a skin by name. Checks user skins first, then built-in.""" + # Check user skins directory + skins_path = _skins_dir() + user_file = skins_path / f"{name}.yaml" + if user_file.is_file(): + data = _load_skin_from_yaml(user_file) + if data: + return _build_skin_config(data) + + # Check built-in skins + if name in _BUILTIN_SKINS: + return _build_skin_config(_BUILTIN_SKINS[name]) + + # Fallback to default + logger.warning("Skin '%s' not found, using default", name) + return _build_skin_config(_BUILTIN_SKINS["default"]) + + +def get_active_skin() -> SkinConfig: + """Get the currently active skin config (cached).""" + global _active_skin + if _active_skin is None: + _active_skin = load_skin(_active_skin_name) + return _active_skin + + +def set_active_skin(name: str) -> SkinConfig: + """Switch the active skin. Returns the new SkinConfig.""" + global _active_skin, _active_skin_name + _active_skin_name = name + _active_skin = load_skin(name) + return _active_skin + + +def get_active_skin_name() -> str: + """Get the name of the currently active skin.""" + return _active_skin_name + + +def init_skin_from_config(config: dict) -> None: + """Initialize the active skin from CLI config at startup. + + Call this once during CLI init with the loaded config dict. + """ + display = config.get("display", {}) + skin_name = display.get("skin", "default") + if isinstance(skin_name, str) and skin_name.strip(): + set_active_skin(skin_name.strip()) + else: + set_active_skin("default") diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index 3b01eb7b3..ec81fbeed 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -12,7 +12,7 @@ EXPECTED_COMMANDS = { "/personality", "/clear", "/history", "/new", "/reset", "/retry", "/undo", "/save", "/config", "/cron", "/skills", "/platforms", "/verbose", "/compress", "/title", "/usage", "/insights", "/paste", - "/reload-mcp", "/quit", + "/reload-mcp", "/rollback", "/skin", "/quit", } diff --git a/tests/hermes_cli/test_skin_engine.py b/tests/hermes_cli/test_skin_engine.py new file mode 100644 index 000000000..7de90b32c --- /dev/null +++ b/tests/hermes_cli/test_skin_engine.py @@ -0,0 +1,232 @@ +"""Tests for hermes_cli.skin_engine — the data-driven skin/theme system.""" + +import json +import os +import pytest +from pathlib import Path +from unittest.mock import patch + + +@pytest.fixture(autouse=True) +def reset_skin_state(): + """Reset skin engine state between tests.""" + from hermes_cli import skin_engine + skin_engine._active_skin = None + skin_engine._active_skin_name = "default" + yield + skin_engine._active_skin = None + skin_engine._active_skin_name = "default" + + +class TestSkinConfig: + def test_default_skin_has_required_fields(self): + from hermes_cli.skin_engine import load_skin + skin = load_skin("default") + assert skin.name == "default" + assert skin.tool_prefix == "┊" + assert "banner_title" in skin.colors + assert "banner_border" in skin.colors + assert "agent_name" in skin.branding + + def test_get_color_with_fallback(self): + from hermes_cli.skin_engine import load_skin + skin = load_skin("default") + assert skin.get_color("banner_title") == "#FFD700" + assert skin.get_color("nonexistent", "#000") == "#000" + + def test_get_branding_with_fallback(self): + from hermes_cli.skin_engine import load_skin + skin = load_skin("default") + assert skin.get_branding("agent_name") == "Hermes Agent" + assert skin.get_branding("nonexistent", "fallback") == "fallback" + + def test_get_spinner_list_empty_for_default(self): + from hermes_cli.skin_engine import load_skin + skin = load_skin("default") + # Default skin has no custom spinner config + assert skin.get_spinner_list("waiting_faces") == [] + assert skin.get_spinner_list("thinking_verbs") == [] + + def test_get_spinner_wings_empty_for_default(self): + from hermes_cli.skin_engine import load_skin + skin = load_skin("default") + assert skin.get_spinner_wings() == [] + + +class TestBuiltinSkins: + def test_ares_skin_loads(self): + from hermes_cli.skin_engine import load_skin + skin = load_skin("ares") + assert skin.name == "ares" + assert skin.tool_prefix == "╎" + assert skin.get_color("banner_border") == "#9F1C1C" + assert skin.get_branding("agent_name") == "Ares Agent" + + def test_ares_has_spinner_customization(self): + from hermes_cli.skin_engine import load_skin + skin = load_skin("ares") + assert len(skin.get_spinner_list("waiting_faces")) > 0 + assert len(skin.get_spinner_list("thinking_faces")) > 0 + assert len(skin.get_spinner_list("thinking_verbs")) > 0 + wings = skin.get_spinner_wings() + assert len(wings) > 0 + assert isinstance(wings[0], tuple) + assert len(wings[0]) == 2 + + def test_mono_skin_loads(self): + from hermes_cli.skin_engine import load_skin + skin = load_skin("mono") + assert skin.name == "mono" + assert skin.get_color("banner_title") == "#e6edf3" + + def test_slate_skin_loads(self): + from hermes_cli.skin_engine import load_skin + skin = load_skin("slate") + assert skin.name == "slate" + assert skin.get_color("banner_title") == "#7eb8f6" + + def test_unknown_skin_falls_back_to_default(self): + from hermes_cli.skin_engine import load_skin + skin = load_skin("nonexistent_skin_xyz") + assert skin.name == "default" + + def test_all_builtin_skins_have_complete_colors(self): + from hermes_cli.skin_engine import _BUILTIN_SKINS, _build_skin_config + required_keys = ["banner_border", "banner_title", "banner_accent", + "banner_dim", "banner_text", "ui_accent"] + for name, data in _BUILTIN_SKINS.items(): + skin = _build_skin_config(data) + for key in required_keys: + assert key in skin.colors, f"Skin '{name}' missing color '{key}'" + + +class TestSkinManagement: + def test_set_active_skin(self): + from hermes_cli.skin_engine import set_active_skin, get_active_skin, get_active_skin_name + skin = set_active_skin("ares") + assert skin.name == "ares" + assert get_active_skin_name() == "ares" + assert get_active_skin().name == "ares" + + def test_get_active_skin_defaults(self): + from hermes_cli.skin_engine import get_active_skin + skin = get_active_skin() + assert skin.name == "default" + + def test_list_skins_includes_builtins(self): + from hermes_cli.skin_engine import list_skins + skins = list_skins() + names = [s["name"] for s in skins] + assert "default" in names + assert "ares" in names + assert "mono" in names + assert "slate" in names + for s in skins: + assert "source" in s + assert s["source"] == "builtin" + + def test_init_skin_from_config(self): + from hermes_cli.skin_engine import init_skin_from_config, get_active_skin_name + init_skin_from_config({"display": {"skin": "ares"}}) + assert get_active_skin_name() == "ares" + + def test_init_skin_from_empty_config(self): + from hermes_cli.skin_engine import init_skin_from_config, get_active_skin_name + init_skin_from_config({}) + assert get_active_skin_name() == "default" + + +class TestUserSkins: + def test_load_user_skin_from_yaml(self, tmp_path, monkeypatch): + from hermes_cli.skin_engine import load_skin, _skins_dir + # Create a user skin YAML + skins_dir = tmp_path / "skins" + skins_dir.mkdir() + skin_file = skins_dir / "custom.yaml" + skin_data = { + "name": "custom", + "description": "A custom test skin", + "colors": {"banner_title": "#FF0000"}, + "branding": {"agent_name": "Custom Agent"}, + "tool_prefix": "▸", + } + import yaml + skin_file.write_text(yaml.dump(skin_data)) + + # Patch skins dir + monkeypatch.setattr("hermes_cli.skin_engine._skins_dir", lambda: skins_dir) + + skin = load_skin("custom") + assert skin.name == "custom" + assert skin.get_color("banner_title") == "#FF0000" + assert skin.get_branding("agent_name") == "Custom Agent" + assert skin.tool_prefix == "▸" + # Should inherit defaults for unspecified colors + assert skin.get_color("banner_border") == "#CD7F32" # from default + + def test_list_skins_includes_user_skins(self, tmp_path, monkeypatch): + from hermes_cli.skin_engine import list_skins + skins_dir = tmp_path / "skins" + skins_dir.mkdir() + import yaml + (skins_dir / "pirate.yaml").write_text(yaml.dump({ + "name": "pirate", + "description": "Arr matey", + })) + monkeypatch.setattr("hermes_cli.skin_engine._skins_dir", lambda: skins_dir) + + skins = list_skins() + names = [s["name"] for s in skins] + assert "pirate" in names + pirate = [s for s in skins if s["name"] == "pirate"][0] + assert pirate["source"] == "user" + + +class TestDisplayIntegration: + def test_get_skin_tool_prefix_default(self): + from agent.display import get_skin_tool_prefix + assert get_skin_tool_prefix() == "┊" + + def test_get_skin_tool_prefix_custom(self): + from hermes_cli.skin_engine import set_active_skin + from agent.display import get_skin_tool_prefix + set_active_skin("ares") + assert get_skin_tool_prefix() == "╎" + + def test_get_skin_faces_default(self): + from agent.display import get_skin_faces, KawaiiSpinner + faces = get_skin_faces("waiting_faces", KawaiiSpinner.KAWAII_WAITING) + # Default skin has no custom faces, so should return the default list + assert faces == KawaiiSpinner.KAWAII_WAITING + + def test_get_skin_faces_ares(self): + from hermes_cli.skin_engine import set_active_skin + from agent.display import get_skin_faces, KawaiiSpinner + set_active_skin("ares") + faces = get_skin_faces("waiting_faces", KawaiiSpinner.KAWAII_WAITING) + assert "(⚔)" in faces + + def test_get_skin_verbs_default(self): + from agent.display import get_skin_verbs, KawaiiSpinner + verbs = get_skin_verbs() + assert verbs == KawaiiSpinner.THINKING_VERBS + + def test_get_skin_verbs_ares(self): + from hermes_cli.skin_engine import set_active_skin + from agent.display import get_skin_verbs + set_active_skin("ares") + verbs = get_skin_verbs() + assert "forging" in verbs + + def test_tool_message_uses_skin_prefix(self): + from hermes_cli.skin_engine import set_active_skin + from agent.display import get_cute_tool_message + set_active_skin("ares") + msg = get_cute_tool_message("terminal", {"command": "ls"}, 0.5) + assert msg.startswith("╎") + assert "┊" not in msg + + def test_tool_message_default_prefix(self): + from agent.display import get_cute_tool_message + msg = get_cute_tool_message("terminal", {"command": "ls"}, 0.5) + assert msg.startswith("┊")