diff --git a/cli.py b/cli.py index 53bf7bf5dead..138c603e0f70 100644 --- a/cli.py +++ b/cli.py @@ -420,6 +420,7 @@ def load_cli_config() -> Dict[str, Any]: "busy_input_mode": "interrupt", "persistent_output": True, "persistent_output_max_lines": 200, + "status_bar_style": "default", "skin": "default", }, @@ -2855,6 +2856,13 @@ def __init__( self.bell_on_complete = CLI_CONFIG["display"].get("bell_on_complete", False) # show_reasoning: display model thinking/reasoning before the response self.show_reasoning = CLI_CONFIG["display"].get("show_reasoning", False) + self._status_bar_style = str( + CLI_CONFIG["display"].get("status_bar_style", "default") or "default" + ).strip().lower() + self._status_bar_codex_usage_cache: dict[str, Any] = {"at": 0.0, "value": ""} + self._status_bar_token_rollup_cache: dict[str, Any] = {"at": 0.0, "tokens": None, "value": ""} + self._status_bar_token_log_last_at = 0.0 + self._status_bar_token_log_last_tokens = -1 _configure_output_history( enabled=CLI_CONFIG["display"].get("persistent_output", True), max_lines=CLI_CONFIG["display"].get("persistent_output_max_lines", 200), @@ -3501,6 +3509,211 @@ def _trim_status_bar_text(cls, text: str, max_width: int) -> str: width += ch_width return "".join(out).rstrip() + ellipsis + @staticmethod + def _lifeos_status_bar_style_enabled(style: object) -> bool: + return str(style or "default").strip().lower() in {"lifeos", "anirvan", "claude"} + + @staticmethod + def _valid_lifeos_codex_usage_label(value: str) -> bool: + """Return True for compact Codex usage-window labels. + + The source is a local helper script, but this runs on the prompt redraw + path. Treat unexpected output as absent instead of splashing tracebacks + or warnings into the status bar. + """ + return bool( + re.fullmatch( + r"\d+[dhm]:\d+%/\d+%(?:\s*\|\s*\d+[dhm]:\d+%/\d+%)*", + value.strip(), + ) + ) + + def _use_lifeos_status_bar(self) -> bool: + return self._lifeos_status_bar_style_enabled(getattr(self, "_status_bar_style", "default")) + + @staticmethod + def _status_bar_abbrev_cwd(cwd: object = None) -> str: + """Abbreviate a working directory for a compact status bar.""" + try: + path = Path(str(cwd or os.getcwd())).expanduser() + if not path.is_absolute(): + path = Path(os.getcwd()) / path + path = path.resolve(strict=False) + home = Path.home().resolve(strict=False) + try: + rel = path.relative_to(home) + rel_text = str(rel) + return "~" if rel_text == "." else f"~/{rel_text}" + except ValueError: + return str(path) + except Exception: + return "" + + def _status_bar_current_cwd(self) -> str: + cfg = getattr(self, "config", {}) or {} + cwd = None + try: + term_cfg = cfg.get("terminal", {}) if isinstance(cfg, dict) else {} + cwd = term_cfg.get("cwd") if isinstance(term_cfg, dict) else None + except Exception: + cwd = None + return self._status_bar_abbrev_cwd(cwd) + + def _get_lifeos_codex_usage_label(self, ttl_seconds: float = 30.0) -> str: + """Return cached Codex-window usage text for the LifeOS status bar.""" + now = time.monotonic() + cache = getattr(self, "_status_bar_codex_usage_cache", None) + if isinstance(cache, dict) and now - float(cache.get("at") or 0.0) < ttl_seconds: + return str(cache.get("value") or "") + + value = "" + script = Path.home() / "bin" / "codex-usage-status" + if script.exists(): + try: + import subprocess + result = subprocess.run( + [str(script), "--cache-only"], + capture_output=True, + text=True, + timeout=0.75, + check=False, + ) + if result.returncode == 0: + first_line = (result.stdout or "").splitlines()[0:1] + if first_line: + # Strip ANSI codes defensively; the status bar supplies + # its own prompt_toolkit styles. + label = re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", first_line[0]).strip() + if self._valid_lifeos_codex_usage_label(label): + value = label + except Exception: + value = "" + + setattr(self, "_status_bar_codex_usage_cache", {"at": now, "value": value}) + return value + + @staticmethod + def _format_lifeos_token_count(value: int) -> str: + try: + return format_token_count_compact(int(value or 0)) + except Exception: + return "0" + + def _get_lifeos_token_rollup_label(self, snapshot: Dict[str, Any], ttl_seconds: float = 30.0) -> str: + """Best-effort session/day/week/month token rollup for the status bar.""" + try: + session_total = int(snapshot.get("session_total_tokens") or 0) + except Exception: + session_total = 0 + if session_total <= 0: + return "" + + now = time.time() + cache = getattr(self, "_status_bar_token_rollup_cache", None) + if ( + isinstance(cache, dict) + and cache.get("tokens") == session_total + and now - float(cache.get("at") or 0.0) < ttl_seconds + ): + return str(cache.get("value") or "") + + label = f"tok:{self._format_lifeos_token_count(session_total)}(s)" + try: + cache_dir = _hermes_home / "cache" + path = cache_dir / "statusbar-token-usage.tsv" + session_id = str(getattr(self, "session_id", "") or "unknown") + cutoff_month = now - (31 * 86400) + entries: list[tuple[float, str, int]] = [] + + if path.exists(): + for line in path.read_text(encoding="utf-8").splitlines(): + try: + ts_text, sid, tok_text = line.split("\t", 2) + ts = float(ts_text) + tok = int(tok_text) + except Exception: + continue + if ts >= cutoff_month and tok > 0: + entries.append((ts, sid, tok)) + + last_at = float(getattr(self, "_status_bar_token_log_last_at", 0.0) or 0.0) + last_tokens = int(getattr(self, "_status_bar_token_log_last_tokens", -1) or -1) + if session_total != last_tokens or now - last_at >= 1800: + entries.append((now, session_id, session_total)) + try: + cache_dir.mkdir(parents=True, exist_ok=True) + tmp_path = path.with_suffix(".tmp") + tmp_path.write_text( + "".join(f"{ts:.0f}\t{sid}\t{tok}\n" for ts, sid, tok in entries), + encoding="utf-8", + ) + tmp_path.replace(path) + self._status_bar_token_log_last_at = now + self._status_bar_token_log_last_tokens = session_total + except Exception: + pass + + def _window_total(days: int) -> int: + cutoff = now - (days * 86400) + latest_by_session: dict[str, int] = {} + for ts, sid, tok in entries: + if ts >= cutoff: + latest_by_session[sid] = max(latest_by_session.get(sid, 0), tok) + latest_by_session[session_id] = max(latest_by_session.get(session_id, 0), session_total) + return sum(latest_by_session.values()) + + day = _window_total(1) + week = _window_total(7) + month = _window_total(30) + label = ( + f"tok:{self._format_lifeos_token_count(session_total)}(s)/" + f"{self._format_lifeos_token_count(day)}(d)/" + f"{self._format_lifeos_token_count(week)}(w)/" + f"{self._format_lifeos_token_count(month)}(m)" + ) + except Exception: + pass + + setattr(self, "_status_bar_token_rollup_cache", {"at": now, "tokens": session_total, "value": label}) + return label + + def _build_lifeos_status_bar_text(self, snapshot: Dict[str, Any], width: int) -> str: + """Build Anirvan's preferred Claude/Codex-style status line.""" + percent = snapshot.get("context_percent") + ctx_label = "ctx:--" + if percent is not None: + try: + remaining = max(0, min(100, 100 - int(percent))) + ctx_label = f"ctx:{remaining}%" + except Exception: + ctx_label = "ctx:--" + + cwd_label = self._status_bar_current_cwd() + model_label = str(snapshot.get("model_short") or "Hermes") + token_label = self._get_lifeos_token_rollup_label(snapshot) + codex_label = self._get_lifeos_codex_usage_label() + + parts: list[str] = [] + if width >= 76 and codex_label: + parts.append(f"cdx:{codex_label}") + if width >= 64 and token_label: + parts.append(token_label) + parts.append(ctx_label) + + bg_count = snapshot.get("active_background_tasks", 0) or 0 + if bg_count: + parts.append(f"▶{bg_count}") + compressions = snapshot.get("compressions", 0) or 0 + if compressions and width >= 76: + parts.append(f"zip:{compressions}") + if bool(os.getenv("HERMES_YOLO_MODE")): + parts.append("YOLO") + + parts.append(model_label) + if cwd_label: + parts.append(cwd_label) + return self._trim_status_bar_text(" | ".join(parts), width) + @staticmethod def _get_tui_terminal_width(default: tuple[int, int] = (80, 24)) -> int: """Return the live prompt_toolkit width, falling back to ``shutil``. @@ -3654,6 +3867,8 @@ def _build_status_bar_text(self, width: Optional[int] = None) -> str: snapshot = self._get_status_bar_snapshot() if width is None: width = self._get_tui_terminal_width() + if self._use_lifeos_status_bar(): + return self._build_lifeos_status_bar_text(snapshot, width) percent = snapshot["context_percent"] percent_label = f"{percent}%" if percent is not None else "--" duration_label = snapshot["duration"] @@ -3712,6 +3927,9 @@ def _get_status_bar_fragments(self): # actually renders, causing the fragments to overflow to a second # line and produce duplicated status bar rows over long sessions. width = self._get_tui_terminal_width() + if self._use_lifeos_status_bar(): + text = self._build_lifeos_status_bar_text(snapshot, max(0, width - 2)) + return [("class:status-bar", self._trim_status_bar_text(f" {text} ", width))] duration_label = snapshot["duration"] yolo_active = bool(os.getenv("HERMES_YOLO_MODE")) @@ -8054,7 +8272,44 @@ def _show_gateway_status(self): print(" DISCORD_BOT_TOKEN=your_token") print(f" 2. Or configure settings in {display_hermes_home()}/config.yaml") print() - + + def _handle_statusbar_command(self, cmd_original: str) -> None: + """Handle /statusbar visibility toggles and style selection.""" + usage = " Usage: /statusbar [on|off|toggle|default|lifeos|anirvan|claude]" + parts = cmd_original.split(None, 1) + arg = parts[1].strip().lower() if len(parts) > 1 else "toggle" + + if arg in {"", "toggle"}: + self._status_bar_visible = not self._status_bar_visible + state = "visible" if self._status_bar_visible else "hidden" + self._console_print(f" Status bar {state}") + return + + if arg == "on": + self._status_bar_visible = True + self._console_print(" Status bar visible") + return + + if arg == "off": + self._status_bar_visible = False + self._console_print(" Status bar hidden") + return + + if arg in {"default", "lifeos", "anirvan", "claude"}: + if not save_config_value("display.status_bar_style", arg): + self._console_print(" Failed to save status bar style") + return + self._status_bar_style = arg + cfg = getattr(self, "config", None) + if isinstance(cfg, dict): + display_cfg = cfg.setdefault("display", {}) + if isinstance(display_cfg, dict): + display_cfg["status_bar_style"] = arg + self._console_print(f" Status bar style set to {arg}") + return + + self._console_print(usage) + def process_command(self, command: str) -> bool: """ Process a slash command. @@ -8286,9 +8541,7 @@ def process_command(self, command: str) -> bool: elif canonical == "status": self._show_session_status() elif canonical == "statusbar": - self._status_bar_visible = not self._status_bar_visible - state = "visible" if self._status_bar_visible else "hidden" - self._console_print(f" Status bar {state}") + self._handle_statusbar_command(cmd_original) elif canonical == "verbose": self._toggle_verbose() elif canonical == "footer": diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index f589248621c5..cf6606aa2b8b 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -130,8 +130,10 @@ class CommandDef: CommandDef("personality", "Set a predefined personality", "Configuration", args_hint="[name]"), - CommandDef("statusbar", "Toggle the context/model status bar", "Configuration", - cli_only=True, aliases=("sb",)), + CommandDef("statusbar", "Toggle the context/model status bar or set its style", "Configuration", + cli_only=True, aliases=("sb",), + args_hint="[on|off|toggle|default|lifeos|anirvan|claude]", + subcommands=("on", "off", "toggle", "default", "lifeos", "anirvan", "claude")), CommandDef("verbose", "Cycle tool progress display: off -> new -> all -> verbose", "Configuration", cli_only=True, gateway_config_gate="display.tool_progress_command"), diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 715fd7eb76ff..cc064d863c42 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1019,6 +1019,7 @@ def _ensure_hermes_home_managed(home: Path): "streaming": False, "timestamps": False, # Show [HH:MM] on user and assistant labels "final_response_markdown": "strip", # render | strip | raw + "status_bar_style": "default", # default | lifeos/anirvan/claude # Preserve recent classic CLI output across Ctrl+L, /redraw, and # terminal resize full-screen clears. Disable if a terminal emulator # behaves badly with replayed scrollback. diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 23cb8e685fd7..5daa907755f6 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -704,22 +704,62 @@ def _run_cua_driver_installer(label: str = "Installing", verbose: bool = True) - The script is idempotent: it always downloads the latest release, so re-running it on an already-installed system performs an upgrade. """ + import pathlib import shutil import subprocess - install_cmd = ( - "/bin/bash -c \"$(curl -fsSL " + install_url = ( "https://raw.githubusercontent.com/trycua/cua/main/" - "libs/cua-driver/scripts/install.sh)\"" + "libs/cua-driver/scripts/install.sh" ) if verbose: _print_info(f" {label} cua-driver (macOS background computer-use)...") else: _print_info(f" {label} cua-driver...") + + curl_bin = shutil.which("curl") + bash_bin = shutil.which("bash") + if not bash_bin and pathlib.Path("/bin/bash").exists(): + bash_bin = "/bin/bash" + + if not curl_bin: + _print_warning(" curl not found — install manually:") + manual_shell = bash_bin or shutil.which("sh") + if manual_shell: + _print_info(f" curl -fsSL {install_url} | {manual_shell}") + else: + _print_info(" Download the installer script and run it with a local shell.") + _print_info(f" {install_url}") + return False + + if not bash_bin: + _print_warning(" bash not found — cannot run the cua-driver installer.") + _print_info(" Install manually with an external shell:") + _print_info(f" curl -fsSL {install_url} | bash") + return False + driver_cmd = _cua_driver_cmd() try: - result = subprocess.run(install_cmd, shell=True, timeout=300) - if result.returncode == 0 and shutil.which(driver_cmd): + curl_result = subprocess.run( + [curl_bin, "-fsSL", install_url], + capture_output=True, + text=True, + timeout=60, + check=False, + ) + if curl_result.returncode != 0 or not curl_result.stdout: + _print_warning(f" cua-driver {label.lower()} did not complete. Re-run manually:") + _print_info(f" curl -fsSL {install_url} | {bash_bin}") + return False + + bash_result = subprocess.run( + [bash_bin], + input=curl_result.stdout, + text=True, + timeout=300, + check=False, + ) + if bash_result.returncode == 0 and shutil.which(driver_cmd): if verbose: _print_success(f" {driver_cmd} installed.") _print_info(" IMPORTANT — grant macOS permissions now:") @@ -728,7 +768,7 @@ def _run_cua_driver_installer(label: str = "Installing", verbose: bool = True) - _print_info(" Both must allow the terminal / Hermes process.") return True _print_warning(f" cua-driver {label.lower()} did not complete. Re-run manually:") - _print_info(f" {install_cmd}") + _print_info(f" curl -fsSL {install_url} | {bash_bin}") return False except subprocess.TimeoutExpired: _print_warning(f" cua-driver {label.lower()} timed out. Re-run manually.") diff --git a/tests/cli/test_cli_status_bar.py b/tests/cli/test_cli_status_bar.py index 47bd68aa25d1..7cba1e088684 100644 --- a/tests/cli/test_cli_status_bar.py +++ b/tests/cli/test_cli_status_bar.py @@ -1,5 +1,6 @@ import time from datetime import datetime, timedelta +from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock, patch @@ -52,6 +53,14 @@ def _attach_agent( return cli_obj +def _make_statusbar_command_cli(*, visible: bool = True, style: str = "default"): + cli_obj = _make_cli() + cli_obj._status_bar_visible = visible + cli_obj._status_bar_style = style + cli_obj.config = {"display": {"status_bar_style": style}} + return cli_obj + + class TestCLIStatusBar: def test_context_style_thresholds(self): cli_obj = _make_cli() @@ -81,6 +90,138 @@ def test_build_status_bar_text_for_wide_terminal(self): assert "$0.06" not in text # cost hidden by default assert "15m" in text + def test_lifeos_status_bar_includes_codex_tokens_context_model_and_cwd(self): + cli_obj = _attach_agent( + _make_cli(model="openai-codex/gpt-5.5"), + prompt_tokens=10_230, + completion_tokens=2_220, + total_tokens=12_450, + api_calls=7, + context_tokens=50_000, + context_length=200_000, + ) + cli_obj._status_bar_style = "lifeos" + cli_obj.config = {"terminal": {"cwd": str(Path.home() / "Builds" / "hermes-agent")}} + + with patch.object(cli_obj, "_get_lifeos_codex_usage_label", return_value="7d:95%/100% | 5h:68%/99%"), \ + patch.object(cli_obj, "_get_lifeos_token_rollup_label", return_value="tok:12.4K(s)/20K(d)/30K(w)/40K(m)"): + text = cli_obj._build_status_bar_text(width=160) + + assert "cdx:7d:95%/100% | 5h:68%/99%" in text + assert "tok:12.4K(s)/20K(d)/30K(w)/40K(m)" in text + assert "ctx:75%" in text # remaining context, not used context + assert "gpt-5.5" in text + assert "~/Builds/hermes-agent" in text + + def test_lifeos_status_bar_omits_expensive_sections_on_narrow_width(self): + cli_obj = _attach_agent( + _make_cli(model="openai-codex/gpt-5.5"), + prompt_tokens=100, + completion_tokens=25, + total_tokens=125, + api_calls=1, + context_tokens=10, + context_length=100, + ) + cli_obj._status_bar_style = "lifeos" + + with patch.object(cli_obj, "_get_lifeos_codex_usage_label", return_value="7d:95%/100% | 5h:68%/99%"), \ + patch.object(cli_obj, "_get_lifeos_token_rollup_label", return_value="tok:125(s)/125(d)/125(w)/125(m)"): + text = cli_obj._build_status_bar_text(width=52) + + assert "cdx:" not in text + assert "tok:" not in text + assert "ctx:90%" in text + assert cli_obj._status_bar_display_width(text) <= 52 + + def test_lifeos_codex_usage_status_is_cached(self): + cli_obj = _make_cli() + cli_obj._status_bar_codex_usage_cache = {"at": 0.0, "value": ""} + result = SimpleNamespace(returncode=0, stdout="\x1b[32m7d:1%/100% | 5h:2%/99%\x1b[0m\n") + + with patch.object(Path, "exists", return_value=True), \ + patch("subprocess.run", return_value=result) as run_mock: + assert cli_obj._get_lifeos_codex_usage_label() == "7d:1%/100% | 5h:2%/99%" + assert cli_obj._get_lifeos_codex_usage_label() == "7d:1%/100% | 5h:2%/99%" + + assert run_mock.call_count == 1 + + def test_lifeos_token_rollup_uses_hermes_cache_file(self, tmp_path, monkeypatch): + monkeypatch.setattr("cli._hermes_home", tmp_path) + cli_obj = _make_cli() + cli_obj.session_id = "current-session" + snapshot = {"session_total_tokens": 1_250} + + label = cli_obj._get_lifeos_token_rollup_label(snapshot) + + assert label == "tok:1.25K(s)/1.25K(d)/1.25K(w)/1.25K(m)" + assert (tmp_path / "cache" / "statusbar-token-usage.tsv").exists() + + def test_lifeos_token_rollup_skips_malformed_cache_rows(self, tmp_path, monkeypatch): + monkeypatch.setattr("cli._hermes_home", tmp_path) + cache_dir = tmp_path / "cache" + cache_dir.mkdir() + now = time.time() + (cache_dir / "statusbar-token-usage.tsv").write_text( + "not-a-tsv-row\n" + f"{now}\tother-session\t250\n" + f"{now}\tbad-session\tnot-an-int\n" + f"{now}\tnegative-session\t-10\n", + encoding="utf-8", + ) + cli_obj = _make_cli() + cli_obj.session_id = "current-session" + + label = cli_obj._get_lifeos_token_rollup_label({"session_total_tokens": 100}) + + assert label == "tok:100(s)/350(d)/350(w)/350(m)" + + def test_lifeos_codex_usage_rejects_malformed_output(self): + cli_obj = _make_cli() + cli_obj._status_bar_codex_usage_cache = {"at": 0.0, "value": ""} + result = SimpleNamespace(returncode=0, stdout="\x1b[31munexpected failure output\x1b[0m\n") + + with patch.object(Path, "exists", return_value=True), \ + patch("subprocess.run", return_value=result): + assert cli_obj._get_lifeos_codex_usage_label() == "" + + def test_statusbar_command_sets_visibility_without_persisting(self): + cli_obj = _make_statusbar_command_cli(visible=False) + with patch("cli.save_config_value") as save_mock, \ + patch.object(cli_obj, "_console_print"): + cli_obj._handle_statusbar_command("/statusbar on") + assert cli_obj._status_bar_visible is True + cli_obj._handle_statusbar_command("/statusbar off") + assert cli_obj._status_bar_visible is False + cli_obj._handle_statusbar_command("/statusbar toggle") + assert cli_obj._status_bar_visible is True + + save_mock.assert_not_called() + + def test_statusbar_command_sets_style_and_updates_session_config(self): + cli_obj = _make_statusbar_command_cli(style="default") + + with patch("cli.save_config_value", return_value=True) as save_mock, \ + patch.object(cli_obj, "_console_print"): + cli_obj._handle_statusbar_command("/statusbar claude") + + save_mock.assert_called_once_with("display.status_bar_style", "claude") + assert cli_obj._status_bar_style == "claude" + assert cli_obj.config["display"]["status_bar_style"] == "claude" + + def test_statusbar_command_invalid_arg_does_not_mutate_state(self): + cli_obj = _make_statusbar_command_cli(visible=False, style="lifeos") + + with patch("cli.save_config_value") as save_mock, \ + patch.object(cli_obj, "_console_print") as print_mock: + cli_obj._handle_statusbar_command("/statusbar plasma") + + save_mock.assert_not_called() + assert cli_obj._status_bar_visible is False + assert cli_obj._status_bar_style == "lifeos" + printed = "\n".join(str(call.args[0]) for call in print_mock.call_args_list) + assert "Usage: /statusbar" in printed + def test_input_height_counts_wide_characters_using_cell_width(self): cli_obj = _make_cli() diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index 0cb42ba299ac..fafaa6e54b09 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -3,6 +3,7 @@ from unittest.mock import patch import pytest +import subprocess from hermes_cli.tools_config import ( _DEFAULT_OFF_TOOLSETS, @@ -13,6 +14,7 @@ _platform_toolset_summary, _reconfigure_tool, _run_post_setup, + _run_cua_driver_installer, _save_platform_tools, _toolset_has_keys, _toolset_needs_configuration_prompt, @@ -839,6 +841,138 @@ def fake_which(name: str): assert "curl" in seen +def test_computer_use_post_setup_downloads_and_executes_script_without_shell(): + """The cua-driver installer should use argv lists and pipe script text into bash.""" + seen = [] + + def fake_which(name: str): + seen.append(name) + if name == "cua-driver": + # Force installer on first check and success on post-install check. + if seen.count(name) > 1: + return "/usr/local/bin/cua-driver" + return None + if name == "curl": + return "/usr/bin/curl" + if name == "bash": + return "/bin/bash" + return None + + install_url = "https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/install.sh" + curl_result = subprocess.CompletedProcess( + args=["/usr/bin/curl", "-fsSL", install_url], + returncode=0, + stdout="#!/bin/bash\necho install\n", + stderr="", + ) + bash_result = subprocess.CompletedProcess( + args=["/bin/bash"], returncode=0, stdout="", stderr="" + ) + + with patch("platform.system", return_value="Darwin"), \ + patch("shutil.which", side_effect=fake_which), \ + patch("hermes_cli.tools_config._check_cua_driver_asset_for_arch", return_value=True), \ + patch("subprocess.run") as run: + run.side_effect = [curl_result, bash_result] + + _run_post_setup("cua_driver") + + assert run.call_count == 2 + curl_call = run.call_args_list[0] + bash_call = run.call_args_list[1] + + assert curl_call.args[0] == ["/usr/bin/curl", "-fsSL", install_url] + assert bash_call.args[0] == ["/bin/bash"] + assert bash_call.kwargs["input"] == "#!/bin/bash\necho install\n" + assert not curl_call.kwargs.get("shell") + assert not bash_call.kwargs.get("shell") + assert "cua-driver" in seen + + +def test_computer_use_post_setup_fails_when_curl_fails_and_skips_bash(): + """curl non-zero exit should abort without invoking bash.""" + def fake_which(name: str): + if name == "cua-driver": + return None + if name == "curl": + return "/usr/bin/curl" + if name == "bash": + return "/bin/bash" + return None + + curl_error = subprocess.CompletedProcess( + args=[ + "/usr/bin/curl", + "-fsSL", + "https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/install.sh", + ], + returncode=1, + stdout="", + stderr="network fail", + ) + + with patch("platform.system", return_value="Darwin"), \ + patch("shutil.which", side_effect=fake_which), \ + patch("hermes_cli.tools_config._check_cua_driver_asset_for_arch", return_value=True), \ + patch("subprocess.run") as run: + run.return_value = curl_error + + _run_post_setup("cua_driver") + + assert run.call_count == 1 + assert run.call_args_list[0].args[0] == [ + "/usr/bin/curl", + "-fsSL", + "https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/install.sh", + ] + assert not run.call_args_list[0].kwargs.get("shell") + + +def test_cua_driver_installer_reports_manual_command_when_curl_missing_and_bash_found(): + """curl missing path should explain how to rerun install manually with bash.""" + info_calls = [] + warn_calls = [] + + def fake_which(name: str): + if name == "bash": + return "/bin/bash" + return None + + with patch("shutil.which", side_effect=fake_which), \ + patch("hermes_cli.tools_config._print_warning", side_effect=lambda msg: warn_calls.append(msg)), \ + patch("hermes_cli.tools_config._print_info", side_effect=lambda msg: info_calls.append(msg)), \ + patch("subprocess.run") as run: + assert _run_cua_driver_installer() is False + + assert not run.called + assert any("curl not found — install manually:" in msg for msg in warn_calls) + assert any( + "curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/install.sh | /bin/bash" in msg + for msg in info_calls + ) + + +def test_cua_driver_installer_reports_missing_bash_without_referring_to_bin_bash(): + """When bash is unavailable, do not print a stale /bin/bash manual hint.""" + info_calls = [] + warn_calls = [] + + with patch("shutil.which", return_value=None), \ + patch("pathlib.Path.exists", return_value=False), \ + patch("hermes_cli.tools_config._print_warning", side_effect=lambda msg: warn_calls.append(msg)), \ + patch("hermes_cli.tools_config._print_info", side_effect=lambda msg: info_calls.append(msg)), \ + patch("subprocess.run") as run: + assert _run_cua_driver_installer() is False + + assert not run.called + assert any("curl not found — install manually:" in msg for msg in warn_calls) + assert not any("/bin/bash" in msg for msg in info_calls) + assert any( + "Download the installer script and run it with a local shell." in msg + for msg in info_calls + ) + + class TestImagegenBackendRegistry: """IMAGEGEN_BACKENDS tags drive the model picker flow in tools_config.""" diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 3239aa431176..a8edebcbce1a 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -72,7 +72,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | `/fast [normal\|fast\|status]` | Toggle fast mode — OpenAI Priority Processing / Anthropic Fast Mode. Options: `normal`, `fast`, `status`. | | `/reasoning` | Manage reasoning effort and display (usage: /reasoning [level\|show\|hide]) | | `/skin` | Show or change the display skin/theme | -| `/statusbar` (alias: `/sb`) | Toggle the context/model status bar on or off | +| `/statusbar [on\|off\|toggle\|default\|lifeos\|anirvan\|claude]` (alias: `/sb`) | Toggle the context/model status bar or set the CLI status bar style | | `/voice [on\|off\|tts\|status]` | Toggle CLI voice mode and spoken playback. Recording uses `voice.record_key` (default: `Ctrl+B`). | | `/yolo` | Toggle YOLO mode — skip all dangerous command approval prompts. | | `/footer [on\|off\|status]` | Toggle the gateway runtime-metadata footer on final replies (shows model, tool counts, timing). | @@ -233,7 +233,7 @@ The messaging gateway supports the following built-in commands inside Telegram, ## Notes -- `/skin`, `/snapshot`, `/gquota`, `/reload`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/skills`, `/platforms`, `/paste`, `/image`, `/statusbar`, `/plugins`, `/busy`, `/indicator`, `/redraw`, `/clear`, `/history`, `/save`, `/copy`, `/handoff`, and `/quit` are **CLI-only** commands. +- `/skin`, `/snapshot`, `/gquota`, `/reload`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/skills`, `/platforms`, `/paste`, `/image`, `/statusbar [on|off|toggle|default|lifeos|anirvan|claude]`, `/plugins`, `/busy`, `/indicator`, `/redraw`, `/clear`, `/history`, `/save`, `/copy`, `/handoff`, and `/quit` are **CLI-only** commands. - `/verbose` is **CLI-only by default**, but can be enabled for messaging platforms by setting `display.tool_progress_command: true` in `config.yaml`. When enabled, it cycles the `display.tool_progress` mode and saves to config. - `/sethome`, `/update`, `/restart`, `/approve`, `/deny`, `/topic`, and `/commands` are **messaging-only** commands. - `/status`, `/background`, `/queue`, `/steer`, `/voice`, `/reload-mcp`, `/reload-skills`, `/rollback`, `/debug`, `/fast`, `/footer`, `/curator`, `/kanban`, `/sessions`, and `/yolo` work in **both** the CLI and the messaging gateway. diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index ad63ed84c096..0266d2d5b1fa 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1183,6 +1183,7 @@ display: show_reasoning: false # Show model reasoning/thinking above each response (toggle with /reasoning show|hide) streaming: false # Stream tokens to terminal as they arrive (real-time output) show_cost: false # Show estimated $ cost in the CLI status bar + status_bar_style: default # default | lifeos | anirvan | claude timestamps: false # When true, prefixes user and assistant labels with [HH:MM] timestamps in the CLI / TUI transcript tool_preview_length: 0 # Max chars for tool call previews (0 = no limit, show full paths/commands) runtime_footer: # Gateway: append a runtime-context footer to final replies diff --git a/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md b/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md index f954be2822aa..402f1aaca27e 100644 --- a/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md +++ b/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md @@ -287,7 +287,8 @@ The registry of record is `hermes_cli/commands.py` — every consumer (styles: kaomoji, emoji, unicode, ascii) /footer [on|off] Toggle gateway runtime-metadata footer on final replies /skin [name] Change theme (CLI) -/statusbar Toggle status bar (CLI) +/statusbar [on|off|toggle|default|lifeos|anirvan|claude] + Toggle status bar or set style (CLI) ``` ### Tools & Skills