diff --git a/agent/display.py b/agent/display.py index ef7356d54..604b7a298 100644 --- a/agent/display.py +++ b/agent/display.py @@ -21,11 +21,73 @@ _RESET = "\033[0m" logger = logging.getLogger(__name__) _ANSI_RESET = "\033[0m" -_ANSI_DIM = "\033[38;2;150;150;150m" -_ANSI_FILE = "\033[38;2;180;160;255m" -_ANSI_HUNK = "\033[38;2;120;120;140m" -_ANSI_MINUS = "\033[38;2;255;255;255;48;2;120;20;20m" -_ANSI_PLUS = "\033[38;2;255;255;255;48;2;20;90;20m" + +# Diff colors — resolved lazily from the skin engine so they adapt +# to light/dark themes. Falls back to sensible defaults on import +# failure. We cache after first resolution for performance. +_diff_colors_cached: dict[str, str] | None = None + + +def _diff_ansi() -> dict[str, str]: + """Return ANSI escapes for diff display, resolved from the active skin.""" + global _diff_colors_cached + if _diff_colors_cached is not None: + return _diff_colors_cached + + # Defaults that work on dark terminals + dim = "\033[38;2;150;150;150m" + file_c = "\033[38;2;180;160;255m" + hunk = "\033[38;2;120;120;140m" + minus = "\033[38;2;255;255;255;48;2;120;20;20m" + plus = "\033[38;2;255;255;255;48;2;20;90;20m" + + try: + from hermes_cli.skin_engine import get_active_skin + skin = get_active_skin() + + def _hex_fg(key: str, fallback_rgb: tuple[int, int, int]) -> str: + h = skin.get_color(key, "") + if h and len(h) == 7 and h[0] == "#": + r, g, b = int(h[1:3], 16), int(h[3:5], 16), int(h[5:7], 16) + return f"\033[38;2;{r};{g};{b}m" + r, g, b = fallback_rgb + return f"\033[38;2;{r};{g};{b}m" + + dim = _hex_fg("banner_dim", (150, 150, 150)) + file_c = _hex_fg("session_label", (180, 160, 255)) + hunk = _hex_fg("session_border", (120, 120, 140)) + # minus/plus use background colors — derive from ui_error/ui_ok + err_h = skin.get_color("ui_error", "#ef5350") + ok_h = skin.get_color("ui_ok", "#4caf50") + if err_h and len(err_h) == 7: + er, eg, eb = int(err_h[1:3], 16), int(err_h[3:5], 16), int(err_h[5:7], 16) + # Use a dark tinted version as background + minus = f"\033[38;2;255;255;255;48;2;{max(er//2,20)};{max(eg//4,10)};{max(eb//4,10)}m" + if ok_h and len(ok_h) == 7: + or_, og, ob = int(ok_h[1:3], 16), int(ok_h[3:5], 16), int(ok_h[5:7], 16) + plus = f"\033[38;2;255;255;255;48;2;{max(or_//4,10)};{max(og//2,20)};{max(ob//4,10)}m" + except Exception: + pass + + _diff_colors_cached = { + "dim": dim, "file": file_c, "hunk": hunk, + "minus": minus, "plus": plus, + } + return _diff_colors_cached + + +def reset_diff_colors() -> None: + """Reset cached diff colors (call after /skin switch).""" + global _diff_colors_cached + _diff_colors_cached = None + + +# Module-level helpers — each call resolves from the active skin lazily. +def _diff_dim(): return _diff_ansi()["dim"] +def _diff_file(): return _diff_ansi()["file"] +def _diff_hunk(): return _diff_ansi()["hunk"] +def _diff_minus(): return _diff_ansi()["minus"] +def _diff_plus(): return _diff_ansi()["plus"] _MAX_INLINE_DIFF_FILES = 6 _MAX_INLINE_DIFF_LINES = 80 @@ -403,19 +465,19 @@ def _render_inline_unified_diff(diff: str) -> list[str]: if raw_line.startswith("+++ "): to_file = raw_line[4:].strip() if from_file or to_file: - rendered.append(f"{_ANSI_FILE}{from_file or 'a/?'} → {to_file or 'b/?'}{_ANSI_RESET}") + rendered.append(f"{_diff_file()}{from_file or 'a/?'} → {to_file or 'b/?'}{_ANSI_RESET}") continue if raw_line.startswith("@@"): - rendered.append(f"{_ANSI_HUNK}{raw_line}{_ANSI_RESET}") + rendered.append(f"{_diff_hunk()}{raw_line}{_ANSI_RESET}") continue if raw_line.startswith("-"): - rendered.append(f"{_ANSI_MINUS}{raw_line}{_ANSI_RESET}") + rendered.append(f"{_diff_minus()}{raw_line}{_ANSI_RESET}") continue if raw_line.startswith("+"): - rendered.append(f"{_ANSI_PLUS}{raw_line}{_ANSI_RESET}") + rendered.append(f"{_diff_plus()}{raw_line}{_ANSI_RESET}") continue if raw_line.startswith(" "): - rendered.append(f"{_ANSI_DIM}{raw_line}{_ANSI_RESET}") + rendered.append(f"{_diff_dim()}{raw_line}{_ANSI_RESET}") continue if raw_line: rendered.append(raw_line) @@ -481,7 +543,7 @@ def _summarize_rendered_diff_sections( summary = f"… omitted {omitted_lines} diff line(s)" if omitted_files: summary += f" across {omitted_files} additional file(s)/section(s)" - rendered.append(f"{_ANSI_HUNK}{summary}{_ANSI_RESET}") + rendered.append(f"{_diff_hunk()}{summary}{_ANSI_RESET}") return rendered diff --git a/cli.py b/cli.py index 223d36093..1e687f7b5 100644 --- a/cli.py +++ b/cli.py @@ -987,11 +987,60 @@ def _prune_orphaned_branches(repo_root: str) -> None: # - Dim: #B8860B (muted text) # ANSI building blocks for conversation display -_GOLD = "\033[1;38;2;255;215;0m" # True-color #FFD700 bold — matches Rich Panel gold +_ACCENT_ANSI_DEFAULT = "\033[1;38;2;255;215;0m" # True-color #FFD700 bold — fallback _BOLD = "\033[1m" _DIM = "\033[2m" _RST = "\033[0m" + +def _hex_to_ansi_bold(hex_color: str) -> str: + """Convert a hex color like '#268bd2' to a bold true-color ANSI escape.""" + try: + r = int(hex_color[1:3], 16) + g = int(hex_color[3:5], 16) + b = int(hex_color[5:7], 16) + return f"\033[1;38;2;{r};{g};{b}m" + except (ValueError, IndexError): + return _ACCENT_ANSI_DEFAULT + + +class _SkinAwareAnsi: + """Lazy ANSI escape that resolves from the skin engine on first use. + + Acts as a string in f-strings and concatenation. Call ``.reset()`` to + force re-resolution after a ``/skin`` switch. + """ + + def __init__(self, skin_key: str, fallback_hex: str = "#FFD700"): + self._skin_key = skin_key + self._fallback_hex = fallback_hex + self._cached: str | None = None + + def __str__(self) -> str: + if self._cached is None: + try: + from hermes_cli.skin_engine import get_active_skin + self._cached = _hex_to_ansi_bold( + get_active_skin().get_color(self._skin_key, self._fallback_hex) + ) + except Exception: + self._cached = _hex_to_ansi_bold(self._fallback_hex) + return self._cached + + def __add__(self, other: str) -> str: + return str(self) + other + + def __radd__(self, other: str) -> str: + return other + str(self) + + def reset(self) -> None: + """Clear cache so the next access re-reads the skin.""" + self._cached = None + + +_ACCENT = _SkinAwareAnsi("response_border", "#FFD700") + + def _accent_hex() -> str: """Return the active skin accent color for legacy CLI output lines.""" try: @@ -2466,7 +2515,7 @@ class HermesCLI: self._stream_text_ansi = "" w = shutil.get_terminal_size().columns fill = w - 2 - len(label) - _cprint(f"\n{_GOLD}╭─{label}{'─' * max(fill - 1, 0)}╮{_RST}") + _cprint(f"\n{_ACCENT}╭─{label}{'─' * max(fill - 1, 0)}╮{_RST}") self._stream_buf += text @@ -2497,7 +2546,7 @@ class HermesCLI: # Close the response box if self._stream_box_opened: w = shutil.get_terminal_size().columns - _cprint(f"{_GOLD}╰{'─' * (w - 2)}╯{_RST}") + _cprint(f"{_ACCENT}╰{'─' * (w - 2)}╯{_RST}") def _reset_stream_state(self) -> None: """Reset streaming state before each agent invocation.""" @@ -2920,15 +2969,17 @@ class HermesCLI: title_part = "" if session_meta.get("title"): title_part = f' "{session_meta["title"]}"' + accent_color = _accent_hex() self.console.print( - f"[#DAA520]↻ Resumed session [bold]{self.session_id}[/bold]" + f"[{accent_color}]↻ Resumed session [bold]{self.session_id}[/bold]" f"{title_part} " f"({msg_count} user message{'s' if msg_count != 1 else ''}, " f"{len(restored)} total messages)[/]" ) else: + accent_color = _accent_hex() self.console.print( - f"[#DAA520]Session {self.session_id} found but has no " + f"[{accent_color}]Session {self.session_id} found but has no " f"messages. Starting fresh.[/]" ) return False @@ -3397,18 +3448,26 @@ class HermesCLI: else: api_indicator = "[red bold]●[/]" - # Build status line with proper markup + # Build status line with proper markup — skin-aware colors + try: + from hermes_cli.skin_engine import get_active_skin + skin = get_active_skin() + separator_color = skin.get_color("banner_dim", "#B8860B") + accent_color = skin.get_color("ui_accent", "#FFBF00") + label_color = skin.get_color("ui_label", "#4dd0e1") + except Exception: + separator_color, accent_color, label_color = "#B8860B", "#FFBF00", "cyan" toolsets_info = "" if self.enabled_toolsets and "all" not in self.enabled_toolsets: - toolsets_info = f" [dim #B8860B]·[/] [#CD7F32]toolsets: {', '.join(self.enabled_toolsets)}[/]" + toolsets_info = f" [dim {separator_color}]·[/] [{label_color}]toolsets: {', '.join(self.enabled_toolsets)}[/]" - provider_info = f" [dim #B8860B]·[/] [dim]provider: {self.provider}[/]" + provider_info = f" [dim {separator_color}]·[/] [dim]provider: {self.provider}[/]" if self._provider_source: - provider_info += f" [dim #B8860B]·[/] [dim]auth: {self._provider_source}[/]" + provider_info += f" [dim {separator_color}]·[/] [dim]auth: {self._provider_source}[/]" self.console.print( - f" {api_indicator} [#FFBF00]{model_short}[/] " - f"[dim #B8860B]·[/] [bold cyan]{tool_count} tools[/]" + f" {api_indicator} [{accent_color}]{model_short}[/] " + f"[dim {separator_color}]·[/] [bold {label_color}]{tool_count} tools[/]" f"{toolsets_info}{provider_info}" ) @@ -3599,7 +3658,7 @@ class HermesCLI: # TUI event loop (known pitfall). verb = "Disabling" if subcommand == "disable" else "Enabling" label = ", ".join(names) - _cprint(f"{_GOLD}{verb} {label}...{_RST}") + _cprint(f"{_ACCENT}{verb} {label}...{_RST}") tools_disable_enable_command( Namespace(tools_action=subcommand, names=names, platform="cli")) @@ -5112,17 +5171,17 @@ class HermesCLI: if full_name == typed_base: # Already an exact token — no expansion possible; fall through _cprint(f"\033[1;31mUnknown command: {cmd_lower}{_RST}") - _cprint(f"{_DIM}{_GOLD}Type /help for available commands{_RST}") + _cprint(f"{_DIM}{_ACCENT}Type /help for available commands{_RST}") else: remainder = cmd_original.strip()[len(typed_base):] full_cmd = full_name + remainder return self.process_command(full_cmd) elif len(matches) > 1: - _cprint(f"{_GOLD}Ambiguous command: {cmd_lower}{_RST}") + _cprint(f"{_ACCENT}Ambiguous command: {cmd_lower}{_RST}") _cprint(f"{_DIM}Did you mean: {', '.join(sorted(matches))}?{_RST}") else: _cprint(f"\033[1;31mUnknown command: {cmd_lower}{_RST}") - _cprint(f"{_DIM}{_GOLD}Type /help for available commands{_RST}") + _cprint(f"{_DIM}{_ACCENT}Type /help for available commands{_RST}") return True @@ -5660,6 +5719,7 @@ class HermesCLI: return set_active_skin(new_skin) + _ACCENT.reset() # Re-resolve ANSI color for the new skin if save_config_value("display.skin", new_skin): print(f" Skin set to: {new_skin} (saved)") else: @@ -5728,8 +5788,8 @@ class HermesCLI: else: level = rc.get("effort", "medium") display_state = "on ✓" if self.show_reasoning else "off" - _cprint(f" {_GOLD}Reasoning effort: {level}{_RST}") - _cprint(f" {_GOLD}Reasoning display: {display_state}{_RST}") + _cprint(f" {_ACCENT}Reasoning effort: {level}{_RST}") + _cprint(f" {_ACCENT}Reasoning display: {display_state}{_RST}") _cprint(f" {_DIM}Usage: /reasoning {_RST}") return @@ -5741,7 +5801,7 @@ class HermesCLI: if self.agent: self.agent.reasoning_callback = self._current_reasoning_callback() save_config_value("display.show_reasoning", True) - _cprint(f" {_GOLD}✓ Reasoning display: ON (saved){_RST}") + _cprint(f" {_ACCENT}✓ Reasoning display: ON (saved){_RST}") _cprint(f" {_DIM} Model thinking will be shown during and after each response.{_RST}") return if arg in ("hide", "off"): @@ -5749,7 +5809,7 @@ class HermesCLI: if self.agent: self.agent.reasoning_callback = self._current_reasoning_callback() save_config_value("display.show_reasoning", False) - _cprint(f" {_GOLD}✓ Reasoning display: OFF (saved){_RST}") + _cprint(f" {_ACCENT}✓ Reasoning display: OFF (saved){_RST}") return # Effort level change @@ -5764,9 +5824,9 @@ class HermesCLI: self.agent = None # Force agent re-init with new reasoning config if save_config_value("agent.reasoning_effort", arg): - _cprint(f" {_GOLD}✓ Reasoning effort set to '{arg}' (saved to config){_RST}") + _cprint(f" {_ACCENT}✓ Reasoning effort set to '{arg}' (saved to config){_RST}") else: - _cprint(f" {_GOLD}✓ Reasoning effort set to '{arg}' (session only){_RST}") + _cprint(f" {_ACCENT}✓ Reasoning effort set to '{arg}' (session only){_RST}") def _handle_fast_command(self, cmd: str): """Handle /fast — toggle fast mode (OpenAI Priority Processing / Anthropic Fast Mode).""" @@ -5786,7 +5846,7 @@ class HermesCLI: parts = cmd.strip().split(maxsplit=1) if len(parts) < 2 or parts[1].strip().lower() == "status": status = "fast" if self.service_tier == "priority" else "normal" - _cprint(f" {_GOLD}{feature_name}: {status}{_RST}") + _cprint(f" {_ACCENT}{feature_name}: {status}{_RST}") _cprint(f" {_DIM}Usage: /fast [normal|fast|status]{_RST}") return @@ -5807,9 +5867,9 @@ class HermesCLI: self.agent = None # Force agent re-init with new service-tier config if save_config_value("agent.service_tier", saved_value): - _cprint(f" {_GOLD}✓ {feature_name} set to {label} (saved to config){_RST}") + _cprint(f" {_ACCENT}✓ {feature_name} set to {label} (saved to config){_RST}") else: - _cprint(f" {_GOLD}✓ {feature_name} set to {label} (session only){_RST}") + _cprint(f" {_ACCENT}✓ {feature_name} set to {label} (session only){_RST}") def _on_reasoning(self, reasoning_text: str): """Callback for intermediate reasoning display during tool-call loops.""" @@ -6309,7 +6369,7 @@ class HermesCLI: _recording_hint = "Termux:API capture | Ctrl+B to stop" else: _recording_hint = "Ctrl+B to stop" - _cprint(f"\n{_GOLD}● Recording...{_RST} {_DIM}({_recording_hint}){_RST}") + _cprint(f"\n{_ACCENT}● Recording...{_RST} {_DIM}({_recording_hint}){_RST}") # Periodically refresh prompt to update audio level indicator def _refresh_level(): @@ -6509,14 +6569,14 @@ class HermesCLI: # Environment detection -- warn and block in incompatible environments env_check = detect_audio_environment() if not env_check["available"]: - _cprint(f"\n{_GOLD}Voice mode unavailable in this environment:{_RST}") + _cprint(f"\n{_ACCENT}Voice mode unavailable in this environment:{_RST}") for warning in env_check["warnings"]: _cprint(f" {_DIM}{warning}{_RST}") return reqs = check_voice_requirements() if not reqs["available"]: - _cprint(f"\n{_GOLD}Voice mode requirements not met:{_RST}") + _cprint(f"\n{_ACCENT}Voice mode requirements not met:{_RST}") for line in reqs["details"].split("\n"): _cprint(f" {_DIM}{line}{_RST}") if reqs["missing_packages"]: @@ -6554,7 +6614,7 @@ class HermesCLI: except Exception: _ptt_key = "c-b" _ptt_display = _ptt_key.replace("c-", "Ctrl+").upper() - _cprint(f"\n{_GOLD}Voice mode enabled{tts_status}{_RST}") + _cprint(f"\n{_ACCENT}Voice mode enabled{tts_status}{_RST}") _cprint(f" {_DIM}{_ptt_display} to start/stop recording{_RST}") _cprint(f" {_DIM}/voice tts to toggle speech output{_RST}") _cprint(f" {_DIM}/voice off to disable voice mode{_RST}") @@ -6606,7 +6666,7 @@ class HermesCLI: if not check_tts_requirements(): _cprint(f"{_DIM}Warning: No TTS provider available. Install edge-tts or set API keys.{_RST}") - _cprint(f"{_GOLD}Voice TTS {status}.{_RST}") + _cprint(f"{_ACCENT}Voice TTS {status}.{_RST}") def _show_voice_status(self): """Show current voice mode status.""" @@ -7091,7 +7151,7 @@ class HermesCLI: w = self.console.width label = " ⚕ Hermes " fill = w - 2 - len(label) - _cprint(f"\n{_GOLD}╭─{label}{'─' * max(fill - 1, 0)}╮{_RST}") + _cprint(f"\n{_ACCENT}╭─{label}{'─' * max(fill - 1, 0)}╮{_RST}") _cprint(sentence.rstrip()) tts_thread = threading.Thread( @@ -7307,7 +7367,7 @@ class HermesCLI: if use_streaming_tts and _streaming_box_opened and not is_error_response: # Text was already printed sentence-by-sentence; just close the box w = shutil.get_terminal_size().columns - _cprint(f"\n{_GOLD}╰{'─' * (w - 2)}╯{_RST}") + _cprint(f"\n{_ACCENT}╰{'─' * (w - 2)}╯{_RST}") elif already_streamed: # Response was already streamed token-by-token with box framing; # _flush_stream() already closed the box. Skip Rich Panel.