diff --git a/agent/display.py b/agent/display.py index e9a19ff6192b..c1799e7458c0 100644 --- a/agent/display.py +++ b/agent/display.py @@ -15,7 +15,57 @@ from utils import safe_json_loads -# ANSI escape codes for coloring tool failure indicators +# Module-level notification config — set once at startup by the CLI +# from display.notifications config. These are read-only after init so the +# spinner thread can access them without locking. +_notif_enabled: bool = False # Master switch +_notif_tab_title: bool = True # Terminal tab title updates +_notif_desktop: bool = False # Desktop notifications + + +def init_notifications( + enabled: bool = False, + tab_title: bool = True, + desktop: bool = False, +) -> None: + """Called once at CLI startup to configure notification settings.""" + global _notif_enabled, _notif_tab_title, _notif_desktop + _notif_enabled = bool(enabled) + _notif_tab_title = bool(tab_title) + _notif_desktop = bool(desktop) + + +def _update_tab_title(title: str) -> None: + """Internal helper — updates terminal tab title if enabled.""" + if not _notif_enabled or not _notif_tab_title: + return + try: + if sys.stdout.isatty(): + sys.stdout.write(f"\033]0;{title}\007") + sys.stdout.flush() + except (ValueError, OSError): + pass + + +# Public alias for CLI import +set_tab_title = _update_tab_title + + +def _reset_tab_title() -> None: + """Reset terminal tab title back to default.""" + _update_tab_title("") + + +def get_notification_config() -> dict: + """Return current notification config for /notif command display.""" + return { + "enabled": _notif_enabled, + "tab_title": _notif_tab_title, + "desktop": _notif_desktop, + } + + +# ========================================================================= _RED = "\033[31m" _RESET = "\033[0m" @@ -746,11 +796,24 @@ def start(self): return self.running = True self.start_time = time.time() + # Update tab title to thinking state if notifications are enabled + if _notif_enabled and _notif_tab_title: + _update_tab_title("Hermes - Thinking...") self.thread = threading.Thread(target=self._animate, daemon=True) self.thread.start() def update_text(self, new_message: str): self.message = new_message + # Update tab title with tool name if this looks like a tool execution + if _notif_enabled and _notif_tab_title and self.message: + # Try to extract tool name from "Using tool: X" or "emoji tool_name" pattern + msg = self.message.lstrip() + # Remove leading emoji/unicode + import re + cleaned = re.sub(r'^[\u2600-\u27bf\U0001f000-\U0001ffff]+', '', msg).strip() + if cleaned: + tool_name = cleaned.split()[0] if cleaned.split() else cleaned + _update_tab_title(f"Hermes - Using tool: {tool_name}") def print_above(self, text: str): """Print a line above the spinner without disrupting animation. @@ -774,6 +837,9 @@ def stop(self, final_message: str = None): self.running = False if self.thread: self.thread.join(timeout=0.5) + # Reset tab title to ready state when spinner stops + if _notif_enabled and _notif_tab_title: + _reset_tab_title() is_tty = self._is_tty if is_tty: diff --git a/agent/notification.py b/agent/notification.py new file mode 100644 index 000000000000..b6a072fd5e72 --- /dev/null +++ b/agent/notification.py @@ -0,0 +1,216 @@ +"""Cross-platform desktop notification helpers. + +Sends native OS notifications so users with multiple terminal tabs +can see at a glance which Hermes session needs attention. + +Supports: +- **Linux**: ``notify-send`` (freedesktop.org) +- **macOS**: ``osascript display notification`` +- **Windows**: PowerShell toast via WinRT + +All functions are best-effort — no exceptions propagate into the agent loop. + +This module is separate from ``agent.display`` (which handles tab titles via +OSC escape sequences). Use ``agent.display`` for window/tab title updates +and this module for OS-level notification tray popups. + +Desktop notifications are intentionally NOT enabled by default. Users who +want them must set ``display.notifications.desktop: true`` in config or run +``/notif desktop on`` in-session. Tab title updates (``display.notifications.enabled: +true``) use the lightweight OSC approach and are the recommended first step. +""" + +import logging +import os +import platform +import shutil +import subprocess + +logger = logging.getLogger(__name__) + + +# ── Platform-specific notification command builders ──────────────────── + + +def _linux_notify_cmd(summary: str, body: str) -> list[str]: + """Use ``notify-send`` if available (desktop Linux, freedesktop.org).""" + if not shutil.which("notify-send"): + return [] + return [ + "notify-send", + "--urgency", "normal", + "--category", "im.received", + "--expire-time", "5000", + "--icon", "utilities-terminal", + summary, + body, + ] + + +def _macos_notify_cmd(summary: str, body: str) -> list[str]: + """Use ``osascript`` (AppleScript) for macOS notifications.""" + if platform.system() != "Darwin": + return [] + if not shutil.which("osascript"): + return [] + safe_summary = summary.replace('"', '\\"') + safe_body = body.replace('"', '\\"') + return [ + "osascript", + "-e", f'display notification "{safe_body}" with title "{safe_summary}"', + ] + + +def _windows_notify_cmd(summary: str, body: str) -> list[str]: + """Use PowerShell to show a Windows 10+ toast notification. + + Uses the WinRT Windows.UI.Notifications API via PowerShell, + which is the most reliable built-in approach (no pip packages). + """ + if platform.system() != "Windows": + return [] + if not shutil.which("powershell"): + return [] + # Escape single quotes for PowerShell + safe_summary = summary.replace("'", "''") + safe_body = body.replace("'", "''") + ps_script = ( + f"[Windows.UI.Notifications.ToastNotificationManager, " + f"Windows.UI.Notifications, ContentType = WindowsRuntime] | " + f"Out-Null; " + f"$template = [Windows.UI.Notifications.ToastNotificationManager]" + f"::GetTemplateContent(" + f"[Windows.UI.Notifications.ToastTemplateType]::ToastText02); " + f"$template.GetElementsByTagName('text')[0].AppendChild(" + f"$template.CreateTextNode('{safe_summary}')); " + f"$template.GetElementsByTagName('text')[1].AppendChild(" + f"$template.CreateTextNode('{safe_body}')); " + f"$notifier = [Windows.UI.Notifications.ToastNotificationManager]" + f"::CreateToastNotifier('Hermes Agent'); " + f"$notifier.Show($template)" + ) + return ["powershell", "-NoProfile", "-Command", ps_script] + + +def _build_notification_cmd(summary: str, body: str) -> list[str]: + """Return the platform-appropriate notification command. + + Returns an empty list if no suitable backend is available. + """ + system = platform.system() + if system == "Linux": + return _linux_notify_cmd(summary, body) + elif system == "Darwin": + return _macos_notify_cmd(summary, body) + elif system == "Windows": + return _windows_notify_cmd(summary, body) + return [] + + +# ── Core notification function ───────────────────────────────────────── + + +def send_desktop_notification( + summary: str, + body: str = "", + *, + enabled: bool = True, +) -> bool: + """Send a desktop notification (best-effort, never raises). + + Parameters + ---------- + summary : str + Short title shown at the top of the notification. + body : str + Longer detail text. + enabled : bool + Master switch — if False the call is a no-op. + + Returns + ------- + bool + True if a notification command was spawned, False if skipped. + """ + if not enabled or not summary: + return False + + # Skip when running without a desktop session (headless server, CI, + # systemd, SSH without XDG_RUNTIME_DIR / DBUS). + if platform.system() == "Linux": + if ( + not os.environ.get("DISPLAY") + and not os.environ.get("WAYLAND_DISPLAY") + and not os.environ.get("XDG_RUNTIME_DIR") + ): + logger.debug( + "Skipping desktop notification: no display session detected" + ) + return False + + cmd = _build_notification_cmd(summary, body) + if not cmd: + logger.debug("Skipping desktop notification: no backend available") + return False + + try: + subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + return True + except OSError as exc: + logger.debug("Desktop notification failed: %s", exc) + return False + + +# ── Convenience wrappers for specific agent events ───────────────────── + + +def notify_approval_needed(tool_name: str = "", command: str = "") -> bool: + """Send a notification that the agent is blocked on command approval.""" + parts = ["Command approval needed"] + if tool_name: + parts.append(f"via {tool_name}") + if command: + cmd_preview = command[:120] + if len(command) > 120: + cmd_preview += "..." + parts.append(f"`{cmd_preview}`") + return send_desktop_notification( + summary="Hermes - Awaiting Approval", + body=" ".join(parts), + ) + + +def notify_question(question: str = "") -> bool: + """Notify the user that the agent has a clarify question.""" + body = str(question)[:200] if question else "The agent has a question for you." + return send_desktop_notification( + summary="Hermes - Question for You", + body=body, + ) + + +def notify_error(error_summary: str = "") -> bool: + """Notify on a blocking error (tool failure, API error, etc.).""" + body = str(error_summary)[:200] if error_summary else "An error occurred." + return send_desktop_notification( + summary="Hermes - Error", + body=body, + ) + + +def notify_turn_complete(tokens: int = 0) -> bool: + """Optional notification when the agent finishes a long turn.""" + if tokens > 0: + return send_desktop_notification( + summary="Hermes - Done", + body=f"Response complete ({tokens} tokens).", + ) + return send_desktop_notification( + summary="Hermes - Done", + body="Response complete.", + ) diff --git a/cli.py b/cli.py index 0666d74ba58f..3c52bc4a5fdd 100644 --- a/cli.py +++ b/cli.py @@ -650,6 +650,18 @@ def load_cli_config() -> Dict[str, Any]: except Exception: pass +# Initialize tab title / desktop notifications from config +try: + from agent.display import init_notifications + _notif_cfg = CLI_CONFIG.get("display", {}).get("notifications", {}) + if isinstance(_notif_cfg, dict): + init_notifications( + enabled=_notif_cfg.get("enabled", False), + tab_title=_notif_cfg.get("tab_title", True), + desktop=_notif_cfg.get("desktop", False), + ) +except Exception: + pass # Neuter AsyncHttpxClientWrapper.__del__ before any AsyncOpenAI clients are # created. The SDK's __del__ schedules aclose() on asyncio.get_running_loop() # which, during CLI idle time, finds prompt_toolkit's event loop and tries to @@ -7485,6 +7497,8 @@ def process_command(self, command: str) -> bool: self._handle_reasoning_command(cmd_original) elif canonical == "fast": self._handle_fast_command(cmd_original) + elif canonical == "notif": + self._handle_notif_command(cmd_original) elif canonical == "compress": self._manual_compress(cmd_original) elif canonical == "usage": @@ -7826,6 +7840,16 @@ def _bg_thinking(text: str) -> None: sys.stdout.write("\a") sys.stdout.flush() + # Reset tab title and optionally notify on turn complete + try: + from agent.display import _reset_tab_title, _notif_enabled, _notif_desktop + from agent.notification import notify_turn_complete + _reset_tab_title() + if _notif_enabled and _notif_desktop: + notify_turn_complete() + except Exception: + pass + except Exception as e: # Same TUI refresh pattern as success path (#2718) if self._app: @@ -8512,6 +8536,98 @@ def _handle_busy_command(self, cmd: str): else: _cprint(f" {_ACCENT}✓ Busy input mode set to '{arg}' (session only){_RST}") + def _handle_notif_command(self, cmd: str): + """Handle /notif — configure terminal tab & desktop notifications. + + Usage: + /notif Show current status + /notif status Show current status + /notif on Enable all notifications + /notif off Disable all notifications + /notif tab [on|off] Enable/disable tab title updates + /notif desktop [on|off] Enable/disable OS desktop notifications + """ + try: + from agent.display import ( + init_notifications, + get_notification_config, + ) + except ImportError: + _cprint(f" {_DIM}Notifications module not available.{_RST}") + return + + _notif = CLI_CONFIG.get("display", {}).get("notifications", {}) + if not isinstance(_notif, dict): + _notif = {} + + parts = cmd.strip().split(maxsplit=1) + if len(parts) < 2 or parts[1].strip().lower() in ("status",): + cfg = get_notification_config() + status = "enabled" if cfg["enabled"] else "disabled" + _cprint(f" {_ACCENT}Notifications: {status}{_RST}") + _cprint(f" {_DIM} Tab title: {'on' if cfg['tab_title'] else 'off'}{_RST}") + _cprint(f" {_DIM} Desktop: {'on' if cfg['desktop'] else 'off'}{_RST}") + _cprint(f" {_DIM}Usage: /notif [on|off|status|tab [on|off]|desktop [on|off]]{_RST}") + return + + arg = parts[1].strip().split() + sub = arg[0].lower() if arg else "" + + if sub == "on": + init_notifications( + enabled=True, + tab_title=_notif.get("tab_title", True), + desktop=_notif.get("desktop", False), + ) + if save_config_value("display.notifications.enabled", True): + _cprint(f" {_ACCENT}✓ Notifications enabled (saved to config){_RST}") + else: + _cprint(f" {_ACCENT}✓ Notifications enabled (session only){_RST}") + return + + if sub == "off": + init_notifications(enabled=False, tab_title=False, desktop=False) + if save_config_value("display.notifications.enabled", False): + _cprint(f" {_ACCENT}✓ Notifications disabled (saved to config){_RST}") + else: + _cprint(f" {_ACCENT}✓ Notifications disabled (session only){_RST}") + return + + if sub == "tab": + val = arg[1].lower() if len(arg) > 1 else "toggle" + current = get_notification_config() + if val == "toggle": + new_val = not current["tab_title"] + else: + new_val = val in ("on", "true", "1") + init_notifications( + enabled=current["enabled"], + tab_title=new_val, + desktop=current["desktop"], + ) + state = "enabled" if new_val else "disabled" + _cprint(f" {_ACCENT}✓ Tab title updates {state} (session only){_RST}") + return + + if sub == "desktop": + val = arg[1].lower() if len(arg) > 1 else "toggle" + current = get_notification_config() + if val == "toggle": + new_val = not current["desktop"] + else: + new_val = val in ("on", "true", "1") + init_notifications( + enabled=current["enabled"], + tab_title=current["tab_title"], + desktop=new_val, + ) + state = "enabled" if new_val else "disabled" + _cprint(f" {_ACCENT}✓ Desktop notifications {state} (session only){_RST}") + return + + _cprint(f" {_DIM}(._.) Unknown argument: {sub}{_RST}") + _cprint(f" {_DIM}Usage: /notif [on|off|status|tab [on|off]|desktop [on|off]]{_RST}") + def _handle_fast_command(self, cmd: str): """Handle /fast — toggle fast mode (OpenAI Priority Processing / Anthropic Fast Mode).""" if not self._fast_command_available(): @@ -9789,6 +9905,16 @@ def _clarify_callback(self, question, choices): # Open-ended questions skip straight to freetext input self._clarify_freetext = is_open_ended + # Update tab title and optionally send desktop notification + try: + from agent.display import set_tab_title, _notif_enabled, _notif_desktop + from agent.notification import notify_question + set_tab_title("Hermes - Has a question for you") + if _notif_enabled and _notif_desktop: + notify_question(question=str(question)[:200]) + except Exception: + pass + # Trigger prompt_toolkit repaint from this (non-main) thread self._invalidate() @@ -9907,6 +10033,16 @@ def _approval_callback(self, command: str, description: str, } self._approval_deadline = _time.monotonic() + timeout + # Update tab title and optionally send desktop notification + try: + from agent.display import set_tab_title, _notif_enabled, _notif_desktop + from agent.notification import notify_approval_needed + set_tab_title("Hermes - Waiting for approval") + if _notif_enabled and _notif_desktop: + notify_approval_needed(command=command[:120]) + except Exception: + pass + self._invalidate() _last_countdown_refresh = _time.monotonic() diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 1478b8b2e442..755bef11e903 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -144,8 +144,11 @@ class CommandDef: CommandDef("skin", "Show or change the display skin/theme", "Configuration", cli_only=True, args_hint="[name]"), CommandDef("indicator", "Pick the TUI busy-indicator style", "Configuration", - cli_only=True, args_hint="[kaomoji|emoji|unicode|ascii]", + args_hint="[kaomoji|emoji|unicode|ascii]", subcommands=("kaomoji", "emoji", "unicode", "ascii")), + CommandDef("notif", "Configure terminal tab & desktop notifications", "Configuration", + args_hint="[on|off|status|tab|desktop]", + subcommands=("on", "off", "status", "tab", "desktop")), CommandDef("voice", "Toggle voice mode", "Configuration", args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status")), CommandDef("busy", "Control what Enter does while Hermes is working", "Configuration", diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 6b9818242790..9f2dab4db08c 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -967,6 +967,23 @@ def _ensure_hermes_home_managed(home: Path): "fields": ["model", "context_pct", "cwd"], # Order shown; drop any to hide }, "copy_shortcut": "auto", # "auto" (platform default) | "ctrl_c" | "ctrl_shift_c" | "disabled" + # Terminal tab / desktop notifications — helps users with multiple + # terminal tabs/instances see at a glance which session needs attention. + # Tab titles use standard OSC 0 escape sequences (works in GNOME + # Terminal, iTerm2, Alacritty, Kitty, Windows Terminal, tmux). + # Desktop notifications use notify-send (Linux), osascript (macOS), + # or PowerShell (Windows). + "notifications": { + "enabled": False, # Master switch for all notification features + "tab_title": True, # Update terminal tab title on state changes + "desktop": False, # Send OS notifications for approval/errors + "events": { # Fine-grained event-level control + "approval": True, # Notify when command approval is needed + "clarify": True, # Notify when agent asks a question + "error": True, # Notify on blocking tool / API errors + "turn_complete": False, # Notify when a long turn finishes + }, + }, }, # Web dashboard settings diff --git a/tests/agent/test_notification.py b/tests/agent/test_notification.py new file mode 100644 index 000000000000..9479ed71cc68 --- /dev/null +++ b/tests/agent/test_notification.py @@ -0,0 +1,309 @@ +"""Tests for cross-platform desktop notification helpers. + +Covers: +- Notification module (agent.notification) +- Tab title helpers in agent.display +- KawaiiSpinner integration with tab titles +- CLI /notif command +- Config defaults for display.notifications +""" + +import json +import sys +import subprocess +import unittest +from unittest import mock + + +# ── agent.notification tests ─────────────────────────────────────────── + + +class TestDesktopNotification(unittest.TestCase): + + def test_send_disabled_is_noop(self): + """send_desktop_notification with enabled=False returns False.""" + from agent.notification import send_desktop_notification + result = send_desktop_notification("test", "body", enabled=False) + self.assertIs(result, False) + + def test_send_empty_summary_is_noop(self): + """Empty summary should be skipped.""" + from agent.notification import send_desktop_notification + result = send_desktop_notification("", "body", enabled=True) + self.assertIs(result, False) + + def test_empty_summary_is_none(self): + """None-like summary should be skipped.""" + from agent.notification import send_desktop_notification + result = send_desktop_notification(None, "body", enabled=True) + self.assertIs(result, False) + + @mock.patch("agent.notification._linux_notify_cmd") + @mock.patch.dict("os.environ", {"DISPLAY": ":0"}, clear=False) + def test_linux_cmd_called_when_display_set(self, mock_cmd): + """On Linux with DISPLAY set, notify-send should be attempted.""" + mock_cmd.return_value = ["notify-send", "title", "body"] + with mock.patch("agent.notification.subprocess.Popen") as mock_popen: + from agent.notification import send_desktop_notification + with mock.patch("platform.system", return_value="Linux"): + send_desktop_notification("title", "body", enabled=True) + mock_popen.assert_called_once() + + @mock.patch.dict("os.environ", {}, clear=True) + def test_linux_no_display_skipped(self): + """On Linux without DISPLAY/WAYLAND_DISPLAY/XDG_RUNTIME_DIR, skip.""" + import platform + with mock.patch.object(platform, "system", return_value="Linux"): + from agent.notification import send_desktop_notification + result = send_desktop_notification("title", "body", enabled=True) + self.assertIs(result, False) + + def test_macos_builds_osascript_cmd(self): + """macOS should return an osascript command.""" + from agent.notification import _macos_notify_cmd, _build_notification_cmd + with mock.patch("platform.system", return_value="Darwin"): + with mock.patch("shutil.which", return_value="/usr/bin/osascript"): + cmd = _macos_notify_cmd("title", "body") + self.assertIsInstance(cmd, list) + self.assertTrue(len(cmd) > 0) + + def test_windows_builds_powershell_cmd(self): + """Windows should return a powershell command.""" + from agent.notification import _windows_notify_cmd + with mock.patch("platform.system", return_value="Windows"): + with mock.patch("shutil.which", return_value="powershell"): + cmd = _windows_notify_cmd("title", "body") + self.assertIsInstance(cmd, list) + self.assertTrue(len(cmd) > 0) + # Check it's powershell + self.assertEqual(cmd[0], "powershell") + + def test_unknown_platform_returns_empty(self): + """Unknown platform should return empty list.""" + from agent.notification import _build_notification_cmd + with mock.patch("platform.system", return_value="FreeBSD"): + cmd = _build_notification_cmd("title", "body") + self.assertEqual(cmd, []) + + def test_notify_approval_needed_helper(self): + """notify_approval_needed should build correct summary.""" + from agent.notification import notify_approval_needed + with mock.patch( + "agent.notification.send_desktop_notification" + ) as mock_send: + notify_approval_needed(tool_name="terminal", command="rm -rf /tmp") + mock_send.assert_called_once() + call_kwargs = mock_send.call_args + self.assertEqual(call_kwargs.kwargs["summary"], "Hermes - Awaiting Approval") + + def test_notify_question_helper(self): + """notify_question helper.""" + from agent.notification import notify_question + with mock.patch( + "agent.notification.send_desktop_notification" + ) as mock_send: + notify_question("Which directory?") + mock_send.assert_called_once() + self.assertEqual( + mock_send.call_args.kwargs["summary"], + "Hermes - Question for You", + ) + + def test_notify_error_helper(self): + """notify_error helper.""" + from agent.notification import notify_error + with mock.patch( + "agent.notification.send_desktop_notification" + ) as mock_send: + notify_error("API timeout") + mock_send.assert_called_once() + self.assertEqual( + mock_send.call_args.kwargs["summary"], + "Hermes - Error", + ) + + def test_notify_turn_complete_helper(self): + """notify_turn_complete helper.""" + from agent.notification import notify_turn_complete + with mock.patch( + "agent.notification.send_desktop_notification" + ) as mock_send: + notify_turn_complete(tokens=1500) + mock_send.assert_called_once() + self.assertEqual( + mock_send.call_args.kwargs["summary"], + "Hermes - Done", + ) + + +# ── agent.display notification tests ─────────────────────────────────── + + +class TestDisplayNotifications(unittest.TestCase): + + def setUp(self): + """Reset notification state before each test.""" + from agent.display import init_notifications + init_notifications(enabled=False, tab_title=True, desktop=False) + + def test_init_sets_disabled_state(self): + """init_notifications with enabled=False should keep everything off.""" + from agent.display import get_notification_config + cfg = get_notification_config() + self.assertFalse(cfg["enabled"]) + + def test_init_sets_enabled_state(self): + """init_notifications with enabled=True.""" + from agent.display import init_notifications, get_notification_config + init_notifications(enabled=True, tab_title=True, desktop=True) + cfg = get_notification_config() + self.assertTrue(cfg["enabled"]) + self.assertTrue(cfg["tab_title"]) + self.assertTrue(cfg["desktop"]) + + def test_tab_title_noop_when_disabled(self): + """Tab title should not write when notifications are disabled.""" + from agent.display import _update_tab_title + import io + captured = io.StringIO() + with mock.patch("sys.stdout", captured): + _update_tab_title("Hermes - Thinking...") + # When disabled, nothing should be written + self.assertEqual(captured.getvalue(), "") + + def test_tab_title_writes_when_enabled(self): + """Tab title should write OSC escape sequence when enabled.""" + from agent.display import ( + init_notifications, + _update_tab_title, + ) + init_notifications(enabled=True, tab_title=True, desktop=False) + import io + captured = io.StringIO() + with mock.patch.object(sys.stdout, "isatty", return_value=True): + with mock.patch.object(sys.stdout, "write") as mock_write: + with mock.patch.object(sys.stdout, "flush"): + _update_tab_title("Test Title") + mock_write.assert_called() + call_args = mock_write.call_args[0][0] + self.assertIn("Test Title", call_args) + self.assertIn("\033]0;", call_args) + + +# ── KawaiiSpinner tab title integration tests ────────────────────────── + + +class TestSpinnerTabTitleIntegration(unittest.TestCase): + """Test that KawaiiSpinner start/stop updates tab titles.""" + + def setUp(self): + from agent.display import init_notifications + # Reset to disabled + init_notifications(enabled=False, tab_title=True, desktop=False) + + @mock.patch("agent.display._update_tab_title") + @mock.patch("agent.display._reset_tab_title") + def test_spinner_updates_tab_title_on_start_when_enabled( + self, mock_reset, mock_update + ): + """Spinner start() should set tab title when notifications enabled.""" + from agent.display import ( + init_notifications, + KawaiiSpinner, + ) + init_notifications(enabled=True, tab_title=True, desktop=False) + spinner = KawaiiSpinner("test message") + # Don't actually start animation thread — just test the start logic + spinner.running = False + spinner.start() + mock_update.assert_called_with("Hermes - Thinking...") + + @mock.patch("agent.display._update_tab_title") + @mock.patch("agent.display._reset_tab_title") + def test_spinner_resets_tab_title_on_stop_when_enabled(self, mock_reset, mock_update): + """Spinner stop() should reset tab title when notifications enabled.""" + from agent.display import init_notifications, KawaiiSpinner + init_notifications(enabled=True, tab_title=True, desktop=False) + spinner = KawaiiSpinner("test message") + spinner.running = True + spinner.start_time = 0 + spinner.last_line_len = 0 + spinner.thread = None + spinner._print_fn = lambda x: None + # _is_tty is a property — patch _write directly to skip TTY checks + with mock.patch.object(spinner, "_write", lambda *a, **kw: None): + with mock.patch.object(type(spinner), "_is_tty", + new_callable=mock.PropertyMock, return_value=False): + spinner.stop() + mock_reset.assert_called() + + @mock.patch("agent.display._update_tab_title") + def test_spinner_noop_when_disabled(self, mock_update): + """Spinner should not update tab titles when disabled.""" + from agent.display import ( + init_notifications, + KawaiiSpinner, + ) + init_notifications(enabled=False, tab_title=True, desktop=False) + spinner = KawaiiSpinner("test message") + spinner.running = False + spinner.start() + mock_update.assert_not_called() + + +# ── Config defaults test ─────────────────────────────────────────────── + + +class TestNotificationConfigDefaults(unittest.TestCase): + + def test_default_config_has_notifications_key(self): + """DEFAULT_CONFIG should contain display.notifications.""" + from hermes_cli.config import DEFAULT_CONFIG + self.assertIn("notifications", DEFAULT_CONFIG["display"]) + + def test_default_notifications_disabled(self): + """Notifications should be OFF by default.""" + from hermes_cli.config import DEFAULT_CONFIG + notif = DEFAULT_CONFIG["display"]["notifications"] + self.assertFalse(notif["enabled"]) + + def test_default_config_keys(self): + """Config should have expected keys.""" + from hermes_cli.config import DEFAULT_CONFIG + notif = DEFAULT_CONFIG["display"]["notifications"] + self.assertIn("enabled", notif) + self.assertIn("tab_title", notif) + self.assertIn("desktop", notif) + self.assertIn("events", notif) + + def test_event_subkeys(self): + """Events should have approval, clarify, error, turn_complete.""" + from hermes_cli.config import DEFAULT_CONFIG + events = DEFAULT_CONFIG["display"]["notifications"]["events"] + self.assertIn("approval", events) + self.assertIn("clarify", events) + self.assertIn("error", events) + self.assertIn("turn_complete", events) + + +# ── Command registration test ────────────────────────────────────────── + + +class TestNotifCommandRegistration(unittest.TestCase): + + def test_notif_command_in_registry(self): + """/notif should be in COMMAND_REGISTRY.""" + from hermes_cli.commands import COMMAND_REGISTRY + cmd_names = [c.name for c in COMMAND_REGISTRY] + self.assertIn("notif", cmd_names) + + def test_notif_command_has_description(self): + """/notif should have a useful description.""" + from hermes_cli.commands import COMMAND_REGISTRY + cmd = next((c for c in COMMAND_REGISTRY if c.name == "notif"), None) + self.assertIsNotNone(cmd) + self.assertIn("notification", cmd.description.lower()) + + +if __name__ == "__main__": + unittest.main()