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
This commit is contained in:
redactdeveloper 2026-07-01 01:10:48 -07:00 committed by Teknium
parent 081c91c147
commit b94397fe76
2 changed files with 77 additions and 23 deletions

66
cli.py
View file

@ -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 <number>, /resume <session id>, or /resume <session title> 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 <number>, /resume <session id>, or /resume <session title> 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).

View file

@ -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 <session title>" 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=[