From c881209b9289cf4077b8a6688412d0cada6464aa Mon Sep 17 00:00:00 2001 From: teknium1 Date: Tue, 17 Mar 2026 10:04:53 -0700 Subject: [PATCH] Revert "feat(cli): skin-aware light/dark theme mode with terminal auto-detection" This reverts commit a1c81360a57d3c7f6d677f31183cc2dfbfe66b13. --- cli.py | 1 - hermes_cli/colors.py | 121 ------------------ hermes_cli/config.py | 1 - hermes_cli/skin_engine.py | 179 +-------------------------- tests/hermes_cli/test_skin_engine.py | 66 ---------- 5 files changed, 6 insertions(+), 362 deletions(-) diff --git a/cli.py b/cli.py index cd62240c1..f17eeecfc 100755 --- a/cli.py +++ b/cli.py @@ -219,7 +219,6 @@ def load_cli_config() -> Dict[str, Any]: "streaming": 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 415db1591..d30f99c62 100644 --- a/hermes_cli/colors.py +++ b/hermes_cli/colors.py @@ -1,6 +1,5 @@ """Shared ANSI color utilities for Hermes CLI modules.""" -import os import sys @@ -21,123 +20,3 @@ 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 874ceb29e..8da98100b 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -236,7 +236,6 @@ 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 7ef0ad4c7..980ed8b1f 100644 --- a/hermes_cli/skin_engine.py +++ b/hermes_cli/skin_engine.py @@ -114,7 +114,6 @@ 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 = "┊" @@ -123,12 +122,7 @@ 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. - - 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] + """Get a color value with fallback.""" return self.colors.get(key, fallback) def get_spinner_list(self, key: str) -> List[str]: @@ -174,21 +168,6 @@ _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 }, @@ -222,21 +201,6 @@ _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": ["(⚔)", "(⛨)", "(▲)", "(⌁)", "(<>)"], @@ -301,22 +265,6 @@ _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", @@ -348,21 +296,6 @@ _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", @@ -394,21 +327,6 @@ _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": ["(Ψ)", "(∿)", "(≈)", "(⌁)", "(◌)"], @@ -473,23 +391,6 @@ _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": ["(◉)", "(◬)", "(◌)", "(○)", "(●)"], @@ -555,21 +456,6 @@ _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": ["(✦)", "(▲)", "(◇)", "(⌁)", "(🔥)"], @@ -623,8 +509,6 @@ _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: @@ -652,8 +536,6 @@ 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", {})) @@ -663,7 +545,6 @@ 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", "┊")), @@ -744,39 +625,6 @@ 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. @@ -789,13 +637,6 @@ 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 @@ -849,14 +690,6 @@ 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", @@ -865,11 +698,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"{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}", + "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", diff --git a/tests/hermes_cli/test_skin_engine.py b/tests/hermes_cli/test_skin_engine.py index 7732007df..6a5a032f1 100644 --- a/tests/hermes_cli/test_skin_engine.py +++ b/tests/hermes_cli/test_skin_engine.py @@ -13,13 +13,9 @@ 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: @@ -316,65 +312,3 @@ 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"