diff --git a/cli.py b/cli.py old mode 100755 new mode 100644 index 5ff0625c03a..b2c246137ff --- a/cli.py +++ b/cli.py @@ -1915,6 +1915,9 @@ class HermesCLI: tool_progress_callback=self._on_tool_progress, stream_delta_callback=self._stream_delta if self.streaming_enabled else None, ) + # Route agent status output through prompt_toolkit so ANSI escape + # sequences aren't garbled by patch_stdout's StdoutProxy (#2262). + self.agent._print_fn = _cprint self._active_agent_route_signature = ( effective_model, runtime.get("provider"), @@ -4238,13 +4241,18 @@ class HermesCLI: elif not self.show_reasoning: self.agent.reasoning_callback = None + # Use raw ANSI codes via _cprint so the output is routed through + # prompt_toolkit's renderer. self.console.print() with Rich markup + # writes directly to stdout which patch_stdout's StdoutProxy mangles + # into garbled sequences like '?[33mTool progress: NEW?[0m' (#2262). + from hermes_cli.colors import Colors as _Colors labels = { - "off": "[dim]Tool progress: OFF[/] — silent mode, just the final response.", - "new": "[yellow]Tool progress: NEW[/] — show each new tool (skip repeats).", - "all": "[green]Tool progress: ALL[/] — show every tool call.", - "verbose": "[bold green]Tool progress: VERBOSE[/] — full args, results, think blocks, and debug logs.", + "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, think blocks, and debug logs.", } - self.console.print(labels.get(self.tool_progress_mode, "")) + _cprint(labels.get(self.tool_progress_mode, "")) def _handle_reasoning_command(self, cmd: str): """Handle /reasoning — manage effort level and display toggle. diff --git a/run_agent.py b/run_agent.py index 29df5373a73..7931581f80b 100644 --- a/run_agent.py +++ b/run_agent.py @@ -473,6 +473,11 @@ class AIAgent: self.quiet_mode = quiet_mode self.ephemeral_system_prompt = ephemeral_system_prompt self.platform = platform # "cli", "telegram", "discord", "whatsapp", etc. + # Pluggable print function — CLI replaces this with _cprint so that + # raw ANSI status lines are routed through prompt_toolkit's renderer + # instead of going directly to stdout where patch_stdout's StdoutProxy + # would mangle the escape sequences. None = use builtins.print. + self._print_fn = None self.skip_context_files = skip_context_files self.pass_session_id = pass_session_id self.log_prefix_chars = log_prefix_chars @@ -1111,16 +1116,21 @@ class AIAgent: self.context_compressor.compression_count = 0 self.context_compressor._context_probed = False - @staticmethod - def _safe_print(*args, **kwargs): + def _safe_print(self, *args, **kwargs): """Print that silently handles broken pipes / closed stdout. In headless environments (systemd, Docker, nohup) stdout may become unavailable mid-session. A raw ``print()`` raises ``OSError`` which can crash cron jobs and lose completed work. + + Internally routes through ``self._print_fn`` (default: builtin + ``print``) so callers such as the CLI can inject a renderer that + handles ANSI escape sequences properly (e.g. prompt_toolkit's + ``print_formatted_text(ANSI(...))``) without touching this method. """ try: - print(*args, **kwargs) + fn = self._print_fn or print + fn(*args, **kwargs) except OSError: pass