diff --git a/cli.py b/cli.py index fb44790832..febe327899 100755 --- a/cli.py +++ b/cli.py @@ -214,6 +214,7 @@ def load_cli_config() -> Dict[str, Any]: "streaming": False, "show_cost": False, "skin": "default", + "theme_mode": "auto", }, "clarify": { "timeout": 120, # Seconds to wait for a clarify answer before auto-proceeding diff --git a/hermes_cli/colors.py b/hermes_cli/colors.py index d30f99c62d..415db15911 100644 --- a/hermes_cli/colors.py +++ b/hermes_cli/colors.py @@ -1,5 +1,6 @@ """Shared ANSI color utilities for Hermes CLI modules.""" +import os import sys @@ -20,3 +21,123 @@ def color(text: str, *codes) -> str: if not sys.stdout.isatty(): return text return "".join(codes) + text + Colors.RESET + + +# ============================================================================= +# Terminal background detection (light vs dark) +# ============================================================================= + + +def _detect_via_colorfgbg() -> str: + """Check the COLORFGBG environment variable. + + Some terminals (rxvt, xterm, iTerm2) set COLORFGBG to ``;`` + where bg >= 8 usually means a dark background. + Returns "light", "dark", or "unknown". + """ + val = os.environ.get("COLORFGBG", "") + if not val: + return "unknown" + parts = val.split(";") + try: + bg = int(parts[-1]) + except (ValueError, IndexError): + return "unknown" + # Standard terminal colors 0-6 are dark, 7+ are light. + # bg < 7 → dark background; bg >= 7 → light background. + if bg >= 7: + return "light" + return "dark" + + +def _detect_via_macos_appearance() -> str: + """Check macOS AppleInterfaceStyle via ``defaults read``. + + Returns "light", "dark", or "unknown". + """ + if sys.platform != "darwin": + return "unknown" + try: + import subprocess + result = subprocess.run( + ["defaults", "read", "-g", "AppleInterfaceStyle"], + capture_output=True, text=True, timeout=2, + ) + if result.returncode == 0 and "dark" in result.stdout.lower(): + return "dark" + # If the key doesn't exist, macOS is in light mode. + return "light" + except Exception: + return "unknown" + + +def _detect_via_osc11() -> str: + """Query the terminal background colour via the OSC 11 escape sequence. + + Writes ``\\e]11;?\\a`` and reads the response to determine luminance. + Only works when stdin/stdout are connected to a real TTY (not piped). + Returns "light", "dark", or "unknown". + """ + if sys.platform == "win32": + return "unknown" + if not (sys.stdin.isatty() and sys.stdout.isatty()): + return "unknown" + try: + import select + import termios + import tty + + fd = sys.stdin.fileno() + old_attrs = termios.tcgetattr(fd) + try: + tty.setraw(fd) + # Send OSC 11 query + sys.stdout.write("\x1b]11;?\x07") + sys.stdout.flush() + # Wait briefly for response + if not select.select([fd], [], [], 0.1)[0]: + return "unknown" + response = b"" + while select.select([fd], [], [], 0.05)[0]: + response += os.read(fd, 128) + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_attrs) + + # Parse response: \x1b]11;rgb:RRRR/GGGG/BBBB\x07 (or \x1b\\) + text = response.decode("latin-1", errors="replace") + if "rgb:" not in text: + return "unknown" + rgb_part = text.split("rgb:")[-1].split("\x07")[0].split("\x1b")[0] + channels = rgb_part.split("/") + if len(channels) < 3: + return "unknown" + # Each channel is 2 or 4 hex digits; normalise to 0-255 + vals = [] + for ch in channels[:3]: + ch = ch.strip() + if len(ch) <= 2: + vals.append(int(ch, 16)) + else: + vals.append(int(ch[:2], 16)) # take high byte + # Perceived luminance (ITU-R BT.601) + luminance = 0.299 * vals[0] + 0.587 * vals[1] + 0.114 * vals[2] + return "light" if luminance > 128 else "dark" + except Exception: + return "unknown" + + +def detect_terminal_background() -> str: + """Detect whether the terminal has a light or dark background. + + Tries three strategies in order: + 1. COLORFGBG environment variable + 2. macOS appearance setting + 3. OSC 11 escape sequence query + + Returns "light", "dark", or "unknown" if detection fails. + """ + for detector in (_detect_via_colorfgbg, _detect_via_macos_appearance, _detect_via_osc11): + result = detector() + if result != "unknown": + return result + return "unknown" diff --git a/hermes_cli/config.py b/hermes_cli/config.py index ae0d9cb75f..834b8a3fcb 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -233,6 +233,7 @@ DEFAULT_CONFIG = { "streaming": False, "show_cost": False, # Show $ cost in the status bar (off by default) "skin": "default", + "theme_mode": "auto", }, # Privacy settings diff --git a/hermes_cli/skin_engine.py b/hermes_cli/skin_engine.py index 980ed8b1fb..7ef0ad4c7e 100644 --- a/hermes_cli/skin_engine.py +++ b/hermes_cli/skin_engine.py @@ -114,6 +114,7 @@ class SkinConfig: name: str description: str = "" colors: Dict[str, str] = field(default_factory=dict) + colors_light: 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 = "┊" @@ -122,7 +123,12 @@ class SkinConfig: 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.""" + """Get a color value with fallback. + + In light theme mode, returns the light override if available. + """ + if get_theme_mode() == "light" and key in self.colors_light: + return self.colors_light[key] return self.colors.get(key, fallback) def get_spinner_list(self, key: str) -> List[str]: @@ -168,6 +174,21 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "session_label": "#DAA520", "session_border": "#8B8682", }, + "colors_light": { + "banner_border": "#7A5A00", + "banner_title": "#6B4C00", + "banner_accent": "#7A5500", + "banner_dim": "#8B7355", + "banner_text": "#3D2B00", + "prompt": "#3D2B00", + "ui_accent": "#7A5500", + "ui_label": "#01579B", + "ui_ok": "#1B5E20", + "input_rule": "#7A5A00", + "response_border": "#6B4C00", + "session_label": "#5C4300", + "session_border": "#8B7355", + }, "spinner": { # Empty = use hardcoded defaults in display.py }, @@ -201,6 +222,21 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "session_label": "#C7A96B", "session_border": "#6E584B", }, + "colors_light": { + "banner_border": "#6B1010", + "banner_title": "#5C4300", + "banner_accent": "#8B1A1A", + "banner_dim": "#5C4030", + "banner_text": "#3A1800", + "prompt": "#3A1800", + "ui_accent": "#8B1A1A", + "ui_label": "#5C4300", + "ui_ok": "#1B5E20", + "input_rule": "#6B1010", + "response_border": "#7A1515", + "session_label": "#5C4300", + "session_border": "#5C4A3A", + }, "spinner": { "waiting_faces": ["(⚔)", "(⛨)", "(▲)", "(<>)", "(/)"], "thinking_faces": ["(⚔)", "(⛨)", "(▲)", "(⌁)", "(<>)"], @@ -265,6 +301,22 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "session_label": "#888888", "session_border": "#555555", }, + "colors_light": { + "banner_border": "#333333", + "banner_title": "#222222", + "banner_accent": "#333333", + "banner_dim": "#555555", + "banner_text": "#333333", + "prompt": "#222222", + "ui_accent": "#333333", + "ui_label": "#444444", + "ui_ok": "#444444", + "ui_error": "#333333", + "input_rule": "#333333", + "response_border": "#444444", + "session_label": "#444444", + "session_border": "#666666", + }, "spinner": {}, "branding": { "agent_name": "Hermes Agent", @@ -296,6 +348,21 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "session_label": "#7eb8f6", "session_border": "#4b5563", }, + "colors_light": { + "banner_border": "#1A3A7A", + "banner_title": "#1A3570", + "banner_accent": "#1E4090", + "banner_dim": "#3B4555", + "banner_text": "#1A2A50", + "prompt": "#1A2A50", + "ui_accent": "#1A3570", + "ui_label": "#1E3A80", + "ui_ok": "#1B5E20", + "input_rule": "#1A3A7A", + "response_border": "#2A4FA0", + "session_label": "#1A3570", + "session_border": "#5A6070", + }, "spinner": {}, "branding": { "agent_name": "Hermes Agent", @@ -327,6 +394,21 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "session_label": "#A9DFFF", "session_border": "#496884", }, + "colors_light": { + "banner_border": "#0D3060", + "banner_title": "#0D3060", + "banner_accent": "#154080", + "banner_dim": "#2A4565", + "banner_text": "#0A2850", + "prompt": "#0A2850", + "ui_accent": "#0D3060", + "ui_label": "#0D3060", + "ui_ok": "#1B5E20", + "input_rule": "#0D3060", + "response_border": "#1A5090", + "session_label": "#0D3060", + "session_border": "#3A5575", + }, "spinner": { "waiting_faces": ["(≈)", "(Ψ)", "(∿)", "(◌)", "(◠)"], "thinking_faces": ["(Ψ)", "(∿)", "(≈)", "(⌁)", "(◌)"], @@ -391,6 +473,23 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "session_label": "#919191", "session_border": "#656565", }, + "colors_light": { + "banner_border": "#666666", + "banner_title": "#222222", + "banner_accent": "#333333", + "banner_dim": "#555555", + "banner_text": "#333333", + "prompt": "#222222", + "ui_accent": "#333333", + "ui_label": "#444444", + "ui_ok": "#444444", + "ui_error": "#333333", + "ui_warn": "#444444", + "input_rule": "#666666", + "response_border": "#555555", + "session_label": "#444444", + "session_border": "#777777", + }, "spinner": { "waiting_faces": ["(◉)", "(◌)", "(◬)", "(⬤)", "(::)"], "thinking_faces": ["(◉)", "(◬)", "(◌)", "(○)", "(●)"], @@ -456,6 +555,21 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "session_label": "#FFD39A", "session_border": "#6C4724", }, + "colors_light": { + "banner_border": "#7A3511", + "banner_title": "#5C2D00", + "banner_accent": "#8B4000", + "banner_dim": "#5A3A1A", + "banner_text": "#3A1E00", + "prompt": "#3A1E00", + "ui_accent": "#8B4000", + "ui_label": "#5C2D00", + "ui_ok": "#1B5E20", + "input_rule": "#7A3511", + "response_border": "#8B4513", + "session_label": "#5C2D00", + "session_border": "#6B5540", + }, "spinner": { "waiting_faces": ["(✦)", "(▲)", "(◇)", "(<>)", "(🔥)"], "thinking_faces": ["(✦)", "(▲)", "(◇)", "(⌁)", "(🔥)"], @@ -509,6 +623,8 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { _active_skin: Optional[SkinConfig] = None _active_skin_name: str = "default" +_theme_mode: str = "auto" +_resolved_theme_mode: Optional[str] = None def _skins_dir() -> Path: @@ -536,6 +652,8 @@ def _build_skin_config(data: Dict[str, Any]) -> SkinConfig: default = _BUILTIN_SKINS["default"] colors = dict(default.get("colors", {})) colors.update(data.get("colors", {})) + colors_light = dict(default.get("colors_light", {})) + colors_light.update(data.get("colors_light", {})) spinner = dict(default.get("spinner", {})) spinner.update(data.get("spinner", {})) branding = dict(default.get("branding", {})) @@ -545,6 +663,7 @@ def _build_skin_config(data: Dict[str, Any]) -> SkinConfig: name=data.get("name", "unknown"), description=data.get("description", ""), colors=colors, + colors_light=colors_light, spinner=spinner, branding=branding, tool_prefix=data.get("tool_prefix", default.get("tool_prefix", "┊")), @@ -625,6 +744,39 @@ def get_active_skin_name() -> str: return _active_skin_name +def get_theme_mode() -> str: + """Return the resolved theme mode: "light" or "dark". + + When ``_theme_mode`` is ``"auto"``, detection is attempted once and cached. + If detection returns ``"unknown"``, defaults to ``"dark"``. + """ + global _resolved_theme_mode + if _theme_mode in ("light", "dark"): + return _theme_mode + # Auto mode — detect and cache + if _resolved_theme_mode is None: + try: + from hermes_cli.colors import detect_terminal_background + detected = detect_terminal_background() + except Exception: + detected = "unknown" + _resolved_theme_mode = detected if detected in ("light", "dark") else "dark" + return _resolved_theme_mode + + +def set_theme_mode(mode: str) -> None: + """Set the theme mode to "light", "dark", or "auto".""" + global _theme_mode, _resolved_theme_mode + _theme_mode = mode + # Reset cached detection so it re-runs on next get_theme_mode() if auto + _resolved_theme_mode = None + + +def get_theme_mode_setting() -> str: + """Return the raw theme mode setting (may be "auto", "light", or "dark").""" + return _theme_mode + + def init_skin_from_config(config: dict) -> None: """Initialize the active skin from CLI config at startup. @@ -637,6 +789,13 @@ def init_skin_from_config(config: dict) -> None: else: set_active_skin("default") + # Theme mode + theme_mode = display.get("theme_mode", "auto") + if isinstance(theme_mode, str) and theme_mode.strip(): + set_theme_mode(theme_mode.strip()) + else: + set_theme_mode("auto") + # ============================================================================= # Convenience helpers for CLI modules @@ -690,6 +849,14 @@ def get_prompt_toolkit_style_overrides() -> Dict[str, str]: warn = skin.get_color("ui_warn", "#FF8C00") error = skin.get_color("ui_error", "#FF6B6B") + # Use lighter background colours for completion menus in light mode + if get_theme_mode() == "light": + menu_bg = "bg:#e8e8e8" + menu_sel_bg = "bg:#d0d0d0" + else: + menu_bg = "bg:#1a1a2e" + menu_sel_bg = "bg:#333355" + return { "input-area": prompt, "placeholder": f"{dim} italic", @@ -698,11 +865,11 @@ def get_prompt_toolkit_style_overrides() -> Dict[str, str]: "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}", + "completion-menu": f"{menu_bg} {text}", + "completion-menu.completion": f"{menu_bg} {text}", + "completion-menu.completion.current": f"{menu_sel_bg} {title}", + "completion-menu.meta.completion": f"{menu_bg} {dim}", + "completion-menu.meta.completion.current": f"{menu_sel_bg} {label}", "clarify-border": input_rule, "clarify-title": f"{title} bold", "clarify-question": f"{text} bold", diff --git a/tests/hermes_cli/test_skin_engine.py b/tests/hermes_cli/test_skin_engine.py index 6a5a032f1c..7732007df2 100644 --- a/tests/hermes_cli/test_skin_engine.py +++ b/tests/hermes_cli/test_skin_engine.py @@ -13,9 +13,13 @@ def reset_skin_state(): from hermes_cli import skin_engine skin_engine._active_skin = None skin_engine._active_skin_name = "default" + skin_engine._theme_mode = "auto" + skin_engine._resolved_theme_mode = None yield skin_engine._active_skin = None skin_engine._active_skin_name = "default" + skin_engine._theme_mode = "auto" + skin_engine._resolved_theme_mode = None class TestSkinConfig: @@ -312,3 +316,65 @@ class TestCliBrandingHelpers: assert overrides["clarify-title"] == f"{skin.get_color('banner_title')} bold" assert overrides["sudo-prompt"] == f"{skin.get_color('ui_error')} bold" assert overrides["approval-title"] == f"{skin.get_color('ui_warn')} bold" + + +class TestThemeMode: + def test_get_theme_mode_defaults_to_dark_on_unknown(self): + from hermes_cli.skin_engine import get_theme_mode, set_theme_mode + + set_theme_mode("auto") + # In a test env, detection returns "unknown" → defaults to "dark" + with patch("hermes_cli.colors.detect_terminal_background", return_value="unknown"): + from hermes_cli import skin_engine + skin_engine._resolved_theme_mode = None # force re-detection + assert get_theme_mode() == "dark" + + def test_set_theme_mode_light(self): + from hermes_cli.skin_engine import get_theme_mode, set_theme_mode + + set_theme_mode("light") + assert get_theme_mode() == "light" + + def test_set_theme_mode_dark(self): + from hermes_cli.skin_engine import get_theme_mode, set_theme_mode + + set_theme_mode("dark") + assert get_theme_mode() == "dark" + + def test_get_color_respects_light_mode(self): + from hermes_cli.skin_engine import SkinConfig, set_theme_mode + + skin = SkinConfig( + name="test", + colors={"banner_title": "#FFD700", "prompt": "#FFF8DC"}, + colors_light={"banner_title": "#6B4C00"}, + ) + set_theme_mode("light") + assert skin.get_color("banner_title") == "#6B4C00" + # Key not in colors_light falls back to colors + assert skin.get_color("prompt") == "#FFF8DC" + + def test_get_color_falls_back_in_dark_mode(self): + from hermes_cli.skin_engine import SkinConfig, set_theme_mode + + skin = SkinConfig( + name="test", + colors={"banner_title": "#FFD700", "prompt": "#FFF8DC"}, + colors_light={"banner_title": "#6B4C00"}, + ) + set_theme_mode("dark") + assert skin.get_color("banner_title") == "#FFD700" + assert skin.get_color("prompt") == "#FFF8DC" + + def test_init_skin_from_config_reads_theme_mode(self): + from hermes_cli.skin_engine import init_skin_from_config, get_theme_mode_setting + + init_skin_from_config({"display": {"skin": "default", "theme_mode": "light"}}) + assert get_theme_mode_setting() == "light" + + def test_builtin_skins_have_colors_light(self): + from hermes_cli.skin_engine import _BUILTIN_SKINS, _build_skin_config + + for name, data in _BUILTIN_SKINS.items(): + skin = _build_skin_config(data) + assert len(skin.colors_light) > 0, f"Skin '{name}' has empty colors_light"