diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 243280ed1..236d787ce 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -227,30 +227,37 @@ async def _print_interactive_response( await run_in_terminal(_write) -def _print_cli_progress_line(text: str, thinking: ThinkingSpinner | None) -> None: +def _print_cli_progress_line(text: str, thinking: ThinkingSpinner | None, renderer: StreamRenderer | None = None) -> None: """Print a CLI progress line, pausing the spinner if needed.""" if not text.strip(): return - with thinking.pause() if thinking else nullcontext(): - console.print(f" [dim]↳ {text}[/dim]") + target = renderer.console if renderer else console + pause = renderer.pause_spinner() if renderer else (thinking.pause() if thinking else nullcontext()) + with pause: + target.print(f" [dim]↳ {text}[/dim]") -async def _print_interactive_progress_line(text: str, renderer: StreamRenderer | None) -> None: - """Print an interactive progress line, pausing the renderer's spinner if needed.""" +async def _print_interactive_progress_line(text: str, thinking: ThinkingSpinner | None, renderer: StreamRenderer | None = None) -> None: + """Print an interactive progress line, pausing the spinner if needed.""" if not text.strip(): return - with renderer.pause() if renderer else nullcontext(): - await _print_interactive_line(text) + if renderer: + with renderer.pause_spinner(): + renderer.console.print(f" [dim]↳ {text}[/dim]") + else: + with thinking.pause() if thinking else nullcontext(): + await _print_interactive_line(text) async def _maybe_print_interactive_progress( msg: Any, - renderer: StreamRenderer | None, + thinking: ThinkingSpinner | None, channels_config: Any, + renderer: StreamRenderer | None = None, ) -> bool: metadata = msg.metadata or {} if metadata.get("_retry_wait"): - await _print_interactive_progress_line(msg.content, renderer) + await _print_interactive_progress_line(msg.content, thinking, renderer) return True if not metadata.get("_progress"): @@ -262,7 +269,7 @@ async def _maybe_print_interactive_progress( if channels_config and not is_tool_hint and not channels_config.send_progress: return True - await _print_interactive_progress_line(msg.content, renderer) + await _print_interactive_progress_line(msg.content, thinking, renderer) return True @@ -1121,13 +1128,15 @@ def agent( # Shared reference for progress callbacks _thinking: ThinkingSpinner | None = None - async def _cli_progress(content: str, *, tool_hint: bool = False, **_kwargs: Any) -> None: - ch = agent_loop.channels_config - if ch and tool_hint and not ch.send_tool_hints: - return - if ch and not tool_hint and not ch.send_progress: - return - _print_cli_progress_line(content, _thinking) + def _make_progress(renderer: StreamRenderer | None = None): + async def _cli_progress(content: str, *, tool_hint: bool = False, **_kwargs: Any) -> None: + ch = agent_loop.channels_config + if ch and tool_hint and not ch.send_tool_hints: + return + if ch and not tool_hint and not ch.send_progress: + return + _print_cli_progress_line(content, _thinking, renderer) + return _cli_progress if message: # Single message mode — direct call, no bus needed @@ -1135,7 +1144,7 @@ def agent( renderer = StreamRenderer(render_markdown=markdown) response = await agent_loop.process_direct( message, session_id, - on_progress=_cli_progress, + on_progress=_make_progress(renderer), on_stream=renderer.on_delta, on_stream_end=renderer.on_end, ) @@ -1206,6 +1215,7 @@ def agent( msg, renderer, agent_loop.channels_config, + renderer, ): continue diff --git a/nanobot/cli/stream.py b/nanobot/cli/stream.py index b0095f153..807c88fef 100644 --- a/nanobot/cli/stream.py +++ b/nanobot/cli/stream.py @@ -1,13 +1,15 @@ """Streaming renderer for CLI output. -Uses Rich Live with auto_refresh=False for stable, flicker-free -markdown rendering during streaming. Ellipsis mode handles overflow. +Uses Rich Live with ``transient=True`` for in-place markdown updates during +streaming. After the live display stops, a final clean render is printed +so the content persists on screen. ``transient=True`` ensures the live +area is erased before ``stop()`` returns, avoiding the duplication bug +that plagued earlier approaches. """ from __future__ import annotations import sys -import time from rich.console import Console from rich.live import Live @@ -67,27 +69,38 @@ class ThinkingSpinner: class StreamRenderer: - """Rich Live streaming with markdown. auto_refresh=False avoids render races. + """Streaming renderer with Rich Live for in-place updates. - Deltas arrive pre-filtered (no tags) from the agent loop. + During streaming: updates content in-place via Rich Live. + On end: stops Live (transient=True erases it), then prints final render. Flow per round: - spinner -> first visible delta -> header + Live renders -> - on_end -> Live stops (content stays on screen) + spinner -> first delta -> header + Live updates -> + on_end -> stop Live + final render """ def __init__(self, render_markdown: bool = True, show_spinner: bool = True): self._md = render_markdown self._show_spinner = show_spinner self._buf = "" - self._live: Live | None = None - self._t = 0.0 self.streamed = False + self._header_printed = False + self._console = _make_console() + self._live: Live | None = None self._spinner: ThinkingSpinner | None = None self._start_spinner() - def _render(self): - return Markdown(self._buf) if self._md and self._buf else Text(self._buf or "") + def _renderable(self): + """Create a renderable from the current buffer.""" + if self._md and self._buf: + return Markdown(self._buf) + return Text(self._buf or "") + + def _render_str(self) -> str: + """Render current buffer to a plain string via Rich.""" + with self._console.capture() as cap: + self._console.print(self._renderable()) + return cap.get() def _start_spinner(self) -> None: if self._show_spinner: @@ -99,36 +112,55 @@ class StreamRenderer: self._spinner.__exit__(None, None, None) self._spinner = None + @property + def console(self) -> Console: + """Expose the Live's console so external print functions can use it.""" + return self._console + + 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() + async def on_delta(self, delta: str) -> None: self.streamed = True self._buf += delta - if self._live is None: - if not self._buf.strip(): - return - self._stop_spinner() - c = _make_console() - c.print() - c.print(f"[cyan]{__logo__} nanobot[/cyan]") - self._live = Live(self._render(), console=c, auto_refresh=False) + if not self._header_printed and self._buf.strip(): + self._console.print() + self._console.print(f"[cyan]{__logo__} nanobot[/cyan]") + self._header_printed = True + self._stop_spinner() + if not self._live: + self._live = Live( + self._renderable(), + console=self._console, + auto_refresh=False, + transient=True, + ) self._live.start() - now = time.monotonic() - if (now - self._t) > 0.15: - self._live.update(self._render()) - self._live.refresh() - self._t = now + else: + self._live.update(self._renderable()) + self._live.refresh() async def on_end(self, *, resuming: bool = False) -> None: if self._live: - self._live.update(self._render()) + # Double-refresh to sync _shape before stop() calls refresh(). + self._live.refresh() + self._live.update(self._renderable()) self._live.refresh() self._live.stop() self._live = None self._stop_spinner() + if self._header_printed and self._buf.strip(): + # Print final rendered content (persists after Live is gone). + out = sys.stdout + out.write(self._render_str()) + out.flush() if resuming: self._buf = "" self._start_spinner() - else: - _make_console().print() def stop_for_input(self) -> None: """Stop spinner before user input to avoid prompt_toolkit conflicts.""" diff --git a/tests/cli/test_interactive_retry_wait.py b/tests/cli/test_interactive_retry_wait.py index 5cc217c56..e58102dcd 100644 --- a/tests/cli/test_interactive_retry_wait.py +++ b/tests/cli/test_interactive_retry_wait.py @@ -17,7 +17,7 @@ async def test_interactive_retry_wait_is_rendered_as_progress_even_when_progress metadata={"_retry_wait": True}, ) - async def fake_print(text: str, active_thinking: object | None) -> None: + async def fake_print(text: str, active_thinking: object | None, renderer=None) -> None: calls.append((text, active_thinking)) with patch("nanobot.cli.commands._print_interactive_progress_line", side_effect=fake_print):