feat: add data-driven skin/theme engine for CLI customization
Adds a skin system that lets users customize the CLI's visual appearance through data files (YAML) rather than code changes. Skins define: color palette, spinner faces/verbs/wings, branding text, and tool output prefix. New files: - hermes_cli/skin_engine.py — SkinConfig dataclass, built-in skins (default, ares, mono, slate), YAML loader for user skins from ~/.hermes/skins/, skin management API - tests/hermes_cli/test_skin_engine.py — 26 tests covering config, built-in skins, user YAML skins, display integration Modified files: - agent/display.py — skin-aware spinner wings, faces, verbs, tool prefix - hermes_cli/banner.py — skin-aware banner colors (title, border, accent, dim, text, session) via _skin_color()/_skin_branding() helpers - cli.py — /skin command handler, skin init from config, skin-aware response box label and welcome message - hermes_cli/config.py — add display.skin default - hermes_cli/commands.py — add /skin to slash commands Built-in skins: - default: classic Hermes gold/kawaii - ares: crimson/bronze war-god theme (from community PRs #579/#725) - mono: clean grayscale - slate: cool blue developer theme User skins: drop a YAML file in ~/.hermes/skins/ with name, colors, spinner, branding, and tool_prefix fields. Missing values inherit from the default skin.
This commit is contained in:
parent
c0ffd6b704
commit
de6750ed23
8 changed files with 820 additions and 23 deletions
|
|
@ -16,6 +16,47 @@ _RED = "\033[31m"
|
||||||
_RESET = "\033[0m"
|
_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)
|
# Tool preview (one-line summary of a tool call's primary argument)
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
@ -179,13 +220,21 @@ class KawaiiSpinner:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _animate(self):
|
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:
|
while self.running:
|
||||||
if os.getenv("HERMES_SPINNER_PAUSE"):
|
if os.getenv("HERMES_SPINNER_PAUSE"):
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
continue
|
continue
|
||||||
frame = self.spinner_frames[self.frame_idx % len(self.spinner_frames)]
|
frame = self.spinner_frames[self.frame_idx % len(self.spinner_frames)]
|
||||||
elapsed = time.time() - self.start_time
|
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)
|
pad = max(self.last_line_len - len(line), 0)
|
||||||
self._write(f"\r{line}{' ' * pad}", end='', flush=True)
|
self._write(f"\r{line}{' ' * pad}", end='', flush=True)
|
||||||
self.last_line_len = len(line)
|
self.last_line_len = len(line)
|
||||||
|
|
@ -334,6 +383,7 @@ def get_cute_tool_message(
|
||||||
"""
|
"""
|
||||||
dur = f"{duration:.1f}s"
|
dur = f"{duration:.1f}s"
|
||||||
is_failure, failure_suffix = _detect_tool_failure(tool_name, result)
|
is_failure, failure_suffix = _detect_tool_failure(tool_name, result)
|
||||||
|
skin_prefix = get_skin_tool_prefix()
|
||||||
|
|
||||||
def _trunc(s, n=40):
|
def _trunc(s, n=40):
|
||||||
s = str(s)
|
s = str(s)
|
||||||
|
|
@ -344,7 +394,9 @@ def get_cute_tool_message(
|
||||||
return ("..." + p[-(n-3):]) if len(p) > n else p
|
return ("..." + p[-(n-3):]) if len(p) > n else p
|
||||||
|
|
||||||
def _wrap(line: str) -> str:
|
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:
|
if not is_failure:
|
||||||
return line
|
return line
|
||||||
return f"{line}{failure_suffix}"
|
return f"{line}{failure_suffix}"
|
||||||
|
|
|
||||||
138
cli.py
138
cli.py
|
|
@ -202,6 +202,7 @@ def load_cli_config() -> Dict[str, Any]:
|
||||||
"display": {
|
"display": {
|
||||||
"compact": False,
|
"compact": False,
|
||||||
"resume_display": "full",
|
"resume_display": "full",
|
||||||
|
"skin": "default",
|
||||||
},
|
},
|
||||||
"clarify": {
|
"clarify": {
|
||||||
"timeout": 120, # Seconds to wait for a clarify answer before auto-proceeding
|
"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
|
# Load configuration at module startup
|
||||||
CLI_CONFIG = load_cli_config()
|
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.console import Console
|
||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
@ -1051,6 +1059,7 @@ class HermesCLI:
|
||||||
verbose: bool = False,
|
verbose: bool = False,
|
||||||
compact: bool = False,
|
compact: bool = False,
|
||||||
resume: str = None,
|
resume: str = None,
|
||||||
|
checkpoints: bool = False,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Initialize the Hermes CLI.
|
Initialize the Hermes CLI.
|
||||||
|
|
@ -1132,6 +1141,13 @@ class HermesCLI:
|
||||||
if invalid:
|
if invalid:
|
||||||
self.console.print(f"[bold red]Warning: Unknown toolsets: {', '.join(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
|
# Ephemeral system prompt: env var takes precedence, then config
|
||||||
self.system_prompt = (
|
self.system_prompt = (
|
||||||
os.getenv("HERMES_EPHEMERAL_SYSTEM_PROMPT", "")
|
os.getenv("HERMES_EPHEMERAL_SYSTEM_PROMPT", "")
|
||||||
|
|
@ -1401,6 +1417,8 @@ class HermesCLI:
|
||||||
honcho_session_key=self.session_id,
|
honcho_session_key=self.session_id,
|
||||||
fallback_model=self._fallback_model,
|
fallback_model=self._fallback_model,
|
||||||
thinking_callback=self._on_thinking,
|
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
|
# Apply any pending title now that the session exists in the DB
|
||||||
if self._pending_title and self._session_db:
|
if self._pending_title and self._session_db:
|
||||||
|
|
@ -1670,6 +1688,55 @@ class HermesCLI:
|
||||||
self._image_counter -= 1
|
self._image_counter -= 1
|
||||||
return False
|
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):
|
def _handle_paste_command(self):
|
||||||
"""Handle /paste — explicitly check clipboard for an image.
|
"""Handle /paste — explicitly check clipboard for an image.
|
||||||
|
|
||||||
|
|
@ -2679,6 +2746,10 @@ class HermesCLI:
|
||||||
self._handle_paste_command()
|
self._handle_paste_command()
|
||||||
elif cmd_lower == "/reload-mcp":
|
elif cmd_lower == "/reload-mcp":
|
||||||
self._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:
|
else:
|
||||||
# Check for skill slash commands (/gif-search, /axolotl, etc.)
|
# Check for skill slash commands (/gif-search, /axolotl, etc.)
|
||||||
base_cmd = cmd_lower.split()[0]
|
base_cmd = cmd_lower.split()[0]
|
||||||
|
|
@ -2698,6 +2769,43 @@ class HermesCLI:
|
||||||
|
|
||||||
return True
|
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 <name>")
|
||||||
|
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):
|
def _toggle_verbose(self):
|
||||||
"""Cycle tool progress mode: off → new → all → verbose → off."""
|
"""Cycle tool progress mode: off → new → all → verbose → off."""
|
||||||
cycle = ["off", "new", "all", "verbose"]
|
cycle = ["off", "new", "all", "verbose"]
|
||||||
|
|
@ -3169,10 +3277,22 @@ class HermesCLI:
|
||||||
|
|
||||||
if response:
|
if response:
|
||||||
w = shutil.get_terminal_size().columns
|
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 ╮
|
fill = w - 2 - len(label) # 2 for ╭ and ╮
|
||||||
top = f"{_GOLD}╭─{label}{'─' * max(fill - 1, 0)}╮{_RST}"
|
top = f"{_resp_start}╭─{label}{'─' * max(fill - 1, 0)}╮{_RST}"
|
||||||
bot = f"{_GOLD}╰{'─' * (w - 2)}╯{_RST}"
|
bot = f"{_resp_start}╰{'─' * (w - 2)}╯{_RST}"
|
||||||
|
|
||||||
# Render box + response as a single _cprint call so
|
# Render box + response as a single _cprint call so
|
||||||
# nothing can interleave between the box borders.
|
# nothing can interleave between the box borders.
|
||||||
|
|
@ -3241,7 +3361,15 @@ class HermesCLI:
|
||||||
if self._preload_resumed_session():
|
if self._preload_resumed_session():
|
||||||
self._display_resumed_history()
|
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()
|
self.console.print()
|
||||||
|
|
||||||
# State for async operation
|
# State for async operation
|
||||||
|
|
@ -4110,6 +4238,7 @@ def main(
|
||||||
resume: str = None,
|
resume: str = None,
|
||||||
worktree: bool = False,
|
worktree: bool = False,
|
||||||
w: bool = False,
|
w: bool = False,
|
||||||
|
checkpoints: bool = False,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Hermes Agent CLI - Interactive AI Assistant
|
Hermes Agent CLI - Interactive AI Assistant
|
||||||
|
|
@ -4214,6 +4343,7 @@ def main(
|
||||||
verbose=verbose,
|
verbose=verbose,
|
||||||
compact=compact,
|
compact=compact,
|
||||||
resume=resume,
|
resume=resume,
|
||||||
|
checkpoints=checkpoints,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Inject worktree context into agent's system prompt
|
# Inject worktree context into agent's system prompt
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,28 @@ def cprint(text: str):
|
||||||
_pt_print(_PT_ANSI(text))
|
_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
|
# 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("left", justify="center")
|
||||||
layout_table.add_column("right", justify="left")
|
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, ""]
|
left_lines = ["", HERMES_CADUCEUS, ""]
|
||||||
model_short = model.split("/")[-1] if "/" in model else model
|
model_short = model.split("/")[-1] if "/" in model else model
|
||||||
if len(model_short) > 28:
|
if len(model_short) > 28:
|
||||||
model_short = model_short[:25] + "..."
|
model_short = model_short[:25] + "..."
|
||||||
ctx_str = f" [dim #B8860B]·[/] [dim #B8860B]{_format_context_length(context_length)} context[/]" if context_length else ""
|
ctx_str = f" [dim {dim}]·[/] [dim {dim}]{_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"[{accent}]{model_short}[/]{ctx_str} [dim {dim}]·[/] [dim {dim}]Nous Research[/]")
|
||||||
left_lines.append(f"[dim #B8860B]{cwd}[/]")
|
left_lines.append(f"[dim {dim}]{cwd}[/]")
|
||||||
if session_id:
|
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)
|
left_content = "\n".join(left_lines)
|
||||||
|
|
||||||
right_lines = ["[bold #FFBF00]Available Tools[/]"]
|
right_lines = [f"[bold {accent}]Available Tools[/]"]
|
||||||
toolsets_dict: Dict[str, list] = {}
|
toolsets_dict: Dict[str, list] = {}
|
||||||
|
|
||||||
for tool in tools:
|
for tool in tools:
|
||||||
|
|
@ -256,7 +284,7 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
|
||||||
if name in disabled_tools:
|
if name in disabled_tools:
|
||||||
colored_names.append(f"[red]{name}[/]")
|
colored_names.append(f"[red]{name}[/]")
|
||||||
else:
|
else:
|
||||||
colored_names.append(f"[#FFF8DC]{name}[/]")
|
colored_names.append(f"[{text}]{name}[/]")
|
||||||
|
|
||||||
tools_str = ", ".join(colored_names)
|
tools_str = ", ".join(colored_names)
|
||||||
if len(", ".join(sorted(tool_names))) > 45:
|
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:
|
elif name in disabled_tools:
|
||||||
colored_names.append(f"[red]{name}[/]")
|
colored_names.append(f"[red]{name}[/]")
|
||||||
else:
|
else:
|
||||||
colored_names.append(f"[#FFF8DC]{name}[/]")
|
colored_names.append(f"[{text}]{name}[/]")
|
||||||
tools_str = ", ".join(colored_names)
|
tools_str = ", ".join(colored_names)
|
||||||
|
|
||||||
right_lines.append(f"[dim #B8860B]{toolset}:[/] {tools_str}")
|
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("")
|
||||||
right_lines.append("[bold #FFBF00]Available Skills[/]")
|
right_lines.append(f"[bold {accent}]Available Skills[/]")
|
||||||
skills_by_category = get_available_skills()
|
skills_by_category = get_available_skills()
|
||||||
total_skills = sum(len(s) for s in skills_by_category.values())
|
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)
|
skills_str = ", ".join(skill_names)
|
||||||
if len(skills_str) > 50:
|
if len(skills_str) > 50:
|
||||||
skills_str = skills_str[:47] + "..."
|
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:
|
else:
|
||||||
right_lines.append("[dim #B8860B]No skills installed[/]")
|
right_lines.append(f"[dim {dim}]No skills installed[/]")
|
||||||
|
|
||||||
right_lines.append("")
|
right_lines.append("")
|
||||||
mcp_connected = sum(1 for s in mcp_status if s["connected"]) if mcp_status else 0
|
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:
|
if mcp_connected:
|
||||||
summary_parts.append(f"{mcp_connected} MCP servers")
|
summary_parts.append(f"{mcp_connected} MCP servers")
|
||||||
summary_parts.append("/help for commands")
|
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
|
# Update check — show if behind origin/main
|
||||||
try:
|
try:
|
||||||
|
|
@ -347,10 +375,13 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
|
||||||
right_content = "\n".join(right_lines)
|
right_content = "\n".join(right_lines)
|
||||||
layout_table.add_row(left_content, right_content)
|
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(
|
outer_panel = Panel(
|
||||||
layout_table,
|
layout_table,
|
||||||
title=f"[bold #FFD700]Hermes Agent {VERSION}[/]",
|
title=f"[bold {title_color}]{agent_name} {VERSION}[/]",
|
||||||
border_style="#CD7F32",
|
border_style=border_color,
|
||||||
padding=(0, 2),
|
padding=(0, 2),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ COMMANDS = {
|
||||||
"/insights": "Show usage insights and analytics (last 30 days)",
|
"/insights": "Show usage insights and analytics (last 30 days)",
|
||||||
"/paste": "Check clipboard for an image and attach it",
|
"/paste": "Check clipboard for an image and attach it",
|
||||||
"/reload-mcp": "Reload MCP servers from config.yaml",
|
"/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)",
|
"/quit": "Exit the CLI (also: /exit, /q)",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,14 @@ DEFAULT_CONFIG = {
|
||||||
"record_sessions": False, # Auto-record browser sessions as WebM videos
|
"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": {
|
"compression": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"threshold": 0.85,
|
"threshold": 0.85,
|
||||||
|
|
@ -112,8 +120,9 @@ DEFAULT_CONFIG = {
|
||||||
"display": {
|
"display": {
|
||||||
"compact": False,
|
"compact": False,
|
||||||
"personality": "kawaii",
|
"personality": "kawaii",
|
||||||
"resume_display": "full", # "full" (show previous messages) | "minimal" (one-liner only)
|
"resume_display": "full",
|
||||||
"bell_on_complete": False, # Play terminal bell (\a) when agent finishes a response
|
"bell_on_complete": False,
|
||||||
|
"skin": "default",
|
||||||
},
|
},
|
||||||
|
|
||||||
# Text-to-speech configuration
|
# Text-to-speech configuration
|
||||||
|
|
@ -171,7 +180,7 @@ DEFAULT_CONFIG = {
|
||||||
"command_allowlist": [],
|
"command_allowlist": [],
|
||||||
|
|
||||||
# Config schema version - bump this when adding new required fields
|
# Config schema version - bump this when adding new required fields
|
||||||
"_config_version": 5,
|
"_config_version": 6,
|
||||||
}
|
}
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
341
hermes_cli/skin_engine.py
Normal file
341
hermes_cli/skin_engine.py
Normal file
|
|
@ -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")
|
||||||
|
|
@ -12,7 +12,7 @@ EXPECTED_COMMANDS = {
|
||||||
"/personality", "/clear", "/history", "/new", "/reset", "/retry",
|
"/personality", "/clear", "/history", "/new", "/reset", "/retry",
|
||||||
"/undo", "/save", "/config", "/cron", "/skills", "/platforms",
|
"/undo", "/save", "/config", "/cron", "/skills", "/platforms",
|
||||||
"/verbose", "/compress", "/title", "/usage", "/insights", "/paste",
|
"/verbose", "/compress", "/title", "/usage", "/insights", "/paste",
|
||||||
"/reload-mcp", "/quit",
|
"/reload-mcp", "/rollback", "/skin", "/quit",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
232
tests/hermes_cli/test_skin_engine.py
Normal file
232
tests/hermes_cli/test_skin_engine.py
Normal file
|
|
@ -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("┊")
|
||||||
Loading…
Add table
Add a link
Reference in a new issue