mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-20 00:22:31 +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,
|
response: str,
|
||||||
render_markdown: bool,
|
render_markdown: bool,
|
||||||
metadata: dict | None = None,
|
metadata: dict | None = None,
|
||||||
|
show_header: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Render assistant response with consistent terminal styling."""
|
"""Render assistant response with consistent terminal styling."""
|
||||||
console = _make_console()
|
console = _make_console()
|
||||||
content = response or ""
|
content = response or ""
|
||||||
body = _response_renderable(content, render_markdown, metadata)
|
body = _response_renderable(content, render_markdown, metadata)
|
||||||
console.print()
|
if show_header:
|
||||||
console.print(f"[cyan]{__logo__} nanobot[/cyan]")
|
console.print()
|
||||||
|
console.print(f"[cyan]{__logo__} nanobot[/cyan]")
|
||||||
console.print(body)
|
console.print(body)
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
@ -235,6 +237,8 @@ def _print_cli_progress_line(text: str, thinking: ThinkingSpinner | None, render
|
|||||||
target = renderer.console if renderer else console
|
target = renderer.console if renderer else console
|
||||||
pause = renderer.pause_spinner() if renderer else (thinking.pause() if thinking else nullcontext())
|
pause = renderer.pause_spinner() if renderer else (thinking.pause() if thinking else nullcontext())
|
||||||
with pause:
|
with pause:
|
||||||
|
if renderer:
|
||||||
|
renderer.ensure_header()
|
||||||
target.print(f" [dim]↳ {text}[/dim]")
|
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
|
target = renderer.console if renderer else console
|
||||||
pause = renderer.pause_spinner() if renderer else (thinking.pause() if thinking else nullcontext())
|
pause = renderer.pause_spinner() if renderer else (thinking.pause() if thinking else nullcontext())
|
||||||
with pause:
|
with pause:
|
||||||
|
if renderer:
|
||||||
|
renderer.ensure_header()
|
||||||
target.print(f"[dim italic]✻ {text}[/dim italic]")
|
target.print(f"[dim italic]✻ {text}[/dim italic]")
|
||||||
|
|
||||||
|
|
||||||
@ -254,6 +260,7 @@ async def _print_interactive_progress_line(text: str, thinking: ThinkingSpinner
|
|||||||
return
|
return
|
||||||
if renderer:
|
if renderer:
|
||||||
with renderer.pause_spinner():
|
with renderer.pause_spinner():
|
||||||
|
renderer.ensure_header()
|
||||||
renderer.console.print(f" [dim]↳ {text}[/dim]")
|
renderer.console.print(f" [dim]↳ {text}[/dim]")
|
||||||
else:
|
else:
|
||||||
with thinking.pause() if thinking else nullcontext():
|
with thinking.pause() if thinking else nullcontext():
|
||||||
@ -275,7 +282,7 @@ async def _maybe_print_interactive_progress(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
is_tool_hint = metadata.get("_tool_hint", 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 is_reasoning:
|
||||||
if channels_config and not channels_config.show_reasoning:
|
if channels_config and not channels_config.show_reasoning:
|
||||||
return True
|
return True
|
||||||
@ -1118,10 +1125,14 @@ def agent(
|
|||||||
)
|
)
|
||||||
if not renderer.streamed:
|
if not renderer.streamed:
|
||||||
await renderer.close()
|
await renderer.close()
|
||||||
|
print_kwargs: dict[str, Any] = {}
|
||||||
|
if renderer.header_printed:
|
||||||
|
print_kwargs["show_header"] = False
|
||||||
_print_agent_response(
|
_print_agent_response(
|
||||||
response.content if response else "",
|
response.content if response else "",
|
||||||
render_markdown=markdown,
|
render_markdown=markdown,
|
||||||
metadata=response.metadata if response else None,
|
metadata=response.metadata if response else None,
|
||||||
|
**print_kwargs,
|
||||||
)
|
)
|
||||||
await agent_loop.close_mcp()
|
await agent_loop.close_mcp()
|
||||||
|
|
||||||
@ -1246,8 +1257,14 @@ def agent(
|
|||||||
if content and not meta.get("_streamed"):
|
if content and not meta.get("_streamed"):
|
||||||
if renderer:
|
if renderer:
|
||||||
await renderer.close()
|
await renderer.close()
|
||||||
|
print_kwargs: dict[str, Any] = {}
|
||||||
|
if renderer and renderer.header_printed:
|
||||||
|
print_kwargs["show_header"] = False
|
||||||
_print_agent_response(
|
_print_agent_response(
|
||||||
content, render_markdown=markdown, metadata=meta,
|
content,
|
||||||
|
render_markdown=markdown,
|
||||||
|
metadata=meta,
|
||||||
|
**print_kwargs,
|
||||||
)
|
)
|
||||||
elif renderer and not renderer.streamed:
|
elif renderer and not renderer.streamed:
|
||||||
await renderer.close()
|
await renderer.close()
|
||||||
|
|||||||
@ -10,6 +10,7 @@ that plagued earlier approaches.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
from contextlib import contextmanager, nullcontext
|
||||||
|
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.live import Live
|
from rich.live import Live
|
||||||
@ -93,6 +94,7 @@ class StreamRenderer:
|
|||||||
self._console = _make_console()
|
self._console = _make_console()
|
||||||
self._live: Live | None = None
|
self._live: Live | None = None
|
||||||
self._spinner: ThinkingSpinner | None = None
|
self._spinner: ThinkingSpinner | None = None
|
||||||
|
self._header_printed = False
|
||||||
self._start_spinner()
|
self._start_spinner()
|
||||||
|
|
||||||
def _renderable(self):
|
def _renderable(self):
|
||||||
@ -122,12 +124,41 @@ class StreamRenderer:
|
|||||||
"""Expose the Live's console so external print functions can use it."""
|
"""Expose the Live's console so external print functions can use it."""
|
||||||
return self._console
|
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):
|
def pause_spinner(self):
|
||||||
"""Context manager: temporarily stop spinner for clean output."""
|
"""Context manager: temporarily stop transient output for clean trace lines."""
|
||||||
if self._spinner:
|
@contextmanager
|
||||||
return self._spinner.pause()
|
def _pause():
|
||||||
from contextlib import nullcontext
|
live_was_active = self._live is not None
|
||||||
return nullcontext()
|
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:
|
async def on_delta(self, delta: str) -> None:
|
||||||
self.streamed = True
|
self.streamed = True
|
||||||
@ -135,10 +166,7 @@ class StreamRenderer:
|
|||||||
if self._live is None:
|
if self._live is None:
|
||||||
if not self._buf.strip():
|
if not self._buf.strip():
|
||||||
return
|
return
|
||||||
self._stop_spinner()
|
self.ensure_header()
|
||||||
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._live = Live(
|
self._live = Live(
|
||||||
self._renderable(),
|
self._renderable(),
|
||||||
console=self._console,
|
console=self._console,
|
||||||
@ -174,7 +202,6 @@ class StreamRenderer:
|
|||||||
|
|
||||||
def pause(self):
|
def pause(self):
|
||||||
"""Context manager: pause spinner for external output. No-op once streaming has started."""
|
"""Context manager: pause spinner for external output. No-op once streaming has started."""
|
||||||
from contextlib import nullcontext
|
|
||||||
if self._spinner:
|
if self._spinner:
|
||||||
return self._spinner.pause()
|
return self._spinner.pause()
|
||||||
return nullcontext()
|
return nullcontext()
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
from contextlib import nullcontext
|
||||||
from unittest.mock import AsyncMock, MagicMock, call, patch
|
from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@ -96,6 +97,31 @@ def test_print_cli_progress_line_pauses_spinner_before_printing():
|
|||||||
assert order == ["start", "stop", "print", "start", "stop"]
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_print_interactive_progress_line_pauses_spinner_before_printing():
|
async def test_print_interactive_progress_line_pauses_spinner_before_printing():
|
||||||
"""Interactive progress output should also pause spinner cleanly."""
|
"""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..."]
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_reasoning_hidden_when_show_reasoning_disabled():
|
async def test_reasoning_hidden_when_show_reasoning_disabled():
|
||||||
"""Reasoning content should be suppressed when show_reasoning is False."""
|
"""Reasoning content should be suppressed when show_reasoning is False."""
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user