refactor(hermes_cli): table-drive MCP diff/tool-progress labels, compact modal docstrings

This commit is contained in:
Teknium
2026-09-02 21:14:52 -07:00
parent c732ff08ac
commit 6bf802d6b5
3 changed files with 60 additions and 104 deletions
+4 -5
View File
@@ -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:
+25 -36
View File
@@ -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({
+31 -63
View File
@@ -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)