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
co-authored by Cursor
parent 9d50f1b933
commit 3fab736262
4 changed files with 103 additions and 14 deletions
+21 -4
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()
+37 -10
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()