From d630ac90d1b88086e79ba595bedfe0abab66eb74 Mon Sep 17 00:00:00 2001 From: Flinn Xie Date: Wed, 6 May 2026 01:34:23 +0800 Subject: [PATCH] fix(cli): prevent TUI content duplication via transient Live and renderer routing Route progress output through the Live's render hook to fix cursor misalignment that caused content duplication. The root cause was that progress/reasoning output used a separate Console instance, bypassing Rich Live's process_renderables hook. Also fixes pre-existing issue where multiple headers printed per agent turn. Co-Authored-By: Claude Opus 4.7 --- nanobot/cli/commands.py | 46 ++++++++----- nanobot/cli/stream.py | 86 ++++++++++++++++-------- tests/cli/test_interactive_retry_wait.py | 2 +- 3 files changed, 88 insertions(+), 46 deletions(-) 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):