diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index dd23cb620..e02653bf9 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -176,13 +176,15 @@ def _print_agent_response( response: str, render_markdown: bool, metadata: dict | None = None, + show_header: bool = True, ) -> None: """Render assistant response with consistent terminal styling.""" console = _make_console() content = response or "" body = _response_renderable(content, render_markdown, metadata) - console.print() - console.print(f"[cyan]{__logo__} nanobot[/cyan]") + if show_header: + console.print() + console.print(f"[cyan]{__logo__} nanobot[/cyan]") console.print(body) console.print() @@ -235,6 +237,8 @@ def _print_cli_progress_line(text: str, thinking: ThinkingSpinner | None, render target = renderer.console if renderer else console pause = renderer.pause_spinner() if renderer else (thinking.pause() if thinking else nullcontext()) with pause: + if renderer: + renderer.ensure_header() target.print(f" [dim]↳ {text}[/dim]") @@ -245,6 +249,8 @@ def _print_cli_reasoning(text: str, thinking: ThinkingSpinner | None, renderer: target = renderer.console if renderer else console pause = renderer.pause_spinner() if renderer else (thinking.pause() if thinking else nullcontext()) with pause: + if renderer: + renderer.ensure_header() target.print(f"[dim italic]✻ {text}[/dim italic]") @@ -254,6 +260,7 @@ async def _print_interactive_progress_line(text: str, thinking: ThinkingSpinner return if renderer: with renderer.pause_spinner(): + renderer.ensure_header() renderer.console.print(f" [dim]↳ {text}[/dim]") else: with thinking.pause() if thinking else nullcontext(): @@ -275,7 +282,7 @@ async def _maybe_print_interactive_progress( return False is_tool_hint = metadata.get("_tool_hint", False) - is_reasoning = metadata.get("_reasoning", False) + is_reasoning = metadata.get("_reasoning", False) or metadata.get("_reasoning_delta", False) if is_reasoning: if channels_config and not channels_config.show_reasoning: return True @@ -1118,10 +1125,14 @@ def agent( ) if not renderer.streamed: await renderer.close() + print_kwargs: dict[str, Any] = {} + if renderer.header_printed: + print_kwargs["show_header"] = False _print_agent_response( response.content if response else "", render_markdown=markdown, metadata=response.metadata if response else None, + **print_kwargs, ) await agent_loop.close_mcp() @@ -1246,8 +1257,14 @@ def agent( if content and not meta.get("_streamed"): if renderer: await renderer.close() + print_kwargs: dict[str, Any] = {} + if renderer and renderer.header_printed: + print_kwargs["show_header"] = False _print_agent_response( - content, render_markdown=markdown, metadata=meta, + content, + render_markdown=markdown, + metadata=meta, + **print_kwargs, ) elif renderer and not renderer.streamed: await renderer.close() diff --git a/nanobot/cli/stream.py b/nanobot/cli/stream.py index 64cb4ed78..382ae9aac 100644 --- a/nanobot/cli/stream.py +++ b/nanobot/cli/stream.py @@ -10,6 +10,7 @@ that plagued earlier approaches. from __future__ import annotations import sys +from contextlib import contextmanager, nullcontext from rich.console import Console from rich.live import Live @@ -93,6 +94,7 @@ class StreamRenderer: self._console = _make_console() self._live: Live | None = None self._spinner: ThinkingSpinner | None = None + self._header_printed = False self._start_spinner() def _renderable(self): @@ -122,12 +124,41 @@ class StreamRenderer: """Expose the Live's console so external print functions can use it.""" return self._console + @property + def header_printed(self) -> bool: + """Whether this turn has already opened the assistant output block.""" + return self._header_printed + + def ensure_header(self) -> None: + """Print the assistant header once, before trace or answer content.""" + if self._header_printed: + return + self._stop_spinner() + self._console.print() + header = f"{self._bot_icon} {self._bot_name}" if self._bot_icon else self._bot_name + self._console.print(f"[cyan]{header}[/cyan]") + self._header_printed = True + def pause_spinner(self): - """Context manager: temporarily stop spinner for clean output.""" - if self._spinner: - return self._spinner.pause() - from contextlib import nullcontext - return nullcontext() + """Context manager: temporarily stop transient output for clean trace lines.""" + @contextmanager + def _pause(): + live_was_active = self._live is not None + if self._live: + # Trace/reasoning can arrive after answer streaming has started. + # Stop the transient Live view first so it does not leak a raw + # partial markdown frame before the trace line. + self._live.stop() + self._live = None + with self._spinner.pause() if self._spinner else nullcontext(): + yield + # If more answer deltas arrive after the trace, on_delta() will + # create a fresh Live using the existing buffer. If no deltas arrive, + # on_end() prints the final buffered answer once. + if live_was_active: + return + + return _pause() async def on_delta(self, delta: str) -> None: self.streamed = True @@ -135,10 +166,7 @@ class StreamRenderer: if self._live is None: if not self._buf.strip(): return - self._stop_spinner() - self._console.print() - header = f"{self._bot_icon} {self._bot_name}" if self._bot_icon else self._bot_name - self._console.print(f"[cyan]{header}[/cyan]") + self.ensure_header() self._live = Live( self._renderable(), console=self._console, @@ -174,7 +202,6 @@ class StreamRenderer: def pause(self): """Context manager: pause spinner for external output. No-op once streaming has started.""" - from contextlib import nullcontext if self._spinner: return self._spinner.pause() return nullcontext() diff --git a/tests/cli/test_cli_input.py b/tests/cli/test_cli_input.py index 69293f4b8..8b7a79cfc 100644 --- a/tests/cli/test_cli_input.py +++ b/tests/cli/test_cli_input.py @@ -1,4 +1,5 @@ import asyncio +from contextlib import nullcontext from unittest.mock import AsyncMock, MagicMock, call, patch import pytest @@ -96,6 +97,31 @@ def test_print_cli_progress_line_pauses_spinner_before_printing(): assert order == ["start", "stop", "print", "start", "stop"] +def test_print_cli_progress_line_opens_renderer_header_before_trace(): + """Trace lines should appear under the assistant header, not under You.""" + order: list[str] = [] + renderer = MagicMock() + renderer.console.print.side_effect = lambda *_args, **_kwargs: order.append("print") + renderer.ensure_header.side_effect = lambda: order.append("header") + renderer.pause_spinner.return_value = nullcontext() + + commands._print_cli_progress_line("tool running", None, renderer) + + assert order == ["header", "print"] + + +def test_print_cli_progress_line_stops_live_before_trace(): + """A trace line should not leak the current transient Live frame.""" + mock_live = MagicMock() + renderer = stream_mod.StreamRenderer(show_spinner=False) + renderer._live = mock_live + + commands._print_cli_progress_line("tool running", None, renderer) + + mock_live.stop.assert_called_once() + assert renderer._live is None + + @pytest.mark.asyncio async def test_print_interactive_progress_line_pauses_spinner_before_printing(): """Interactive progress output should also pause spinner cleanly.""" diff --git a/tests/cli/test_interactive_retry_wait.py b/tests/cli/test_interactive_retry_wait.py index 7ddef1c48..52c27d2c9 100644 --- a/tests/cli/test_interactive_retry_wait.py +++ b/tests/cli/test_interactive_retry_wait.py @@ -50,6 +50,25 @@ async def test_reasoning_displayed_when_show_reasoning_enabled(): assert calls == ["Let me think about this..."] +@pytest.mark.asyncio +async def test_reasoning_delta_displayed_when_show_reasoning_enabled(): + """Streamed reasoning delta frames should use the reasoning renderer.""" + calls: list[str] = [] + channels_config = SimpleNamespace( + send_progress=True, send_tool_hints=False, show_reasoning=True, + ) + msg = SimpleNamespace( + content="I should search first.", + metadata={"_progress": True, "_reasoning_delta": True}, + ) + + with patch("nanobot.cli.commands._print_cli_reasoning", side_effect=lambda t, th, r=None: calls.append(t)): + handled = await commands._maybe_print_interactive_progress(msg, None, channels_config) + + assert handled is True + assert calls == ["I should search first."] + + @pytest.mark.asyncio async def test_reasoning_hidden_when_show_reasoning_disabled(): """Reasoning content should be suppressed when show_reasoning is False."""