Files
hermes-agent/hermes_cli/cli_commands_session_tools.py
2026-10-08 17:22:54 +02:00

327 lines
15 KiB
Python

"""Shared print/translate helpers + session-info slash handlers (``cli_commands_mixin`` split).
Moved verbatim from ``hermes_cli/cli_commands_mixin.py`` (file-line ratchet #68779): the
lazy-binding contract carries over unchanged — ``cli.py``-internal symbols are imported at
call time via ``from cli import ...``, so patch seams on the ``cli`` facade still intercept.
``cli_commands_mixin`` re-imports every helper moved here, so existing ``from
cli_commands_mixin import _t`` style imports keep working.
"""
from __future__ import annotations
import argparse
import importlib
import io
import shlex
from contextlib import redirect_stdout
from io import StringIO
from agent.i18n import t
def _cp(*lines: str) -> None:
"""``_cprint`` each line (lazy import: cli.py imports this module)."""
from cli import _cprint
for line in lines:
_cprint(line)
def _pr(*lines: str) -> None:
"""print() each line."""
for line in lines:
print(line)
def _save(key: str, value) -> bool:
"""cli.save_config_value, resolved lazily (cli.py imports this module)."""
from cli import save_config_value
return save_config_value(key, value)
def _dim(text: str) -> str:
"""Wrap ``text`` in the dim ANSI escape."""
from cli import _DIM, _RST
return f"{_DIM}{text}{_RST}"
def _dim_line(text: str) -> str:
"""Two-space indented dim line (the standard slash-command hint shape)."""
return " " + _dim(text)
def _accent(text: str) -> str:
"""Wrap ``text`` in the skin accent ANSI escape."""
from cli import _ACCENT, _RST
return f"{_ACCENT}{text}{_RST}"
def _accent_line(text: str) -> str:
"""Two-space indented accent line (the standard slash-command headline shape)."""
return f" {_accent(text)}"
def _t(key: str, **kwargs) -> str:
"""Catalog text for this module: ``cli.commands.<key>`` in the active language."""
return t(f"cli.commands.{key}", **kwargs)
def _tn(key: str, count: int, **kwargs) -> str:
"""Plural-aware ``_t``: ``<key>_one`` when ``count == 1``, else ``<key>_other``."""
return _t(f"{key}_{'one' if count == 1 else 'other'}", count=count, **kwargs)
def _gt(key: str, **kwargs) -> str:
"""A ``gateway.<key>`` catalog entry the CLI shares verbatim with the messaging gateway."""
return t(f"gateway.{key}", **kwargs)
def _lines(text: str, pad: str = " ") -> list:
"""Each line of a (possibly multi-line) catalog value prefixed with ``pad`` (blank lines kept)."""
return [f"{pad}{line}" if line else "" for line in text.splitlines()]
def _probe(module: str, name: str, default, *args):
"""``<module>.<name>(*args)`` or ``default`` when the import or the call fails (optional
subsystems: browser backends, async delegations, wake word, ...)."""
try:
return getattr(importlib.import_module(module), name)(*args)
except Exception:
return default
class CLICommandsSessionToolsMixin:
"""``/stop``, ``/agents``, ``/journey``, ``/paste``, ``/copy``, ``/image``, ``/tools``,
``/profile`` handlers (moved from ``CLICommandsMixin``)."""
# ---- /stop, /agents -------------------------------------------------------------------
def _handle_stop_command(self):
"""Handle /stop — kill all running background processes and background (async) delegations.
Separate from interrupt (stop the current turn), as in Codex.
See #14602.
"""
from tools.process_registry import process_registry
running = [p for p in process_registry.list_sessions() if p.get("status") == "running"]
# Background subagents live in their own registry, not the process registry.
n_async = _probe("tools.async_delegation", "active_count", 0)
if not running and not n_async:
return print(f" {_t('stop.none_running')}")
if running:
print(f" {_t('stop.stopping', count=len(running))}")
print(f" {_t('stop.stopped', count=process_registry.kill_all(source='cli.stop'))}")
if n_async:
from tools.async_delegation import interrupt_all
print(f" {_t('stop.interrupted_delegations', count=interrupt_all(reason='/stop'))}")
def _handle_agents_command(self):
"""Handle /agents — show background processes and agent status."""
from tools.process_registry import format_uptime_short, process_registry
processes = process_registry.list_sessions()
running = [p for p in processes if p.get("status") == "running"]
finished = [p for p in processes if p.get("status") != "running"]
_cp(f" {_t('agents.running_processes', count=len(running))}")
for p in running:
up = format_uptime_short(p.get("uptime_seconds", 0))
_cp(f" {p.get('session_id', '?')} · {up} · {p.get('command', '')[:80]}")
if finished:
_cp(f" {_t('agents.recently_finished', count=len(finished))}")
# Background (async) delegations — delegate_task(background=true)
delegations = _probe("tools.async_delegation", "list_async_delegations", [])
if delegations:
running_d = [d for d in delegations if d.get("status") in ("running", "stalling")]
_cp(f" {_t('agents.background_delegations', count=len(running_d))}")
for d in delegations:
status = d.get("status", "?")
line = f" {d.get('delegation_id', '?')} · {status} · {(d.get('goal') or '')[:60]}"
# Live-status detail for in-flight delegations.
# See #51690.
if status == "stalling":
quiet = d.get("stalled_after_quiet_seconds")
if quiet is not None:
line += _t("agents.no_progress", seconds=f"{quiet:.0f}")
elif status == "running":
quiet = d.get("seconds_since_progress")
if quiet is not None and quiet >= 60:
line += _t("agents.quiet", seconds=f"{quiet:.0f}")
_cp(line)
for i, child in enumerate(d.get("children_activity") or []):
if not isinstance(child, dict):
continue
tool = child.get("current_tool")
doing = _t("agents.in_tool", tool=tool) if tool else _t("agents.between_turns")
part = " " + _t("agents.child_line", index=i + 1,
api_calls=child.get("api_calls", "?"), doing=doing)
idle = child.get("seconds_since_activity")
if idle is not None:
part += _t("agents.last_activity", seconds=f"{idle:.0f}")
_cp(part)
agent_running = getattr(self, "_agent_running", False)
_cp(f" {_t('agents.agent_running') if agent_running else _t('agents.agent_idle')}")
# ---- /journey, /paste, /copy, /image --------------------------------------------------
def _handle_journey_command(self, cmd_original: str) -> None:
"""Handle /journey — the learning timeline (see `hermes journey`). Read-only views render
Rich color that patch_stdout would swallow, so capture with forced ANSI and re-emit via
``_cprint``; ``delete``/``edit`` are interactive and keep real stdio."""
from hermes_cli.journey import register_cli
parser = argparse.ArgumentParser(prog="/journey", add_help=False)
register_cli(parser)
try:
args = parser.parse_args(shlex.split(cmd_original)[1:])
except SystemExit:
return
try:
if getattr(args, "journey_action", None) in ("delete", "edit"):
args.func(args)
return
args.force_color = True
buf = io.StringIO()
with redirect_stdout(buf):
args.func(args)
_cp(buf.getvalue().rstrip("\n"))
except Exception as exc:
_cp(f" {_t('journey.failed', error=exc)}")
def _handle_paste_command(self):
"""Handle /paste — explicitly check clipboard for an image.
This is the reliable fallback for terminals where BracketedPaste
doesn't fire for image-only clipboard content (e.g., VSCode terminal,
Windows Terminal with WSL2).
"""
from hermes_cli.clipboard import has_clipboard_image
if not has_clipboard_image():
_cp(_dim_line(_t("paste.no_image")))
elif self._try_attach_clipboard_image():
_cp(f" {_t('paste.attached', index=len(self._attached_images))}")
else:
_cp(_dim_line(_t("paste.extract_failed")))
def _handle_copy_command(self, cmd_original: str) -> None:
"""Handle /copy [number] — copy assistant output to clipboard."""
from cli import _assistant_copy_text
arg = _command_arg(cmd_original)
assistant = [m for m in self.conversation_history if m.get("role") == "assistant"]
if not assistant:
return _cp(f" {_t('copy.nothing_yet')}")
if arg:
try:
idx = int(arg) - 1
except ValueError:
return _cp(f" {_t('copy.usage')}")
if idx < 0 or idx >= len(assistant):
return _cp(f" {_t('copy.invalid_number', max=len(assistant))}")
else: # latest response that has copyable text
idx = next((i for i in range(len(assistant) - 1, -1, -1)
if _assistant_copy_text(assistant[i].get("content"))), -1)
if idx < 0:
return _cp(f" {_t('copy.nothing_in_responses')}")
text = _assistant_copy_text(assistant[idx].get("content"))
if not text:
return _cp(f" {_t('copy.nothing_in_response')}")
try:
from hermes_cli.clipboard import is_remote_shell_session, write_clipboard_text
# Over SSH native tools write the REMOTE clipboard; OSC 52 reaches the user's terminal.
# Locally, OSC 52 is the fallback when native tools are unavailable/fail (SSH/tmux).
if is_remote_shell_session() or not write_clipboard_text(text):
# Fixes #31528.
self._write_osc52_clipboard(text)
_cp(f" {_t('copy.copied_osc52', index=idx + 1)}")
else:
_cp(f" {_t('copy.copied', index=idx + 1)}")
except Exception as e:
_cp(f" {_t('copy.failed', error=e)}")
def _handle_image_command(self, cmd_original: str):
"""Handle /image <path> — attach a local image file for the next prompt."""
from cli import _DIM, _IMAGE_EXTENSIONS, _RST, _cprint, _resolve_attachment_path, _split_path_input
raw_args = (cmd_original.split(None, 1)[1].strip() if " " in cmd_original else "")
if not raw_args:
hint = "/path/to/image.png"
_cprint(f" {_DIM}{_t('image.usage', example=hint)}{_RST}")
return
path_token, _remainder = _split_path_input(raw_args)
image_path = _resolve_attachment_path(path_token)
if image_path is None:
return _cp(_dim_line(_t("image.not_found", path=path_token)))
if image_path.suffix.lower() not in _IMAGE_EXTENSIONS:
return _cp(_dim_line(_t("image.unsupported", name=image_path.name)))
self._attached_images.append(image_path)
_cp(f" {_t('image.attached', name=image_path.name)}")
if _remainder:
_cprint(f" {_DIM}{_t('image.now_type_prompt', text=_remainder)}{_RST}")
# ---- /tools, /profile -----------------------------------------------------------------
def _handle_tools_command(self, cmd: str):
"""Handle /tools [list|disable|enable]. Bare shows the tool list; ``list`` shows per-toolset
status; disable/enable save to config and reset the session so the new tool set takes
effect cleanly (no prompt-cache breakage mid-conversation)."""
parts = _shlex_args(cmd)
subcommand = parts[0] if parts else ""
if subcommand not in {"list", "disable", "enable"}:
return self.show_tools()
if subcommand == "list":
return self._run_tools_config(tools_action="list", platform="cli")
names = parts[1:]
if not names:
return _pr(_t("tools.usage", subcommand=subcommand),
f" {_t('tools.example_toolset', subcommand=subcommand)}",
f" {_t('tools.example_mcp', subcommand=subcommand)}")
# Typing the command is consent. Do NOT use input() — it hangs in prompt_toolkit's loop.
_cp(_accent(_t("tools.disabling" if subcommand == "disable" else "tools.enabling",
names=", ".join(names))))
self._run_tools_config(tools_action=subcommand, names=names, platform="cli")
from hermes_cli.tools_config import _get_platform_tools
from hermes_cli.config import load_config
self.enabled_toolsets = _get_platform_tools(load_config(), "cli")
self.new_session()
_cp(_dim(_t("tools.session_reset")))
def _run_tools_config(self, **ns) -> None:
"""Run ``tools_disable_enable_command``. Inside the interactive TUI its ANSI print() output
is captured (isatty=True so colors still render) and re-emitted through _cprint so
patch_stdout's StdoutProxy doesn't garble the escapes; standalone/tests call straight through."""
from argparse import Namespace
from hermes_cli.tools_config import tools_disable_enable_command
if getattr(self, "_app", None) is None:
return tools_disable_enable_command(Namespace(**ns))
buf = _TTYBuf()
with redirect_stdout(buf):
tools_disable_enable_command(Namespace(**ns))
_cp(*buf.getvalue().splitlines())
def _handle_profile_command(self):
"""Display active profile name and home directory."""
from hermes_cli.slash_exec import CommandContext, execute_command
reply = execute_command("profile", CommandContext(surface="cli"))
_say_block(f" {_t('profile.profile', profile=reply.data['profile'])}",
f" {_t('profile.home', home=reply.data['home'])}")
class _TTYBuf(StringIO):
"""StringIO that claims to be a TTY so ``hermes_cli.colors.color()`` still emits ANSI escapes."""
def isatty(self) -> bool:
return True
def _say_block(*lines: str) -> None:
"""print() the lines framed by a blank line above and below (the /browser output style)."""
_pr("", *lines, "")
def _command_arg(cmd: str, *, lower: bool = False) -> str:
"""Everything after the slash-command word, stripped (optionally lowercased)."""
parts = (cmd or "").strip().split(None, 1)
arg = parts[1].strip() if len(parts) > 1 else ""
return arg.lower() if lower else arg
def _shlex_args(cmd: str) -> list:
"""Tokens after the command word; falls back to whitespace split on unbalanced quotes."""
try:
return shlex.split(cmd)[1:] if cmd else []
except ValueError:
return (cmd or "").split()[1:]