mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-19 16:12:30 +00:00
fix(cli): keep trace output under assistant header
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
9d50f1b933
commit
3fab736262
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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."""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user