diff --git a/cli.py b/cli.py index df7f7464bd..c4a3b0ae56 100644 --- a/cli.py +++ b/cli.py @@ -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 diff --git a/hermes_cli/cli_agent_setup_mixin.py b/hermes_cli/cli_agent_setup_mixin.py index f1ca63a88e..0c24f4c214 100644 --- a/hermes_cli/cli_agent_setup_mixin.py +++ b/hermes_cli/cli_agent_setup_mixin.py @@ -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: diff --git a/hermes_cli/cli_stream_mixin.py b/hermes_cli/cli_stream_mixin.py index 0aa2773d89..ba12ac6223 100644 --- a/hermes_cli/cli_stream_mixin.py +++ b/hermes_cli/cli_stream_mixin.py @@ -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.""" diff --git a/tests/cli/test_status_lines_held_during_stream_box.py b/tests/cli/test_status_lines_held_during_stream_box.py new file mode 100644 index 0000000000..08c9078ce4 --- /dev/null +++ b/tests/cli/test_status_lines_held_during_stream_box.py @@ -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", [])