From b94397fe7652a22faced9036c9450c6536eab451 Mon Sep 17 00:00:00 2001 From: redactdeveloper <283494121+redactdeveloper@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:10:48 -0700 Subject: [PATCH] fix(cli): route /sessions and /history through prompt_toolkit-safe printing Bare print() output is swallowed by patch_stdout while an interactive prompt_toolkit Application owns the terminal, so /sessions and /history rendered nothing. Route those emissions through _cprint (prompt_toolkit's native renderer) when an app is running, and fall back to print otherwise. Fixes #36815 --- cli.py | 66 ++++++++++++++++++---------- tests/cli/test_cli_resume_command.py | 34 ++++++++++++++ 2 files changed, 77 insertions(+), 23 deletions(-) diff --git a/cli.py b/cli.py index 1d77023523d..a2ab0cc78f3 100644 --- a/cli.py +++ b/cli.py @@ -2510,6 +2510,26 @@ def _prepend_note_to_message(message, note: str): return message +def _cli_visible_print(text: str = "") -> None: + """Print normally unless prompt_toolkit owns the live terminal. + + Bare ``print()`` output is swallowed by ``patch_stdout`` while an + interactive ``Application`` is running, so ``/sessions`` and ``/history`` + would render nothing. Route through ``_cprint`` (prompt_toolkit-native) + in that case, and fall back to ``print`` otherwise. + """ + try: + from prompt_toolkit.application import get_app_or_none + app = get_app_or_none() + except Exception: + app = None + + if app is not None and getattr(app, "_is_running", False): + _cprint(text) + else: + print(text) + + # --------------------------------------------------------------------------- # File-drop / local attachment detection — extracted as pure helpers for tests. # --------------------------------------------------------------------------- @@ -6549,30 +6569,30 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): from hermes_cli.main import _relative_time - print() + _cli_visible_print() if reason == "history": - print("(._.) No messages in the current chat yet — here are recent sessions you can resume:") + _cli_visible_print("(._.) No messages in the current chat yet — here are recent sessions you can resume:") else: - print(" Recent sessions:") - print() - print(f" {'#':<3} {'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}") - print(f" {'─' * 3} {'─' * 32} {'─' * 40} {'─' * 13} {'─' * 24}") + _cli_visible_print(" Recent sessions:") + _cli_visible_print() + _cli_visible_print(f" {'#':<3} {'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}") + _cli_visible_print(f" {'─' * 3} {'─' * 32} {'─' * 40} {'─' * 13} {'─' * 24}") for idx, session in enumerate(sessions, start=1): title = session.get("title") or "—" preview = (session.get("preview") or "")[:38] last_active = _relative_time(session.get("last_active")) - print(f" {idx:<3} {title:<32} {preview:<40} {last_active:<13} {session['id']}") - print() - print(" Use /resume , /resume , or /resume to continue.") - print(" Example: /resume 2") - print() + _cli_visible_print(f" {idx:<3} {title:<32} {preview:<40} {last_active:<13} {session['id']}") + _cli_visible_print() + _cli_visible_print(" Use /resume , /resume , or /resume to continue.") + _cli_visible_print(" Example: /resume 2") + _cli_visible_print() return True def show_history(self): """Display conversation history.""" if not self.conversation_history: if not self._show_recent_sessions(reason="history"): - print("(._.) No conversation history yet.") + _cli_visible_print("(._.) No conversation history yet.") return preview_limit = 400 @@ -6601,14 +6621,14 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): return noun = "message" if hidden_tool_messages == 1 else "messages" - print("\n [Tools]") - print(f" ({hidden_tool_messages} tool {noun} hidden)") + _cli_visible_print("\n [Tools]") + _cli_visible_print(f" ({hidden_tool_messages} tool {noun} hidden)") hidden_tool_messages = 0 - print() - print("+" + "-" * 50 + "+") - print("|" + " " * 12 + "(^_^) Conversation History" + " " * 11 + "|") - print("+" + "-" * 50 + "+") + _cli_visible_print() + _cli_visible_print("+" + "-" * 50 + "+") + _cli_visible_print("|" + " " * 12 + "(^_^) Conversation History" + " " * 11 + "|") + _cli_visible_print("+" + "-" * 50 + "+") for msg in self.conversation_history: role = msg.get("role", "unknown") @@ -6627,13 +6647,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): content_text = "" if content is None else str(content) if role == "user": - print(f"\n [You #{visible_index}]{_ts_suffix(msg)}") - print( + _cli_visible_print(f"\n [You #{visible_index}]{_ts_suffix(msg)}") + _cli_visible_print( f" {content_text[:preview_limit]}{'...' if len(content_text) > preview_limit else ''}" ) continue - print(f"\n [Hermes #{visible_index}]{_ts_suffix(msg)}") + _cli_visible_print(f"\n [Hermes #{visible_index}]{_ts_suffix(msg)}") tool_calls = msg.get("tool_calls") or [] if content_text: preview = content_text[:preview_limit] @@ -6646,10 +6666,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): else: preview = "(no text response)" suffix = "" - print(f" {preview}{suffix}") + _cli_visible_print(f" {preview}{suffix}") flush_tool_summary() - print() + _cli_visible_print() def _notify_session_boundary(self, event_type: str) -> None: """Fire a session-boundary plugin hook (on_session_finalize or on_session_reset). diff --git a/tests/cli/test_cli_resume_command.py b/tests/cli/test_cli_resume_command.py index cdb23f54655..b062f93260a 100644 --- a/tests/cli/test_cli_resume_command.py +++ b/tests/cli/test_cli_resume_command.py @@ -1,3 +1,4 @@ +from types import SimpleNamespace from unittest.mock import MagicMock, patch from cli import HermesCLI @@ -38,6 +39,39 @@ class TestCliResumeCommand: assert "/resume 2" in output assert "/resume " in output + def test_show_recent_sessions_uses_prompt_toolkit_safe_print(self): + cli_obj = _make_cli() + cli_obj._list_recent_sessions = MagicMock(return_value=[ + {"id": "sess_002", "title": "Coding", "preview": "build feature", "last_active": None}, + ]) + + running_app = SimpleNamespace(_is_running=True) + with ( + patch("prompt_toolkit.application.get_app_or_none", return_value=running_app), + patch("cli._cprint") as mock_cprint, + ): + shown = cli_obj._show_recent_sessions(reason="sessions") + + assert shown is True + printed = "\n".join(call.args[0] for call in mock_cprint.call_args_list) + assert "Recent sessions" in printed + assert "Coding" in printed + + def test_show_history_uses_prompt_toolkit_safe_print(self): + cli_obj = _make_cli() + cli_obj.conversation_history = [{"role": "user", "content": "Hello"}] + + running_app = SimpleNamespace(_is_running=True) + with ( + patch("prompt_toolkit.application.get_app_or_none", return_value=running_app), + patch("cli._cprint") as mock_cprint, + ): + cli_obj.show_history() + + printed = "\n".join(call.args[0] for call in mock_cprint.call_args_list) + assert "Conversation History" in printed + assert "Hello" in printed + def test_handle_resume_by_index_switches_to_numbered_session(self): cli_obj = _make_cli() cli_obj._list_recent_sessions = MagicMock(return_value=[