refactor(hermes_cli): table-drive MCP diff/tool-progress labels, compact modal docstrings
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user