724 lines
34 KiB
Python
724 lines
34 KiB
Python
"""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.
|
||
No code changes are needed to add a new skin.
|
||
|
||
SKIN YAML SCHEMA
|
||
================
|
||
|
||
All fields are optional. Missing values inherit from the ``default`` skin.
|
||
|
||
.. code-block:: yaml
|
||
|
||
# Required: skin identity
|
||
name: mytheme # Unique skin name (lowercase, hyphens ok)
|
||
description: Short description # Shown in /skin listing
|
||
|
||
# Colors: hex values for Rich markup (banner, UI, response box)
|
||
colors:
|
||
banner_border: "#CD7F32" # Panel border color
|
||
banner_title: "#FFD700" # Panel title text color
|
||
banner_accent: "#FFBF00" # Section headers (Available Tools, etc.)
|
||
banner_dim: "#B8860B" # Dim/muted text (separators, labels)
|
||
banner_text: "#FFF8DC" # Body text (tool names, skill names)
|
||
ui_accent: "#FFBF00" # General UI accent
|
||
ui_label: "#4dd0e1" # UI labels
|
||
ui_ok: "#4caf50" # Success indicators
|
||
ui_error: "#ef5350" # Error indicators
|
||
ui_warn: "#ffa726" # Warning indicators
|
||
prompt: "#FFF8DC" # Prompt text color
|
||
input_rule: "#CD7F32" # Input area horizontal rule
|
||
response_border: "#FFD700" # Response box border (ANSI)
|
||
session_label: "#DAA520" # Session label color
|
||
session_border: "#8B8682" # Session ID dim color
|
||
|
||
# Spinner: customize the animated spinner during API calls
|
||
spinner:
|
||
waiting_faces: # Faces shown while waiting for API
|
||
- "(⚔)"
|
||
- "(⛨)"
|
||
thinking_faces: # Faces shown during reasoning
|
||
- "(⌁)"
|
||
- "(<>)"
|
||
thinking_verbs: # Verbs for spinner messages
|
||
- "forging"
|
||
- "plotting"
|
||
wings: # Optional left/right spinner decorations
|
||
- ["⟪⚔", "⚔⟫"] # Each entry is [left, right] pair
|
||
- ["⟪▲", "▲⟫"]
|
||
|
||
# Branding: text strings used throughout the CLI
|
||
branding:
|
||
agent_name: "Hermes Agent" # Banner title, status display
|
||
welcome: "Welcome message" # Shown at CLI startup
|
||
goodbye: "Goodbye! ⚕" # Shown on exit
|
||
response_label: " ⚕ Hermes " # Response box header label
|
||
prompt_symbol: "❯ " # Input prompt symbol
|
||
help_header: "(^_^)? Commands" # /help header text
|
||
|
||
# Tool prefix: character for tool output lines (default: ┊)
|
||
tool_prefix: "┊"
|
||
|
||
# Tool emojis: override the default emoji for any tool (used in spinners & progress)
|
||
tool_emojis:
|
||
terminal: "⚔" # Override terminal tool emoji
|
||
web_search: "🔮" # Override web_search tool emoji
|
||
# Any tool not listed here uses its registry default
|
||
|
||
USAGE
|
||
=====
|
||
|
||
.. code-block:: python
|
||
|
||
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.get_branding("agent_name")) # "Hermes Agent"
|
||
|
||
set_active_skin("ares") # Switch to built-in ares skin
|
||
set_active_skin("mytheme") # Switch to user skin from ~/.hermes/skins/
|
||
|
||
BUILT-IN SKINS
|
||
==============
|
||
|
||
- ``default`` — Classic Hermes gold/kawaii (the current look)
|
||
- ``ares`` — Crimson/bronze war-god theme with custom spinner wings
|
||
- ``mono`` — Clean grayscale monochrome
|
||
- ``slate`` — Cool blue developer-focused theme
|
||
|
||
USER SKINS
|
||
==========
|
||
|
||
Drop a YAML file in ``~/.hermes/skins/<name>.yaml`` following the schema above.
|
||
Activate with ``/skin <name>`` in the CLI or ``display.skin: <name>`` in config.yaml.
|
||
"""
|
||
|
||
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 = "┊"
|
||
tool_emojis: Dict[str, str] = field(default_factory=dict) # per-tool emoji overrides
|
||
banner_logo: str = "" # Rich-markup ASCII art logo (replaces HERMES_AGENT_LOGO)
|
||
banner_hero: str = "" # Rich-markup hero art (replaces HERMES_CADUCEUS)
|
||
|
||
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": "╎",
|
||
"banner_logo": """[bold #A3261F] █████╗ ██████╗ ███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗[/]
|
||
[bold #B73122]██╔══██╗██╔══██╗██╔════╝██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝[/]
|
||
[#C93C24]███████║██████╔╝█████╗ ███████╗█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║[/]
|
||
[#D84A28]██╔══██║██╔══██╗██╔══╝ ╚════██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║[/]
|
||
[#E15A2D]██║ ██║██║ ██║███████╗███████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║[/]
|
||
[#EB6C32]╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝[/]""",
|
||
"banner_hero": """[#9F1C1C]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣤⣤⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
||
[#9F1C1C]⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⣿⠟⠻⣿⣦⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
||
[#C7A96B]⠀⠀⠀⠀⠀⠀⠀⣠⣾⡿⠋⠀⠀⠀⠙⢿⣷⣄⠀⠀⠀⠀⠀⠀⠀[/]
|
||
[#C7A96B]⠀⠀⠀⠀⠀⢀⣾⡿⠋⠀⠀⢠⡄⠀⠀⠙⢿⣷⡀⠀⠀⠀⠀⠀[/]
|
||
[#DD4A3A]⠀⠀⠀⠀⣰⣿⠟⠀⠀⠀⣰⣿⣿⣆⠀⠀⠀⠻⣿⣆⠀⠀⠀⠀[/]
|
||
[#DD4A3A]⠀⠀⠀⢰⣿⠏⠀⠀⢀⣾⡿⠉⢿⣷⡀⠀⠀⠹⣿⡆⠀⠀⠀[/]
|
||
[#9F1C1C]⠀⠀⠀⣿⡟⠀⠀⣠⣿⠟⠀⠀⠀⠻⣿⣄⠀⠀⢻⣿⠀⠀⠀[/]
|
||
[#9F1C1C]⠀⠀⠀⣿⡇⠀⠀⠙⠋⠀⠀⚔⠀⠀⠙⠋⠀⠀⢸⣿⠀⠀⠀[/]
|
||
[#6B1717]⠀⠀⠀⢿⣧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⡿⠀⠀⠀[/]
|
||
[#6B1717]⠀⠀⠀⠘⢿⣷⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣾⡿⠃⠀⠀⠀[/]
|
||
[#C7A96B]⠀⠀⠀⠀⠈⠻⣿⣷⣦⣤⣀⣀⣤⣤⣶⣿⠿⠋⠀⠀⠀⠀[/]
|
||
[#C7A96B]⠀⠀⠀⠀⠀⠀⠀⠉⠛⠿⠿⠿⠿⠛⠉⠀⠀⠀⠀⠀⠀⠀[/]
|
||
[#DD4A3A]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⚔⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
||
[dim #6B1717]⠀⠀⠀⠀⠀⠀⠀⠀war god online⠀⠀⠀⠀⠀⠀⠀⠀[/]""",
|
||
},
|
||
"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": "┊",
|
||
},
|
||
"poseidon": {
|
||
"name": "poseidon",
|
||
"description": "Ocean-god theme — deep blue and seafoam",
|
||
"colors": {
|
||
"banner_border": "#2A6FB9",
|
||
"banner_title": "#A9DFFF",
|
||
"banner_accent": "#5DB8F5",
|
||
"banner_dim": "#153C73",
|
||
"banner_text": "#EAF7FF",
|
||
"ui_accent": "#5DB8F5",
|
||
"ui_label": "#A9DFFF",
|
||
"ui_ok": "#4caf50",
|
||
"ui_error": "#ef5350",
|
||
"ui_warn": "#ffa726",
|
||
"prompt": "#EAF7FF",
|
||
"input_rule": "#2A6FB9",
|
||
"response_border": "#5DB8F5",
|
||
"session_label": "#A9DFFF",
|
||
"session_border": "#496884",
|
||
},
|
||
"spinner": {
|
||
"waiting_faces": ["(≈)", "(Ψ)", "(∿)", "(◌)", "(◠)"],
|
||
"thinking_faces": ["(Ψ)", "(∿)", "(≈)", "(⌁)", "(◌)"],
|
||
"thinking_verbs": [
|
||
"charting currents", "sounding the depth", "reading foam lines",
|
||
"steering the trident", "tracking undertow", "plotting sea lanes",
|
||
"calling the swell", "measuring pressure",
|
||
],
|
||
"wings": [
|
||
["⟪≈", "≈⟫"],
|
||
["⟪Ψ", "Ψ⟫"],
|
||
["⟪∿", "∿⟫"],
|
||
["⟪◌", "◌⟫"],
|
||
],
|
||
},
|
||
"branding": {
|
||
"agent_name": "Poseidon Agent",
|
||
"welcome": "Welcome to Poseidon Agent! Type your message or /help for commands.",
|
||
"goodbye": "Fair winds! Ψ",
|
||
"response_label": " Ψ Poseidon ",
|
||
"prompt_symbol": "Ψ ❯ ",
|
||
"help_header": "(Ψ) Available Commands",
|
||
},
|
||
"tool_prefix": "│",
|
||
"banner_logo": """[bold #B8E8FF]██████╗ ██████╗ ███████╗███████╗██╗██████╗ ██████╗ ███╗ ██╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗[/]
|
||
[bold #97D6FF]██╔══██╗██╔═══██╗██╔════╝██╔════╝██║██╔══██╗██╔═══██╗████╗ ██║ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝[/]
|
||
[#75C1F6]██████╔╝██║ ██║███████╗█████╗ ██║██║ ██║██║ ██║██╔██╗ ██║█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║[/]
|
||
[#4FA2E0]██╔═══╝ ██║ ██║╚════██║██╔══╝ ██║██║ ██║██║ ██║██║╚██╗██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║[/]
|
||
[#2E7CC7]██║ ╚██████╔╝███████║███████╗██║██████╔╝╚██████╔╝██║ ╚████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║[/]
|
||
[#1B4F95]╚═╝ ╚═════╝ ╚══════╝╚══════╝╚═╝╚═════╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝[/]""",
|
||
"banner_hero": """[#2A6FB9]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
||
[#5DB8F5]⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣾⣿⣷⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
||
[#5DB8F5]⠀⠀⠀⠀⠀⠀⠀⢠⣿⠏⠀Ψ⠀⠹⣿⡄⠀⠀⠀⠀⠀⠀⠀[/]
|
||
[#A9DFFF]⠀⠀⠀⠀⠀⠀⠀⣿⡟⠀⠀⠀⠀⠀⢻⣿⠀⠀⠀⠀⠀⠀⠀[/]
|
||
[#A9DFFF]⠀⠀⠀≈≈≈≈≈⣿⡇⠀⠀⠀⠀⠀⢸⣿≈≈≈≈≈⠀⠀⠀[/]
|
||
[#5DB8F5]⠀⠀⠀⠀⠀⠀⠀⣿⡇⠀⠀⠀⠀⠀⢸⣿⠀⠀⠀⠀⠀⠀⠀[/]
|
||
[#2A6FB9]⠀⠀⠀⠀⠀⠀⠀⢿⣧⠀⠀⠀⠀⠀⣼⡿⠀⠀⠀⠀⠀⠀⠀[/]
|
||
[#2A6FB9]⠀⠀⠀⠀⠀⠀⠀⠘⢿⣷⣄⣀⣠⣾⡿⠃⠀⠀⠀⠀⠀⠀⠀[/]
|
||
[#153C73]⠀⠀⠀⠀⠀⠀⠀⠀⠈⠻⣿⣿⡿⠟⠁⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
||
[#153C73]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
||
[#5DB8F5]⠀⠀⠀⠀⠀≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈⠀⠀⠀⠀⠀[/]
|
||
[#A9DFFF]⠀⠀⠀⠀⠀⠀≈≈≈≈≈≈≈≈≈≈≈≈≈⠀⠀⠀⠀⠀⠀[/]
|
||
[dim #153C73]⠀⠀⠀⠀⠀⠀⠀deep waters hold⠀⠀⠀⠀⠀⠀⠀[/]""",
|
||
},
|
||
"sisyphus": {
|
||
"name": "sisyphus",
|
||
"description": "Sisyphean theme — austere grayscale with persistence",
|
||
"colors": {
|
||
"banner_border": "#B7B7B7",
|
||
"banner_title": "#F5F5F5",
|
||
"banner_accent": "#E7E7E7",
|
||
"banner_dim": "#4A4A4A",
|
||
"banner_text": "#D3D3D3",
|
||
"ui_accent": "#E7E7E7",
|
||
"ui_label": "#D3D3D3",
|
||
"ui_ok": "#919191",
|
||
"ui_error": "#E7E7E7",
|
||
"ui_warn": "#B7B7B7",
|
||
"prompt": "#F5F5F5",
|
||
"input_rule": "#656565",
|
||
"response_border": "#B7B7B7",
|
||
"session_label": "#919191",
|
||
"session_border": "#656565",
|
||
},
|
||
"spinner": {
|
||
"waiting_faces": ["(◉)", "(◌)", "(◬)", "(⬤)", "(::)"],
|
||
"thinking_faces": ["(◉)", "(◬)", "(◌)", "(○)", "(●)"],
|
||
"thinking_verbs": [
|
||
"finding traction", "measuring the grade", "resetting the boulder",
|
||
"counting the ascent", "testing leverage", "setting the shoulder",
|
||
"pushing uphill", "enduring the loop",
|
||
],
|
||
"wings": [
|
||
["⟪◉", "◉⟫"],
|
||
["⟪◬", "◬⟫"],
|
||
["⟪◌", "◌⟫"],
|
||
["⟪⬤", "⬤⟫"],
|
||
],
|
||
},
|
||
"branding": {
|
||
"agent_name": "Sisyphus Agent",
|
||
"welcome": "Welcome to Sisyphus Agent! Type your message or /help for commands.",
|
||
"goodbye": "The boulder waits. ◉",
|
||
"response_label": " ◉ Sisyphus ",
|
||
"prompt_symbol": "◉ ❯ ",
|
||
"help_header": "(◉) Available Commands",
|
||
},
|
||
"tool_prefix": "│",
|
||
"banner_logo": """[bold #F5F5F5]███████╗██╗███████╗██╗ ██╗██████╗ ██╗ ██╗██╗ ██╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗[/]
|
||
[bold #E7E7E7]██╔════╝██║██╔════╝╚██╗ ██╔╝██╔══██╗██║ ██║██║ ██║██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝[/]
|
||
[#D7D7D7]███████╗██║███████╗ ╚████╔╝ ██████╔╝███████║██║ ██║███████╗█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║[/]
|
||
[#BFBFBF]╚════██║██║╚════██║ ╚██╔╝ ██╔═══╝ ██╔══██║██║ ██║╚════██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║[/]
|
||
[#8F8F8F]███████║██║███████║ ██║ ██║ ██║ ██║╚██████╔╝███████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║[/]
|
||
[#626262]╚══════╝╚═╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝[/]""",
|
||
"banner_hero": """[#B7B7B7]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
||
[#D3D3D3]⠀⠀⠀⠀⠀⠀⠀⣠⣾⣿⣿⣿⣿⣷⣄⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
||
[#E7E7E7]⠀⠀⠀⠀⠀⠀⣾⣿⣿⣿⣿⣿⣿⣿⣷⠀⠀⠀⠀⠀⠀⠀[/]
|
||
[#F5F5F5]⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀[/]
|
||
[#E7E7E7]⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀[/]
|
||
[#D3D3D3]⠀⠀⠀⠀⠀⠀⠘⢿⣿⣿⣿⣿⣿⡿⠃⠀⠀⠀⠀⠀⠀⠀[/]
|
||
[#B7B7B7]⠀⠀⠀⠀⠀⠀⠀⠀⠙⠿⣿⠿⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
||
[#919191]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
||
[#656565]⠀⠀⠀⠀⠀⠀⠀⠀⠀⣰⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
||
[#656565]⠀⠀⠀⠀⠀⠀⠀⠀⣰⣿⣿⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
||
[#4A4A4A]⠀⠀⠀⠀⠀⠀⠀⣰⣿⣿⣿⣿⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
||
[#4A4A4A]⠀⠀⠀⠀⠀⣀⣴⣿⣿⣿⣿⣿⣿⣦⣀⠀⠀⠀⠀⠀⠀[/]
|
||
[#656565]⠀⠀⠀━━━━━━━━━━━━━━━━━━━━━━━⠀⠀⠀[/]
|
||
[dim #4A4A4A]⠀⠀⠀⠀⠀⠀⠀⠀⠀the boulder⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]""",
|
||
},
|
||
"charizard": {
|
||
"name": "charizard",
|
||
"description": "Volcanic theme — burnt orange and ember",
|
||
"colors": {
|
||
"banner_border": "#C75B1D",
|
||
"banner_title": "#FFD39A",
|
||
"banner_accent": "#F29C38",
|
||
"banner_dim": "#7A3511",
|
||
"banner_text": "#FFF0D4",
|
||
"ui_accent": "#F29C38",
|
||
"ui_label": "#FFD39A",
|
||
"ui_ok": "#4caf50",
|
||
"ui_error": "#ef5350",
|
||
"ui_warn": "#ffa726",
|
||
"prompt": "#FFF0D4",
|
||
"input_rule": "#C75B1D",
|
||
"response_border": "#F29C38",
|
||
"session_label": "#FFD39A",
|
||
"session_border": "#6C4724",
|
||
},
|
||
"spinner": {
|
||
"waiting_faces": ["(✦)", "(▲)", "(◇)", "(<>)", "(🔥)"],
|
||
"thinking_faces": ["(✦)", "(▲)", "(◇)", "(⌁)", "(🔥)"],
|
||
"thinking_verbs": [
|
||
"banking into the draft", "measuring burn", "reading the updraft",
|
||
"tracking ember fall", "setting wing angle", "holding the flame core",
|
||
"plotting a hot landing", "coiling for lift",
|
||
],
|
||
"wings": [
|
||
["⟪✦", "✦⟫"],
|
||
["⟪▲", "▲⟫"],
|
||
["⟪◌", "◌⟫"],
|
||
["⟪◇", "◇⟫"],
|
||
],
|
||
},
|
||
"branding": {
|
||
"agent_name": "Charizard Agent",
|
||
"welcome": "Welcome to Charizard Agent! Type your message or /help for commands.",
|
||
"goodbye": "Flame out! ✦",
|
||
"response_label": " ✦ Charizard ",
|
||
"prompt_symbol": "✦ ❯ ",
|
||
"help_header": "(✦) Available Commands",
|
||
},
|
||
"tool_prefix": "│",
|
||
"banner_logo": """[bold #FFF0D4] ██████╗██╗ ██╗ █████╗ ██████╗ ██╗███████╗ █████╗ ██████╗ ██████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗[/]
|
||
[bold #FFD39A]██╔════╝██║ ██║██╔══██╗██╔══██╗██║╚══███╔╝██╔══██╗██╔══██╗██╔══██╗ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝[/]
|
||
[#F29C38]██║ ███████║███████║██████╔╝██║ ███╔╝ ███████║██████╔╝██║ ██║█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║[/]
|
||
[#E2832B]██║ ██╔══██║██╔══██║██╔══██╗██║ ███╔╝ ██╔══██║██╔══██╗██║ ██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║[/]
|
||
[#C75B1D]╚██████╗██║ ██║██║ ██║██║ ██║██║███████╗██║ ██║██║ ██║██████╔╝ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║[/]
|
||
[#7A3511] ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝[/]""",
|
||
"banner_hero": """[#FFD39A]⠀⠀⠀⠀⠀⠀⠀⠀⣀⣤⠶⠶⠶⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
||
[#F29C38]⠀⠀⠀⠀⠀⠀⣴⠟⠁⠀⠀⠀⠀⠈⠻⣦⠀⠀⠀⠀⠀⠀[/]
|
||
[#F29C38]⠀⠀⠀⠀⠀⣼⠏⠀⠀⠀✦⠀⠀⠀⠀⠹⣧⠀⠀⠀⠀⠀[/]
|
||
[#E2832B]⠀⠀⠀⠀⢰⡟⠀⠀⣀⣤⣤⣤⣀⠀⠀⠀⢻⡆⠀⠀⠀⠀[/]
|
||
[#E2832B]⠀⠀⣠⡾⠛⠁⣠⣾⠟⠉⠀⠉⠻⣷⣄⠀⠈⠛⢷⣄⠀⠀[/]
|
||
[#C75B1D]⠀⣼⠟⠀⢀⣾⠟⠁⠀⠀⠀⠀⠀⠈⠻⣷⡀⠀⠻⣧⠀[/]
|
||
[#C75B1D]⢸⡟⠀⠀⣿⡟⠀⠀⠀🔥⠀⠀⠀⠀⢻⣿⠀⠀⢻⡇[/]
|
||
[#7A3511]⠀⠻⣦⡀⠘⢿⣧⡀⠀⠀⠀⠀⠀⢀⣼⡿⠃⢀⣴⠟⠀[/]
|
||
[#7A3511]⠀⠀⠈⠻⣦⣀⠙⢿⣷⣤⣤⣤⣾⡿⠋⣀⣴⠟⠁⠀⠀[/]
|
||
[#C75B1D]⠀⠀⠀⠀⠈⠙⠛⠶⠤⠭⠭⠤⠶⠛⠋⠁⠀⠀⠀⠀[/]
|
||
[#F29C38]⠀⠀⠀⠀⠀⠀⠀⠀⣰⡿⢿⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
||
[#F29C38]⠀⠀⠀⠀⠀⠀⠀⣼⡟⠀⠀⢻⣧⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
||
[dim #7A3511]⠀⠀⠀⠀⠀⠀⠀tail flame lit⠀⠀⠀⠀⠀⠀⠀⠀[/]""",
|
||
},
|
||
}
|
||
|
||
|
||
# =============================================================================
|
||
# 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", "┊")),
|
||
tool_emojis=data.get("tool_emojis", {}),
|
||
banner_logo=data.get("banner_logo", ""),
|
||
banner_hero=data.get("banner_hero", ""),
|
||
)
|
||
|
||
|
||
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")
|
||
|
||
|
||
# =============================================================================
|
||
# 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",
|
||
}
|