fix(cli): hold agent status lines until the streamed response box closes

Subagent completion notices ("✓ [set 7 · 2/2] … (2175.14s)"), background
process notices and spinner print_above text are emitted from worker threads
through agent._print_fn. In the interactive CLI that was bound straight to
_cprint, so a notice landing while a reply streamed was printed between two
paragraphs inside the response box.

_print_fn is now HermesCLI._agent_status_print: while a response or reasoning
box is open the line is parked in _held_status_lines and released right after
the box footer; outside a box it prints immediately as before. Gateway, ACP
and TUI printers are untouched (they never bound _cprint).
This commit is contained in:
Teknium
2026-09-09 09:32:32 -07:00
parent ecfcbb6276
commit b88e6776f4
4 changed files with 83 additions and 6 deletions
+2 -1
View File
@@ -2619,7 +2619,8 @@ class HermesCLI(CLIProcessNotificationsMixin, CLIAgentSetupMixin, CLICommandsMix
# Streaming display state
self._stream_buf = "" # partial line buffer
self._reasoning_preview_buf = "" # coalesces tiny reasoning chunks
self._stream_started = self._stream_box_opened = False
self._stream_started = self._stream_box_opened = self._stream_box_live = False
self._held_status_lines: list[str] = [] # agent status lines parked while a box streams
# Possible markdown-table lines held until the block ends for wcwidth-aware re-padding.
self._stream_table_buf: list[str] = []
self._in_stream_table = False
+4 -4
View File
@@ -556,10 +556,10 @@ class CLIAgentSetupMixin:
# ``cli._active_agent_ref`` None forever — so memory shutdown never ran on /exit (#49287).
import cli as _cli
_cli._active_agent_ref = self.agent
# Route agent status output through prompt_toolkit so ANSI escapes
# aren't garbled by patch_stdout's StdoutProxy.
# See #2262.
self.agent._print_fn = _cprint
# Route agent status output through prompt_toolkit so ANSI escapes aren't garbled by
# patch_stdout's StdoutProxy (#2262), holding lines while a response box streams so a
# subagent/background completion notice never splits the reply mid-paragraph.
self.agent._print_fn = self._agent_status_print
# Hydrate credits notices at session OPEN (parity with the TUI) so a depletion
# warning shows before the first message. Idempotent + fail-open in the helper.
try:
+26 -1
View File
@@ -250,6 +250,25 @@ class CLIStreamMixin:
_cprint(f"{_DIM}{self._reasoning_buf}{_RST}")
self._reasoning_buf = ""
def _agent_status_print(self, *args, **kwargs) -> None:
"""``agent._print_fn`` for the interactive CLI: agent status lines (subagent completion ``✓ [set n · i/N]``,
background-process notices, spinner ``print_above`` text) arrive from other threads at any moment. While
a response or reasoning box is being streamed they are HELD and released at the box footer, so a line
never lands between two paragraphs of the reply. Outside a box they print immediately."""
from cli import _cprint
text = kwargs.get("sep", " ").join(str(a) for a in args)
if getattr(self, "_stream_box_live", False) or getattr(self, "_reasoning_box_opened", False):
self._held_status_lines = getattr(self, "_held_status_lines", []) + [text]
return
_cprint(text)
def _release_held_status_lines(self) -> None:
"""Print status lines held while a box was open (called right after a box footer)."""
from cli import _cprint
held, self._held_status_lines = getattr(self, "_held_status_lines", []), []
for line in held:
_cprint(line)
def _close_reasoning_box(self) -> None:
"""Close the live reasoning box if it's open, then flush deferred content."""
from cli import _DIM, _RST, _cprint
@@ -262,6 +281,8 @@ class CLIStreamMixin:
w = self._scrollback_box_width()
_cprint(f"{_DIM}└{'─' * (w - 2)}┘{_RST}")
self._reasoning_box_opened = False
if not getattr(self, "_stream_box_live", False):
self._release_held_status_lines()
deferred = getattr(self, "_deferred_content", "")
if deferred:
self._deferred_content = ""
@@ -398,6 +419,7 @@ class CLIStreamMixin:
if not text:
return
self._stream_box_opened = True
self._stream_box_live = True # header drawn; cleared at the footer
try:
from hermes_cli.skin_engine import get_active_skin
_skin = get_active_skin()
@@ -478,9 +500,11 @@ class CLIStreamMixin:
line = _strip_markdown_syntax(self._stream_buf) if self.final_response_markdown == "strip" else self._stream_buf
self._emit_stream_line(line)
self._stream_buf = ""
if self._stream_box_opened:
if self._stream_box_opened and getattr(self, "_stream_box_live", False):
w = self._scrollback_box_width()
_cprint(f"{_ACCENT}╰{'─' * (w - 2)}╯{_RST}")
self._stream_box_live = False
self._release_held_status_lines()
def _reset_stream_state(self) -> None:
"""Reset streaming state before each agent invocation."""
@@ -497,6 +521,7 @@ class CLIStreamMixin:
self._deferred_content = ""
self._stream_table_buf = []
self._in_stream_table = False
self._stream_box_live = False
def _slow_command_status(self, command: str) -> str:
"""Return a user-facing status message for slower slash commands."""
@@ -0,0 +1,51 @@
"""Agent status lines (subagent ``✓ [set n · i/N]`` completions, background-process notices) arrive on other
threads mid-stream. The CLI's ``agent._print_fn`` must park them while a response box is open and release them
at the footer, never between two paragraphs of the reply."""
import os
import re
import sys
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
def _plain(s: str) -> str:
return re.sub(r"\x1b\[[0-9;]*m", "", s)
@pytest.fixture
def cli_stub(monkeypatch):
from cli import HermesCLI
import cli as climod
cli = HermesCLI.__new__(HermesCLI)
cli.show_reasoning = False
cli.final_response_markdown = "raw"
cli.show_timestamps = False
cli._reset_stream_state()
emitted = []
monkeypatch.setattr(climod, "_cprint", lambda s: emitted.append(s))
monkeypatch.setattr(climod, "_terminal_width_for_streaming", lambda: 74)
monkeypatch.setattr(HermesCLI, "_scrollback_box_width", lambda self: 74)
return cli, emitted
def test_status_line_waits_for_box_footer(cli_stub):
cli, emitted = cli_stub
cli._stream_delta("First paragraph.\n")
cli._agent_status_print(" ✓ [set 7 · 2/2] worker (2175.14s)")
cli._stream_delta("Second paragraph.\n")
cli._flush_stream()
lines = [_plain(e) for e in emitted]
notice = next(i for i, l in enumerate(lines) if "set 7" in l)
footer = next(i for i, l in enumerate(lines) if l.startswith("╰"))
second = next(i for i, l in enumerate(lines) if "Second paragraph" in l)
assert second < footer < notice, lines
def test_status_line_prints_immediately_outside_a_box(cli_stub):
cli, emitted = cli_stub
cli._agent_status_print(" ✓ [set 1 · 1/1] worker (3.0s)")
assert [_plain(e) for e in emitted] == [" ✓ [set 1 · 1/1] worker (3.0s)"]
assert not getattr(cli, "_held_status_lines", [])