fix(cli): keep trace output under assistant header

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Xubin Ren 2026-05-13 09:13:16 +00:00
parent 9d50f1b933
commit 3fab736262
4 changed files with 103 additions and 14 deletions

View File

@ -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()

View File

@ -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()

View File

@ -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."""

View File

@ -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."""