diff --git a/hermes_cli/cli_billing_mixin.py b/hermes_cli/cli_billing_mixin.py index de370dc0a4..e6dc509b47 100644 --- a/hermes_cli/cli_billing_mixin.py +++ b/hermes_cli/cli_billing_mixin.py @@ -1,8 +1,5 @@ """Billing and subscription handlers for the interactive CLI (mixed into ``HermesCLI``). - -cli.py-internal symbols (``_cprint``/``_b``/``_d``, display constants) are imported LAZILY inside -each method — the mixin never imports ``cli`` at module load time (import cycle). -""" +cli.py symbols are imported LAZILY inside methods — never at module load (import cycle).""" from __future__ import annotations @@ -951,7 +948,9 @@ class CLIBillingMixin: return _CANCELLED return v.amount threshold_amt = _ask_amount("When balance falls below", ar.threshold_usd if currently_on else None) - reload_amt = _ask_amount("Reload balance to", ar.reload_to_usd if currently_on else None) if threshold_amt is not _CANCELLED else _CANCELLED + if threshold_amt is _CANCELLED: + return + reload_amt = _ask_amount("Reload balance to", ar.reload_to_usd if currently_on else None) if reload_amt is _CANCELLED: return if reload_amt is None or threshold_amt is None or reload_amt <= threshold_amt: diff --git a/hermes_cli/cli_info_mixin.py b/hermes_cli/cli_info_mixin.py index 7e8d35f781..f938b66c6a 100644 --- a/hermes_cli/cli_info_mixin.py +++ b/hermes_cli/cli_info_mixin.py @@ -19,10 +19,19 @@ from rich.markup import escape as _escape from utils import base_url_hostname from hermes_cli.cli_modal_mixin import _gated_confirm +from hermes_cli.colors import Colors as _Colors CONFIG_WATCH_INTERVAL = 5.0 # seconds between config.yaml stat() calls _TOOL_PROGRESS_CYCLE = ["off", "new", "all", "verbose"] +# Raw ANSI (not Rich markup): _cprint routes through prompt_toolkit's renderer, while Rich markup +# written to stdout gets mangled by patch_stdout's StdoutProxy ('?[33mTool progress: NEW?[0m'). +_TOOL_PROGRESS_LABELS = { + "off": f"{_Colors.DIM}Tool progress: OFF{_Colors.RESET} — silent mode, just the final response.", + "new": f"{_Colors.YELLOW}Tool progress: NEW{_Colors.RESET} — show each new tool (skip repeats).", + "all": f"{_Colors.GREEN}Tool progress: ALL{_Colors.RESET} — show every tool call.", + "verbose": f"{_Colors.BOLD}{_Colors.GREEN}Tool progress: VERBOSE{_Colors.RESET} — full args, results, and think blocks.", +} _RELOAD_MCP_CHOICES = [ ("once", "Approve Once", "reload now"), @@ -156,17 +165,12 @@ class CLIInfoMixin: except ValueError: _port = None if _port == 11434 or "ollama" in base_url_hostname(base_url): - self._console_print( - f"[dim] Ollama fix: OLLAMA_CONTEXT_LENGTH={MINIMUM_CONTEXT_LENGTH} ollama serve[/]" - ) + fix = f"Ollama fix: OLLAMA_CONTEXT_LENGTH={MINIMUM_CONTEXT_LENGTH} ollama serve" elif _port == 1234: - self._console_print( - "[dim] LM Studio fix: Set context length in model settings → reload model[/]" - ) + fix = "LM Studio fix: Set context length in model settings → reload model" else: - self._console_print( - "[dim] Fix: Set model.context_length in config.yaml, or increase your server's context setting[/]" - ) + fix = "Fix: Set model.context_length in config.yaml, or increase your server's context setting" + self._console_print(f"[dim] {fix}[/]") from hermes_cli.model_switch import is_nous_hermes_non_agentic if is_nous_hermes_non_agentic(getattr(self, "model", "") or ""): @@ -565,16 +569,7 @@ class CLIInfoMixin: # Sync the live agent so tool_executor rendering reflects the new mode this turn. self.agent.tool_progress_mode = self.tool_progress_mode - # Raw ANSI via _cprint so output routes through prompt_toolkit's renderer; Rich markup to - # stdout gets mangled by patch_stdout's StdoutProxy ('?[33mTool progress: NEW?[0m'). - from hermes_cli.colors import Colors as _Colors - labels = { - "off": f"{_Colors.DIM}Tool progress: OFF{_Colors.RESET} — silent mode, just the final response.", - "new": f"{_Colors.YELLOW}Tool progress: NEW{_Colors.RESET} — show each new tool (skip repeats).", - "all": f"{_Colors.GREEN}Tool progress: ALL{_Colors.RESET} — show every tool call.", - "verbose": f"{_Colors.BOLD}{_Colors.GREEN}Tool progress: VERBOSE{_Colors.RESET} — full args, results, and think blocks.", - } - _cprint(labels.get(self.tool_progress_mode, "")) + _cprint(_TOOL_PROGRESS_LABELS.get(self.tool_progress_mode, "")) def _handle_usage_command(self, cmd_original: str): """Dispatch `/usage [reset [--force]]`: bare `/usage` is the classic display; `reset` @@ -865,16 +860,14 @@ class CLIInfoMixin: with _lock: connected_servers = set(_servers.keys()) - added = connected_servers - old_servers - removed = old_servers - connected_servers - reconnected = connected_servers & old_servers - - if reconnected: - print(f" ♻️ Reconnected: {', '.join(sorted(reconnected))}") - if added: - print(f" ➕ Added: {', '.join(sorted(added))}") - if removed: - print(f" ➖ Removed: {', '.join(sorted(removed))}") + diff = { + "Added": connected_servers - old_servers, + "Removed": old_servers - connected_servers, + "Reconnected": connected_servers & old_servers, + } + for label, icon in (("Reconnected", "♻️ "), ("Added", "➕"), ("Removed", "➖")): + if diff[label]: + print(f" {icon} {label}: {', '.join(sorted(diff[label]))}") if not connected_servers: print(" No MCP servers connected.") else: @@ -901,13 +894,9 @@ class CLIInfoMixin: self.enabled_toolsets = enabled_override # Tell the model tools changed — appended at the END so the prefix cache survives. - change_parts = [] - if added: - change_parts.append(f"Added servers: {', '.join(sorted(added))}") - if removed: - change_parts.append(f"Removed servers: {', '.join(sorted(removed))}") - if reconnected: - change_parts.append(f"Reconnected servers: {', '.join(sorted(reconnected))}") + change_parts = [ + f"{label} servers: {', '.join(sorted(names))}" for label, names in diff.items() if names + ] tool_summary = f"{len(new_tools)} MCP tool(s) now available" if new_tools else "No MCP tools available" change_detail = ". ".join(change_parts) + ". " if change_parts else "" self.conversation_history.append({ diff --git a/hermes_cli/cli_modal_mixin.py b/hermes_cli/cli_modal_mixin.py index c874afb877..407c4aef7e 100644 --- a/hermes_cli/cli_modal_mixin.py +++ b/hermes_cli/cli_modal_mixin.py @@ -116,12 +116,9 @@ class CLIModalMixin: return False def _submit_editor_buffer(self, buffer) -> None: - """Submit the draft an external editor left in ``buffer`` (Ctrl+G done-callback). - - Mirrors the idle/queue branches of the `enter` keybinding: an empty save is ignored (never - submits a blank turn), a bang/slash command is dispatched, otherwise the text goes through - the same input queues. Runs on the prompt_toolkit loop, so it must stay cheap/non-blocking. - """ + """Submit the draft an external editor left in ``buffer`` (Ctrl+G done-callback), mirroring + the `enter` keybinding: empty save ignored, bang/slash dispatched, else queued. Runs on the + prompt_toolkit loop, so it must stay cheap/non-blocking.""" from cli import _DIM, _RST, _cprint, _looks_like_slash_command try: text = (getattr(buffer, "text", "") or "").strip() @@ -219,11 +216,9 @@ class CLIModalMixin: def _prompt_text_input(self, prompt_text: str) -> str | None: """Prompt for free-text input safely inside or outside prompt_toolkit. - ``run_in_terminal`` returns a coroutine only the main-thread event loop can await; slash - commands run on the ``process_loop`` daemon thread, where a bare ``input()`` would block - forever on loop-owned stdin (TUI slash-worker hang). Off the main thread with an app running - we therefore cancel cleanly (None) — mirroring ``_stdin_fallback`` in the modal prompt. - """ + ``run_in_terminal`` only works on the main-thread loop; on the ``process_loop`` daemon + thread a bare ``input()`` would block forever on loop-owned stdin, so with an app running + off-main we cancel cleanly (None) — mirroring ``_stdin_fallback`` in the modal prompt.""" result = [None] def _ask(): @@ -283,15 +278,11 @@ class CLIModalMixin: def _prompt_text_input_modal( self, *, title: str, detail: str, choices: list[tuple[str, str, str]], timeout: float = 120 ) -> str | None: - """Slash-command confirmation through the prompt_toolkit composer instead of raw input(). - - Raw input() fought prompt_toolkit's stdin ownership (prompt drawn above the TUI, Enter read - as EOF). The modal state keeps the choices visible and the normal Enter binding submits. - All platforms (incl. native Windows) drive the modal via ``self._app.loop`` + - ``call_soon_threadsafe``; the raw ``input()`` fallback is kept only for the safe cases: no - running app (tests / non-interactive), no resolvable loop, or a scheduling failure. On - Windows a non-main-thread input() deadlocks against prompt_toolkit, so that case cancels. - """ + """Slash-command confirmation through the prompt_toolkit composer (raw input() fought + prompt_toolkit's stdin ownership: prompt above the TUI, Enter read as EOF). All platforms + drive the modal via ``self._app.loop`` + ``call_soon_threadsafe``; raw ``input()`` is kept + only for the safe cases (no app, no loop, scheduling failure) — on Windows a non-main-thread + input() deadlocks against prompt_toolkit, so that case cancels instead.""" if not choices: return None if not getattr(self, "_app", None): @@ -503,14 +494,10 @@ class CLIModalMixin: def _confirm_destructive_slash( self, command: str, detail: str, cmd_original: Optional[str] = None) -> Optional[str]: - """Confirm a destructive session slash command (``/clear``, ``/new``/``/reset``, ``/undo``). - - Returns ``"once"``, ``"always"`` (also persists ``approvals.destructive_slash_confirm: - false``) or ``None`` (cancelled); callers proceed when non-None. Gate off → ``"once"`` - without prompting. Inline-skip: ``now`` / ``--yes`` / ``-y`` in ``cmd_original`` bypasses - the modal (non-interactive escape hatch; callers strip the tokens via - :meth:`_split_destructive_skip`). - """ + """Confirm a destructive slash command (``/clear``, ``/new``/``/reset``, ``/undo``): returns + ``"once"``, ``"always"`` (persists the opt-out) or ``None`` (cancelled). Gate off → "once" + silently; ``now`` / ``--yes`` / ``-y`` in ``cmd_original`` bypasses the modal (callers strip + the tokens via :meth:`_split_destructive_skip`).""" if cmd_original and self._split_destructive_skip(cmd_original)[1]: return "once" return _gated_confirm( @@ -526,13 +513,9 @@ class CLIModalMixin: once_verb="proceeding") def _ring_bell(self, prompt: bool = False, context: str = "", detail: str = "") -> None: - """Write a terminal bell (\\a) if the matching display.bell_* flag is on. - - ``prompt=True`` is the blocking-modal variant gated by ``display.bell_on_prompt``; the - default is the end-of-turn bell gated by ``display.bell_on_complete``. Works over SSH. The - same flag also emits the OSC 9 / Warp OSC 777 desktop notification via - ``hermes_cli.terminal_notify``; ``context`` is the short notification body. - """ + """Terminal bell (\\a) gated by ``display.bell_on_prompt`` (``prompt=True``, blocking modals) + or ``display.bell_on_complete`` (end of turn); works over SSH. The same flag also emits the + OSC 9 / Warp OSC 777 desktop notification; ``context`` is the short notification body.""" flag = "bell_on_prompt" if prompt else "bell_on_complete" if not getattr(self, flag, False): return @@ -598,13 +581,9 @@ class CLIModalMixin: return _CLARIFY_TIMEOUT_REPLY def _clarify_batch_set_active(self, state, index) -> None: - """Point the batch clarify panel at question ``index``. - - Mirrors the active question into the flat keys the single-question keybindings/renderer - read (``question``/``choices``/``selected``/``multi_select``/``selected_indices``) so - ↑/↓/Space/number keys work unchanged; open-ended drops into freetext. Re-visiting an - answered question restores the cursor/checkboxes to the earlier pick. - """ + """Point the batch clarify panel at question ``index``: mirror it into the flat keys the + single-question keybindings/renderer read so ↑/↓/Space/number keys work unchanged; + open-ended drops into freetext; re-visiting restores the earlier cursor/checkboxes.""" questions_list = state["questions"] index = max(0, min(index, len(questions_list) - 1)) entry = questions_list[index] @@ -634,13 +613,9 @@ class CLIModalMixin: state["selected_indices"] = checked def _clarify_batch_lock(self, state, answer, meta=None) -> None: - """Lock ``answer`` for the active batch question and advance to the next unanswered one. - - Overwrites an earlier answer (locked answers stay editable until the batch completes). - ``meta`` records how it was produced ({"kind": "choice"|"other"|"multi", ...}) so a - re-visit can restore the cursor / prefill an "Other" edit. When every question is answered - the answers dict goes on the response queue and the panel is torn down. - """ + """Lock ``answer`` for the active batch question (overwriting an earlier one) and advance to + the next unanswered; ``meta`` ({"kind": "choice"|"other"|"multi", ...}) lets a re-visit + restore the cursor / prefill an "Other" edit. All answered → resolve the queue, tear down.""" entry = state["questions"][state["active"]] state["answers"][entry["qid"]] = answer state.setdefault("answer_meta", {})[entry["qid"]] = meta or {"kind": "choice"} @@ -660,12 +635,9 @@ class CLIModalMixin: self._clarify_multi_base = None def _clarify_batch_enter(self, state) -> None: - """Enter in batch choice mode: lock the active selection. - - Multi-select locks a JSON array of the checked labels (parsed by the tool core). "Other" - switches to freetext (the freetext submit locks the typed answer), prefilled with an - earlier typed answer so Enter on an answered Other edits instead of retyping. - """ + """Enter in batch choice mode: lock the active selection. Multi-select locks a JSON array of + checked labels (parsed by the tool core); "Other" switches to freetext, prefilled with an + earlier typed answer so Enter on an answered Other edits instead of retyping.""" choices = state.get("choices") or [] selected = state.get("selected", 0) entry = state["questions"][state["active"]] @@ -874,14 +846,10 @@ class CLIModalMixin: pass def _clear_active_overlays_for_interrupt(self) -> None: - """Drain and clear every input-blocking overlay left by an interrupted agent. - - Each prompt blocks a worker thread on ``response_queue.get()``; an interrupt tears the - thread down but leaves the state dict set, gating input with nothing servicing it (frozen - terminal until the prompt's own timeout). Push a safe terminal value onto each queue - (approval -> "deny", clarify/sudo/secret -> cancel), nil the state, restore the draft. - Each step is wrapped so a dead queue can't prevent clearing the others. - """ + """Drain and clear every input-blocking overlay left by an interrupted agent: the worker + thread is gone but the state dict still gates input (frozen terminal until its timeout). + Push a safe value onto each queue (approval -> "deny", others -> cancel), nil the state, + restore the draft; each step is wrapped so a dead queue can't block the others.""" def _put(state, value) -> None: try: state["response_queue"].put(value)