mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-05 02:31:47 +00:00
Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor
This commit is contained in:
commit
2aea75e91e
131 changed files with 12350 additions and 1164 deletions
614
cli.py
614
cli.py
|
|
@ -1393,6 +1393,19 @@ class ChatConsole:
|
|||
for line in output.rstrip("\n").split("\n"):
|
||||
_cprint(line)
|
||||
|
||||
@contextmanager
|
||||
def status(self, *_args, **_kwargs):
|
||||
"""Provide a no-op Rich-compatible status context.
|
||||
|
||||
Some slash command helpers use ``console.status(...)`` when running in
|
||||
the standalone CLI. Interactive chat routes those helpers through
|
||||
``ChatConsole()``, which historically only implemented ``print()``.
|
||||
Returning a silent context manager keeps slash commands compatible
|
||||
without duplicating the higher-level busy indicator already shown by
|
||||
``HermesCLI._busy_command()``.
|
||||
"""
|
||||
yield self
|
||||
|
||||
# ASCII Art - HERMES-AGENT logo (full width, single line - requires ~95 char terminal)
|
||||
HERMES_AGENT_LOGO = """[bold #FFD700]██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗[/]
|
||||
[bold #FFD700]██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝[/]
|
||||
|
|
@ -1842,10 +1855,13 @@ class HermesCLI:
|
|||
self._approval_state = None
|
||||
self._approval_deadline = 0
|
||||
self._approval_lock = threading.Lock()
|
||||
self._model_picker_state = None
|
||||
self._secret_state = None
|
||||
self._secret_deadline = 0
|
||||
self._spinner_text: str = "" # thinking spinner text for TUI
|
||||
self._tool_start_time: float = 0.0 # monotonic timestamp when current tool started (for live elapsed)
|
||||
self._pending_tool_info: dict = {} # function_name -> list of (preview, args) for stacked scrollback
|
||||
self._last_scrollback_tool: str = "" # last tool name printed to scrollback (for "new" dedup)
|
||||
self._command_running = False
|
||||
self._command_status = ""
|
||||
self._attached_images: list[Path] = []
|
||||
|
|
@ -2084,7 +2100,7 @@ class HermesCLI:
|
|||
return f"⚕ {self.model if getattr(self, 'model', None) else 'Hermes'}"
|
||||
|
||||
def _get_status_bar_fragments(self):
|
||||
if not self._status_bar_visible:
|
||||
if not self._status_bar_visible or getattr(self, '_model_picker_state', None):
|
||||
return []
|
||||
try:
|
||||
snapshot = self._get_status_bar_snapshot()
|
||||
|
|
@ -2757,6 +2773,22 @@ class HermesCLI:
|
|||
if runtime_model and isinstance(runtime_model, str):
|
||||
self.model = runtime_model
|
||||
|
||||
# If model is still empty (e.g. user ran `hermes auth add openai-codex`
|
||||
# without `hermes model`), fall back to the provider's first catalog
|
||||
# model so the API call doesn't fail with "model must be non-empty".
|
||||
if not self.model and resolved_provider:
|
||||
try:
|
||||
from hermes_cli.models import get_default_model_for_provider
|
||||
_default = get_default_model_for_provider(resolved_provider)
|
||||
if _default:
|
||||
self.model = _default
|
||||
logger.info(
|
||||
"No model configured — defaulting to %s for provider %s",
|
||||
_default, resolved_provider,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Normalize model for the resolved provider (e.g. swap non-Codex
|
||||
# models when provider is openai-codex). Fixes #651.
|
||||
model_changed = self._normalize_model_for_provider(resolved_provider)
|
||||
|
|
@ -4376,6 +4408,265 @@ class HermesCLI:
|
|||
remaining = len(self.conversation_history)
|
||||
print(f" {remaining} message(s) remaining in history.")
|
||||
|
||||
def _run_curses_picker(self, title: str, items: list[str], default_index: int = 0) -> int | None:
|
||||
"""Run curses_single_select via run_in_terminal so prompt_toolkit handles terminal ownership cleanly."""
|
||||
import threading
|
||||
from hermes_cli.curses_ui import curses_single_select
|
||||
|
||||
result = [None]
|
||||
|
||||
def _pick():
|
||||
result[0] = curses_single_select(title, items, default_index=default_index)
|
||||
|
||||
# run_in_terminal requires an asyncio event loop — only exists in the
|
||||
# main prompt_toolkit thread. If we're in a background thread (e.g.
|
||||
# process_loop), fall back to direct curses call.
|
||||
in_main_thread = threading.current_thread() is threading.main_thread()
|
||||
|
||||
if self._app and in_main_thread:
|
||||
from prompt_toolkit.application import run_in_terminal
|
||||
was_visible = self._status_bar_visible
|
||||
self._status_bar_visible = False
|
||||
self._app.invalidate()
|
||||
try:
|
||||
run_in_terminal(_pick)
|
||||
finally:
|
||||
self._status_bar_visible = was_visible
|
||||
self._app.invalidate()
|
||||
else:
|
||||
_pick()
|
||||
|
||||
return result[0]
|
||||
|
||||
def _prompt_text_input(self, prompt_text: str) -> str | None:
|
||||
"""Prompt for free-text input safely inside or outside prompt_toolkit."""
|
||||
result = [None]
|
||||
|
||||
def _ask():
|
||||
try:
|
||||
result[0] = input(prompt_text).strip() or None
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
pass
|
||||
|
||||
if self._app:
|
||||
from prompt_toolkit.application import run_in_terminal
|
||||
was_visible = self._status_bar_visible
|
||||
self._status_bar_visible = False
|
||||
self._app.invalidate()
|
||||
try:
|
||||
run_in_terminal(_ask)
|
||||
finally:
|
||||
self._status_bar_visible = was_visible
|
||||
self._app.invalidate()
|
||||
else:
|
||||
_ask()
|
||||
return result[0]
|
||||
|
||||
def _interactive_provider_selection(
|
||||
self, providers: list, current_model: str, current_provider: str
|
||||
) -> str | None:
|
||||
"""Show provider picker, return slug or None on cancel."""
|
||||
choices = []
|
||||
for p in providers:
|
||||
count = p.get("total_models", len(p.get("models", [])))
|
||||
label = f"{p['name']} ({count} model{'s' if count != 1 else ''})"
|
||||
if p.get("is_current"):
|
||||
label += " ← current"
|
||||
choices.append(label)
|
||||
|
||||
default_idx = next(
|
||||
(i for i, p in enumerate(providers) if p.get("is_current")), 0
|
||||
)
|
||||
|
||||
idx = self._run_curses_picker(
|
||||
f"Select a provider (current: {current_model} on {current_provider}):",
|
||||
choices,
|
||||
default_index=default_idx,
|
||||
)
|
||||
if idx is None:
|
||||
return None
|
||||
return providers[idx]["slug"]
|
||||
|
||||
def _interactive_model_selection(
|
||||
self, model_list: list, provider_data: dict
|
||||
) -> str | None:
|
||||
"""Show model picker for a given provider, return model_id or None on cancel."""
|
||||
pname = provider_data.get("name", provider_data.get("slug", ""))
|
||||
total = provider_data.get("total_models", len(model_list))
|
||||
|
||||
if not model_list:
|
||||
_cprint(f"\n No models listed for {pname}.")
|
||||
return self._prompt_text_input(" Enter model name manually (or Enter to cancel): ")
|
||||
|
||||
choices = list(model_list) + ["Enter custom model name"]
|
||||
idx = self._run_curses_picker(
|
||||
f"Select model from {pname} ({len(model_list)} of {total}):",
|
||||
choices,
|
||||
)
|
||||
if idx is None:
|
||||
return None
|
||||
if idx < len(model_list):
|
||||
return model_list[idx]
|
||||
return self._prompt_text_input(" Enter model name: ")
|
||||
|
||||
def _open_model_picker(self, providers: list, current_model: str, current_provider: str, user_provs=None, custom_provs=None) -> None:
|
||||
"""Open prompt_toolkit-native /model picker modal."""
|
||||
self._capture_modal_input_snapshot()
|
||||
default_idx = next((i for i, p in enumerate(providers) if p.get("is_current")), 0)
|
||||
self._model_picker_state = {
|
||||
"stage": "provider",
|
||||
"providers": providers,
|
||||
"selected": default_idx,
|
||||
"current_model": current_model,
|
||||
"current_provider": current_provider,
|
||||
"user_provs": user_provs,
|
||||
"custom_provs": custom_provs,
|
||||
}
|
||||
self._invalidate(min_interval=0.0)
|
||||
|
||||
def _close_model_picker(self) -> None:
|
||||
self._model_picker_state = None
|
||||
self._restore_modal_input_snapshot()
|
||||
self._invalidate(min_interval=0.0)
|
||||
|
||||
def _apply_model_switch_result(self, result, persist_global: bool) -> None:
|
||||
if not result.success:
|
||||
_cprint(f" ✗ {result.error_message}")
|
||||
return
|
||||
|
||||
old_model = self.model
|
||||
self.model = result.new_model
|
||||
self.provider = result.target_provider
|
||||
self.requested_provider = result.target_provider
|
||||
if result.api_key:
|
||||
self.api_key = result.api_key
|
||||
self._explicit_api_key = result.api_key
|
||||
if result.base_url:
|
||||
self.base_url = result.base_url
|
||||
self._explicit_base_url = result.base_url
|
||||
if result.api_mode:
|
||||
self.api_mode = result.api_mode
|
||||
|
||||
if self.agent is not None:
|
||||
try:
|
||||
self.agent.switch_model(
|
||||
new_model=result.new_model,
|
||||
new_provider=result.target_provider,
|
||||
api_key=result.api_key,
|
||||
base_url=result.base_url,
|
||||
api_mode=result.api_mode,
|
||||
)
|
||||
except Exception as exc:
|
||||
_cprint(f" ⚠ Agent swap failed ({exc}); change applied to next session.")
|
||||
|
||||
self._pending_model_switch_note = (
|
||||
f"[Note: model was just switched from {old_model} to {result.new_model} "
|
||||
f"via {result.provider_label or result.target_provider}. "
|
||||
f"Adjust your self-identification accordingly.]"
|
||||
)
|
||||
|
||||
provider_label = result.provider_label or result.target_provider
|
||||
_cprint(f" ✓ Model switched: {result.new_model}")
|
||||
_cprint(f" Provider: {provider_label}")
|
||||
|
||||
mi = result.model_info
|
||||
if mi:
|
||||
if mi.context_window:
|
||||
_cprint(f" Context: {mi.context_window:,} tokens")
|
||||
if mi.max_output:
|
||||
_cprint(f" Max output: {mi.max_output:,} tokens")
|
||||
if mi.has_cost_data():
|
||||
_cprint(f" Cost: {mi.format_cost()}")
|
||||
_cprint(f" Capabilities: {mi.format_capabilities()}")
|
||||
else:
|
||||
try:
|
||||
from agent.model_metadata import get_model_context_length
|
||||
ctx = get_model_context_length(
|
||||
result.new_model,
|
||||
base_url=result.base_url or self.base_url,
|
||||
api_key=result.api_key or self.api_key,
|
||||
provider=result.target_provider,
|
||||
)
|
||||
_cprint(f" Context: {ctx:,} tokens")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cache_enabled = (
|
||||
("openrouter" in (result.base_url or "").lower() and "claude" in result.new_model.lower())
|
||||
or result.api_mode == "anthropic_messages"
|
||||
)
|
||||
if cache_enabled:
|
||||
_cprint(" Prompt caching: enabled")
|
||||
if result.warning_message:
|
||||
_cprint(f" ⚠ {result.warning_message}")
|
||||
if persist_global:
|
||||
save_config_value("model.default", result.new_model)
|
||||
if result.provider_changed:
|
||||
save_config_value("model.provider", result.target_provider)
|
||||
_cprint(" Saved to config.yaml (--global)")
|
||||
else:
|
||||
_cprint(" (session only — add --global to persist)")
|
||||
|
||||
def _handle_model_picker_selection(self, persist_global: bool = False) -> None:
|
||||
state = self._model_picker_state
|
||||
if not state:
|
||||
return
|
||||
selected = state.get("selected", 0)
|
||||
stage = state.get("stage")
|
||||
if stage == "provider":
|
||||
providers = state.get("providers") or []
|
||||
if selected >= len(providers):
|
||||
self._close_model_picker()
|
||||
return
|
||||
provider_data = providers[selected]
|
||||
model_list = []
|
||||
try:
|
||||
from hermes_cli.models import provider_model_ids
|
||||
live = provider_model_ids(provider_data["slug"])
|
||||
if live:
|
||||
model_list = live
|
||||
except Exception:
|
||||
pass
|
||||
if not model_list:
|
||||
model_list = provider_data.get("models", [])
|
||||
state["stage"] = "model"
|
||||
state["provider_data"] = provider_data
|
||||
state["model_list"] = model_list
|
||||
state["selected"] = 0
|
||||
self._invalidate(min_interval=0.0)
|
||||
return
|
||||
if stage == "model":
|
||||
provider_data = state.get("provider_data") or {}
|
||||
model_list = state.get("model_list") or []
|
||||
back_idx = len(model_list)
|
||||
cancel_idx = len(model_list) + 1
|
||||
if selected == back_idx:
|
||||
state["stage"] = "provider"
|
||||
state["selected"] = next((i for i, p in enumerate(state.get("providers") or []) if p.get("slug") == provider_data.get("slug")), 0)
|
||||
self._invalidate(min_interval=0.0)
|
||||
return
|
||||
if selected >= cancel_idx:
|
||||
self._close_model_picker()
|
||||
return
|
||||
if selected < len(model_list):
|
||||
from hermes_cli.model_switch import switch_model
|
||||
chosen_model = model_list[selected]
|
||||
result = switch_model(
|
||||
raw_input=chosen_model,
|
||||
current_provider=self.provider or "",
|
||||
current_model=self.model or "",
|
||||
current_base_url=self.base_url or "",
|
||||
current_api_key=self.api_key or "",
|
||||
is_global=persist_global,
|
||||
explicit_provider=provider_data.get("slug"),
|
||||
user_providers=state.get("user_provs"),
|
||||
custom_providers=state.get("custom_provs"),
|
||||
)
|
||||
self._close_model_picker()
|
||||
self._apply_model_switch_result(result, persist_global)
|
||||
return
|
||||
self._close_model_picker()
|
||||
|
||||
def _handle_model_switch(self, cmd_original: str):
|
||||
"""Handle /model command — switch model for this session.
|
||||
|
||||
|
|
@ -4398,56 +4689,46 @@ class HermesCLI:
|
|||
|
||||
user_provs = None
|
||||
custom_provs = None
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
user_provs = cfg.get("providers")
|
||||
custom_provs = cfg.get("custom_providers")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# No args at all: show available providers + models
|
||||
# No args at all: open prompt_toolkit-native picker modal
|
||||
if not model_input and not explicit_provider:
|
||||
model_display = self.model or "unknown"
|
||||
provider_display = get_label(self.provider) if self.provider else "unknown"
|
||||
_cprint(f" Current: {model_display} on {provider_display}")
|
||||
_cprint("")
|
||||
|
||||
# Show authenticated providers with top models
|
||||
user_provs = None
|
||||
custom_provs = None
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
user_provs = cfg.get("providers")
|
||||
custom_provs = cfg.get("custom_providers")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
providers = list_authenticated_providers(
|
||||
current_provider=self.provider or "",
|
||||
user_providers=user_provs,
|
||||
custom_providers=custom_provs,
|
||||
max_models=6,
|
||||
max_models=50,
|
||||
)
|
||||
if providers:
|
||||
for p in providers:
|
||||
tag = " (current)" if p["is_current"] else ""
|
||||
_cprint(f" {p['name']} [--provider {p['slug']}]{tag}:")
|
||||
if p["models"]:
|
||||
model_strs = ", ".join(p["models"])
|
||||
extra = f" (+{p['total_models'] - len(p['models'])} more)" if p["total_models"] > len(p["models"]) else ""
|
||||
_cprint(f" {model_strs}{extra}")
|
||||
elif p.get("api_url"):
|
||||
_cprint(f" {p['api_url']} (use /model <name> --provider {p['slug']})")
|
||||
else:
|
||||
_cprint(f" (no models listed)")
|
||||
_cprint("")
|
||||
else:
|
||||
_cprint(" No authenticated providers found.")
|
||||
_cprint("")
|
||||
except Exception:
|
||||
pass
|
||||
providers = []
|
||||
|
||||
# Aliases
|
||||
from hermes_cli.model_switch import MODEL_ALIASES
|
||||
alias_list = ", ".join(sorted(MODEL_ALIASES.keys()))
|
||||
_cprint(f" Aliases: {alias_list}")
|
||||
_cprint("")
|
||||
_cprint(" /model <name> switch model")
|
||||
_cprint(" /model <name> --provider <slug> switch provider")
|
||||
_cprint(" /model <name> --global persist to config")
|
||||
if not providers:
|
||||
_cprint(" No authenticated providers found.")
|
||||
_cprint("")
|
||||
_cprint(" /model <name> switch model")
|
||||
_cprint(" /model --provider <slug> switch provider")
|
||||
return
|
||||
|
||||
self._open_model_picker(
|
||||
providers,
|
||||
model_display,
|
||||
provider_display,
|
||||
user_provs=user_provs,
|
||||
custom_provs=custom_provs,
|
||||
)
|
||||
return
|
||||
|
||||
# Perform the switch
|
||||
|
|
@ -4555,6 +4836,18 @@ class HermesCLI:
|
|||
else:
|
||||
_cprint(" (session only — add --global to persist)")
|
||||
|
||||
def _should_handle_model_command_inline(self, text: str, has_images: bool = False) -> bool:
|
||||
"""Return True when /model should be handled immediately on the UI thread."""
|
||||
if not text or has_images or not _looks_like_slash_command(text):
|
||||
return False
|
||||
try:
|
||||
from hermes_cli.commands import resolve_command
|
||||
base = text.split(None, 1)[0].lower().lstrip('/')
|
||||
cmd = resolve_command(base)
|
||||
return bool(cmd and cmd.name == "model")
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _show_model_and_providers(self):
|
||||
"""Show current model + provider and list all authenticated providers.
|
||||
|
||||
|
|
@ -5065,9 +5358,33 @@ class HermesCLI:
|
|||
context_length=ctx_len,
|
||||
)
|
||||
_cprint(" ✨ (◕‿◕)✨ Fresh start! Screen cleared and conversation reset.\n")
|
||||
# Show a random tip on new session
|
||||
try:
|
||||
from hermes_cli.tips import get_random_tip
|
||||
_tip = get_random_tip()
|
||||
try:
|
||||
from hermes_cli.skin_engine import get_active_skin
|
||||
_tip_color = get_active_skin().get_color("banner_dim", "#B8860B")
|
||||
except Exception:
|
||||
_tip_color = "#B8860B"
|
||||
cc.print(f"[dim {_tip_color}]✦ Tip: {_tip}[/]")
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
self.show_banner()
|
||||
print(" ✨ (◕‿◕)✨ Fresh start! Screen cleared and conversation reset.\n")
|
||||
# Show a random tip on new session
|
||||
try:
|
||||
from hermes_cli.tips import get_random_tip
|
||||
_tip = get_random_tip()
|
||||
try:
|
||||
from hermes_cli.skin_engine import get_active_skin
|
||||
_tip_color = get_active_skin().get_color("banner_dim", "#B8860B")
|
||||
except Exception:
|
||||
_tip_color = "#B8860B"
|
||||
self.console.print(f"[dim {_tip_color}]✦ Tip: {_tip}[/]")
|
||||
except Exception:
|
||||
pass
|
||||
elif canonical == "history":
|
||||
self.show_history()
|
||||
elif canonical == "title":
|
||||
|
|
@ -5167,7 +5484,7 @@ class HermesCLI:
|
|||
elif canonical == "fast":
|
||||
self._handle_fast_command(cmd_original)
|
||||
elif canonical == "compress":
|
||||
self._manual_compress()
|
||||
self._manual_compress(cmd_original)
|
||||
elif canonical == "usage":
|
||||
self._show_usage()
|
||||
elif canonical == "insights":
|
||||
|
|
@ -6028,8 +6345,14 @@ class HermesCLI:
|
|||
self._reasoning_preview_buf = getattr(self, "_reasoning_preview_buf", "") + reasoning_text
|
||||
self._flush_reasoning_preview(force=False)
|
||||
|
||||
def _manual_compress(self):
|
||||
"""Manually trigger context compression on the current conversation."""
|
||||
def _manual_compress(self, cmd_original: str = ""):
|
||||
"""Manually trigger context compression on the current conversation.
|
||||
|
||||
Accepts an optional focus topic: ``/compress <focus>`` guides the
|
||||
summariser to preserve information related to *focus* while being
|
||||
more aggressive about discarding everything else. Inspired by
|
||||
Claude Code's ``/compact <focus>`` feature.
|
||||
"""
|
||||
if not self.conversation_history or len(self.conversation_history) < 4:
|
||||
print("(._.) Not enough conversation to compress (need at least 4 messages).")
|
||||
return
|
||||
|
|
@ -6042,18 +6365,30 @@ class HermesCLI:
|
|||
print("(._.) Compression is disabled in config.")
|
||||
return
|
||||
|
||||
# Extract optional focus topic from the command (e.g. "/compress database schema")
|
||||
focus_topic = ""
|
||||
if cmd_original:
|
||||
parts = cmd_original.strip().split(None, 1)
|
||||
if len(parts) > 1:
|
||||
focus_topic = parts[1].strip()
|
||||
|
||||
original_count = len(self.conversation_history)
|
||||
try:
|
||||
from agent.model_metadata import estimate_messages_tokens_rough
|
||||
from agent.manual_compression_feedback import summarize_manual_compression
|
||||
original_history = list(self.conversation_history)
|
||||
approx_tokens = estimate_messages_tokens_rough(original_history)
|
||||
print(f"🗜️ Compressing {original_count} messages (~{approx_tokens:,} tokens)...")
|
||||
if focus_topic:
|
||||
print(f"🗜️ Compressing {original_count} messages (~{approx_tokens:,} tokens), "
|
||||
f"focus: \"{focus_topic}\"...")
|
||||
else:
|
||||
print(f"🗜️ Compressing {original_count} messages (~{approx_tokens:,} tokens)...")
|
||||
|
||||
compressed, _ = self.agent._compress_context(
|
||||
original_history,
|
||||
self.agent._cached_system_prompt or "",
|
||||
approx_tokens=approx_tokens,
|
||||
focus_topic=focus_topic or None,
|
||||
)
|
||||
self.conversation_history = compressed
|
||||
new_tokens = estimate_messages_tokens_rough(self.conversation_history)
|
||||
|
|
@ -6369,10 +6704,36 @@ class HermesCLI:
|
|||
On tool.started, records a monotonic timestamp so get_spinner_text()
|
||||
can show a live elapsed timer (the TUI poll loop already invalidates
|
||||
every ~0.15s, so the counter updates automatically).
|
||||
|
||||
When tool_progress_mode is "all" or "new", also prints a persistent
|
||||
stacked line to scrollback on tool.completed so users can see the
|
||||
full history of tool calls (not just the current one in the spinner).
|
||||
"""
|
||||
if event_type == "tool.completed":
|
||||
import time as _time
|
||||
self._tool_start_time = 0.0
|
||||
# Print stacked scrollback line for "all" / "new" modes
|
||||
if function_name and self.tool_progress_mode in ("all", "new"):
|
||||
duration = kwargs.get("duration", 0.0)
|
||||
is_error = kwargs.get("is_error", False)
|
||||
# Pop stored args from tool.started for this function
|
||||
stored = self._pending_tool_info.get(function_name)
|
||||
stored_args = stored.pop(0) if stored else {}
|
||||
if stored is not None and not stored:
|
||||
del self._pending_tool_info[function_name]
|
||||
# "new" mode: skip consecutive repeats of the same tool
|
||||
if self.tool_progress_mode == "new" and function_name == self._last_scrollback_tool:
|
||||
self._invalidate()
|
||||
return
|
||||
self._last_scrollback_tool = function_name
|
||||
try:
|
||||
from agent.display import get_cute_tool_message
|
||||
line = get_cute_tool_message(function_name, stored_args, duration)
|
||||
if is_error:
|
||||
line = f"{line} [error]"
|
||||
_cprint(f" {line}")
|
||||
except Exception:
|
||||
pass
|
||||
self._invalidate()
|
||||
return
|
||||
if event_type != "tool.started":
|
||||
|
|
@ -6388,6 +6749,10 @@ class HermesCLI:
|
|||
label = label[:_pl - 3] + "..."
|
||||
self._spinner_text = f"{emoji} {label}"
|
||||
self._tool_start_time = _time.monotonic()
|
||||
# Store args for stacked scrollback line on completion
|
||||
self._pending_tool_info.setdefault(function_name, []).append(
|
||||
function_args if function_args is not None else {}
|
||||
)
|
||||
self._invalidate()
|
||||
|
||||
if not self._voice_mode:
|
||||
|
|
@ -7781,7 +8146,8 @@ class HermesCLI:
|
|||
secret_widget,
|
||||
approval_widget,
|
||||
clarify_widget,
|
||||
spinner_widget,
|
||||
model_picker_widget=None,
|
||||
spinner_widget=None,
|
||||
spacer,
|
||||
status_bar,
|
||||
input_rule_top,
|
||||
|
|
@ -7798,21 +8164,24 @@ class HermesCLI:
|
|||
ordering.
|
||||
"""
|
||||
return [
|
||||
Window(height=0),
|
||||
sudo_widget,
|
||||
secret_widget,
|
||||
approval_widget,
|
||||
clarify_widget,
|
||||
spinner_widget,
|
||||
spacer,
|
||||
*self._get_extra_tui_widgets(),
|
||||
status_bar,
|
||||
input_rule_top,
|
||||
image_bar,
|
||||
input_area,
|
||||
input_rule_bot,
|
||||
voice_status_bar,
|
||||
completions_menu,
|
||||
item for item in [
|
||||
Window(height=0),
|
||||
sudo_widget,
|
||||
secret_widget,
|
||||
approval_widget,
|
||||
clarify_widget,
|
||||
model_picker_widget,
|
||||
spinner_widget,
|
||||
spacer,
|
||||
*self._get_extra_tui_widgets(),
|
||||
status_bar,
|
||||
input_rule_top,
|
||||
image_bar,
|
||||
input_area,
|
||||
input_rule_bot,
|
||||
voice_status_bar,
|
||||
completions_menu,
|
||||
] if item is not None
|
||||
]
|
||||
|
||||
def run(self):
|
||||
|
|
@ -7848,6 +8217,17 @@ class HermesCLI:
|
|||
_welcome_text = "Welcome to Hermes Agent! Type your message or /help for commands."
|
||||
_welcome_color = "#FFF8DC"
|
||||
self.console.print(f"[{_welcome_color}]{_welcome_text}[/]")
|
||||
# Show a random tip to help users discover features
|
||||
try:
|
||||
from hermes_cli.tips import get_random_tip
|
||||
_tip = get_random_tip()
|
||||
try:
|
||||
_tip_color = _welcome_skin.get_color("banner_dim", "#B8860B")
|
||||
except Exception:
|
||||
_tip_color = "#B8860B"
|
||||
self.console.print(f"[dim {_tip_color}]✦ Tip: {_tip}[/]")
|
||||
except Exception:
|
||||
pass # Tips are non-critical — never break startup
|
||||
if self.preloaded_skills and not self._startup_skills_line_shown:
|
||||
skills_label = ", ".join(self.preloaded_skills)
|
||||
self.console.print(
|
||||
|
|
@ -7973,6 +8353,12 @@ class HermesCLI:
|
|||
event.app.invalidate()
|
||||
return
|
||||
|
||||
# --- /model picker modal ---
|
||||
if self._model_picker_state:
|
||||
self._handle_model_picker_selection()
|
||||
event.app.invalidate()
|
||||
return
|
||||
|
||||
# --- Clarify freetext mode: user typed their own answer ---
|
||||
if self._clarify_freetext and self._clarify_state:
|
||||
text = event.app.current_buffer.text.strip()
|
||||
|
|
@ -8003,6 +8389,16 @@ class HermesCLI:
|
|||
text = event.app.current_buffer.text.strip()
|
||||
has_images = bool(self._attached_images)
|
||||
if text or has_images:
|
||||
# Handle /model directly on the UI thread so interactive pickers
|
||||
# can safely use prompt_toolkit terminal handoff helpers.
|
||||
if self._should_handle_model_command_inline(text, has_images=has_images):
|
||||
if not self.process_command(text):
|
||||
self._should_exit = True
|
||||
if event.app.is_running:
|
||||
event.app.exit()
|
||||
event.app.current_buffer.reset(append_to_history=True)
|
||||
return
|
||||
|
||||
# Snapshot and clear attached images
|
||||
images = list(self._attached_images)
|
||||
self._attached_images.clear()
|
||||
|
|
@ -8106,12 +8502,31 @@ class HermesCLI:
|
|||
self._approval_state["selected"] = min(max_idx, self._approval_state["selected"] + 1)
|
||||
event.app.invalidate()
|
||||
|
||||
# --- /model picker: arrow-key navigation ---
|
||||
@kb.add('up', filter=Condition(lambda: bool(self._model_picker_state)))
|
||||
def model_picker_up(event):
|
||||
if self._model_picker_state:
|
||||
self._model_picker_state["selected"] = max(0, self._model_picker_state.get("selected", 0) - 1)
|
||||
event.app.invalidate()
|
||||
|
||||
@kb.add('down', filter=Condition(lambda: bool(self._model_picker_state)))
|
||||
def model_picker_down(event):
|
||||
state = self._model_picker_state
|
||||
if not state:
|
||||
return
|
||||
if state.get("stage") == "provider":
|
||||
max_idx = len(state.get("providers") or [])
|
||||
else:
|
||||
max_idx = len(state.get("model_list") or []) + 1
|
||||
state["selected"] = min(max_idx, state.get("selected", 0) + 1)
|
||||
event.app.invalidate()
|
||||
|
||||
# --- History navigation: up/down browse history in normal input mode ---
|
||||
# The TextArea is multiline, so by default up/down only move the cursor.
|
||||
# Buffer.auto_up/auto_down handle both: cursor movement when multi-line,
|
||||
# history browsing when on the first/last line (or single-line input).
|
||||
_normal_input = Condition(
|
||||
lambda: not self._clarify_state and not self._approval_state and not self._sudo_state and not self._secret_state
|
||||
lambda: not self._clarify_state and not self._approval_state and not self._sudo_state and not self._secret_state and not self._model_picker_state
|
||||
)
|
||||
|
||||
@kb.add('up', filter=_normal_input)
|
||||
|
|
@ -8177,6 +8592,13 @@ class HermesCLI:
|
|||
event.app.invalidate()
|
||||
return
|
||||
|
||||
# Cancel /model picker
|
||||
if self._model_picker_state:
|
||||
self._close_model_picker()
|
||||
event.app.current_buffer.reset()
|
||||
event.app.invalidate()
|
||||
return
|
||||
|
||||
# Cancel clarify prompt
|
||||
if self._clarify_state:
|
||||
self._clarify_state["response_queue"].put(
|
||||
|
|
@ -8229,7 +8651,7 @@ class HermesCLI:
|
|||
agent_name = get_active_skin().get_branding("agent_name", "Hermes Agent")
|
||||
msg = f"\n{agent_name} has been suspended. Run `fg` to bring {agent_name} back."
|
||||
def _suspend():
|
||||
os.write(1, msg.encode("utf-8", errors="replace"))
|
||||
os.write(1, msg.encode())
|
||||
os.kill(0, _sig.SIGTSTP)
|
||||
run_in_terminal(_suspend)
|
||||
|
||||
|
|
@ -8794,6 +9216,60 @@ class HermesCLI:
|
|||
filter=Condition(lambda: cli_ref._approval_state is not None),
|
||||
)
|
||||
|
||||
# --- /model picker: display widget ---
|
||||
def _get_model_picker_display():
|
||||
state = cli_ref._model_picker_state
|
||||
if not state:
|
||||
return []
|
||||
stage = state.get("stage", "provider")
|
||||
if stage == "provider":
|
||||
title = "⚙ Model Picker — Select Provider"
|
||||
choices = []
|
||||
for p in state.get("providers") or []:
|
||||
count = p.get("total_models", len(p.get("models", [])))
|
||||
label = f"{p['name']} ({count} model{'s' if count != 1 else ''})"
|
||||
if p.get("is_current"):
|
||||
label += " ← current"
|
||||
choices.append(label)
|
||||
choices.append("Cancel")
|
||||
hint = f"Current: {state.get('current_model', 'unknown')} on {state.get('current_provider', 'unknown')}"
|
||||
else:
|
||||
provider_data = state.get("provider_data") or {}
|
||||
model_list = state.get("model_list") or []
|
||||
title = f"⚙ Model Picker — {provider_data.get('name', provider_data.get('slug', 'Provider'))}"
|
||||
choices = list(model_list) + ["← Back", "Cancel"]
|
||||
if model_list:
|
||||
hint = f"Select a model ({len(model_list)} available)"
|
||||
else:
|
||||
hint = "No models listed for this provider. Use Back or Cancel."
|
||||
|
||||
box_width = _panel_box_width(title, [hint] + choices, min_width=46, max_width=84)
|
||||
inner_text_width = max(8, box_width - 6)
|
||||
lines = []
|
||||
lines.append(('class:clarify-border', '╭─ '))
|
||||
lines.append(('class:clarify-title', title))
|
||||
lines.append(('class:clarify-border', ' ' + ('─' * max(0, box_width - len(title) - 3)) + '╮\n'))
|
||||
_append_blank_panel_line(lines, 'class:clarify-border', box_width)
|
||||
_append_panel_line(lines, 'class:clarify-border', 'class:clarify-hint', hint, box_width)
|
||||
_append_blank_panel_line(lines, 'class:clarify-border', box_width)
|
||||
selected = state.get("selected", 0)
|
||||
for idx, choice in enumerate(choices):
|
||||
style = 'class:clarify-selected' if idx == selected else 'class:clarify-choice'
|
||||
prefix = '❯ ' if idx == selected else ' '
|
||||
for wrapped in _wrap_panel_text(prefix + choice, inner_text_width, subsequent_indent=' '):
|
||||
_append_panel_line(lines, 'class:clarify-border', style, wrapped, box_width)
|
||||
_append_blank_panel_line(lines, 'class:clarify-border', box_width)
|
||||
lines.append(('class:clarify-border', '╰' + ('─' * box_width) + '╯\n'))
|
||||
return lines
|
||||
|
||||
model_picker_widget = ConditionalContainer(
|
||||
Window(
|
||||
FormattedTextControl(_get_model_picker_display),
|
||||
wrap_lines=True,
|
||||
),
|
||||
filter=Condition(lambda: cli_ref._model_picker_state is not None),
|
||||
)
|
||||
|
||||
# Horizontal rules above and below the input.
|
||||
# On narrow/mobile terminals we keep the top separator for structure but
|
||||
# hide the bottom one to recover a full row for conversation content.
|
||||
|
|
@ -8869,6 +9345,7 @@ class HermesCLI:
|
|||
secret_widget=secret_widget,
|
||||
approval_widget=approval_widget,
|
||||
clarify_widget=clarify_widget,
|
||||
model_picker_widget=model_picker_widget,
|
||||
spinner_widget=spinner_widget,
|
||||
spacer=spacer,
|
||||
status_bar=status_bar,
|
||||
|
|
@ -9026,9 +9503,14 @@ class HermesCLI:
|
|||
from tools.process_registry import process_registry
|
||||
if not process_registry.completion_queue.empty():
|
||||
evt = process_registry.completion_queue.get_nowait()
|
||||
_synth = _format_process_notification(evt)
|
||||
if _synth:
|
||||
self._pending_input.put(_synth)
|
||||
# Skip if the agent already consumed this via wait/poll/log
|
||||
_evt_sid = evt.get("session_id", "")
|
||||
if evt.get("type") == "completion" and process_registry.is_completion_consumed(_evt_sid):
|
||||
pass # already delivered via tool result
|
||||
else:
|
||||
_synth = _format_process_notification(evt)
|
||||
if _synth:
|
||||
self._pending_input.put(_synth)
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
|
|
@ -9127,6 +9609,8 @@ class HermesCLI:
|
|||
self._agent_running = False
|
||||
self._spinner_text = ""
|
||||
self._tool_start_time = 0.0
|
||||
self._pending_tool_info.clear()
|
||||
self._last_scrollback_tool = ""
|
||||
|
||||
app.invalidate() # Refresh status line
|
||||
|
||||
|
|
@ -9152,6 +9636,10 @@ class HermesCLI:
|
|||
from tools.process_registry import process_registry
|
||||
while not process_registry.completion_queue.empty():
|
||||
evt = process_registry.completion_queue.get_nowait()
|
||||
# Skip if the agent already consumed this via wait/poll/log
|
||||
_evt_sid = evt.get("session_id", "")
|
||||
if evt.get("type") == "completion" and process_registry.is_completion_consumed(_evt_sid):
|
||||
continue # already delivered via tool result
|
||||
_synth = _format_process_notification(evt)
|
||||
if _synth:
|
||||
self._pending_input.put(_synth)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue