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 <noreply@anthropic.com>
This commit is contained in:
Flinn Xie 2026-05-06 01:34:23 +08:00
parent 7c1aa5ae31
commit d630ac90d1
3 changed files with 88 additions and 46 deletions

View File

@ -227,30 +227,37 @@ async def _print_interactive_response(
await run_in_terminal(_write) 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.""" """Print a CLI progress line, pausing the spinner if needed."""
if not text.strip(): if not text.strip():
return return
with thinking.pause() if thinking else nullcontext(): target = renderer.console if renderer else console
console.print(f" [dim]↳ {text}[/dim]") 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: async def _print_interactive_progress_line(text: str, thinking: ThinkingSpinner | None, renderer: StreamRenderer | None = None) -> None:
"""Print an interactive progress line, pausing the renderer's spinner if needed.""" """Print an interactive progress line, pausing the spinner if needed."""
if not text.strip(): if not text.strip():
return return
with renderer.pause() if renderer else nullcontext(): 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) await _print_interactive_line(text)
async def _maybe_print_interactive_progress( async def _maybe_print_interactive_progress(
msg: Any, msg: Any,
renderer: StreamRenderer | None, thinking: ThinkingSpinner | None,
channels_config: Any, channels_config: Any,
renderer: StreamRenderer | None = None,
) -> bool: ) -> bool:
metadata = msg.metadata or {} metadata = msg.metadata or {}
if metadata.get("_retry_wait"): if metadata.get("_retry_wait"):
await _print_interactive_progress_line(msg.content, renderer) await _print_interactive_progress_line(msg.content, thinking, renderer)
return True return True
if not metadata.get("_progress"): 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: if channels_config and not is_tool_hint and not channels_config.send_progress:
return True return True
await _print_interactive_progress_line(msg.content, renderer) await _print_interactive_progress_line(msg.content, thinking, renderer)
return True return True
@ -1121,13 +1128,15 @@ def agent(
# Shared reference for progress callbacks # Shared reference for progress callbacks
_thinking: ThinkingSpinner | None = None _thinking: ThinkingSpinner | None = None
def _make_progress(renderer: StreamRenderer | None = None):
async def _cli_progress(content: str, *, tool_hint: bool = False, **_kwargs: Any) -> None: async def _cli_progress(content: str, *, tool_hint: bool = False, **_kwargs: Any) -> None:
ch = agent_loop.channels_config ch = agent_loop.channels_config
if ch and tool_hint and not ch.send_tool_hints: if ch and tool_hint and not ch.send_tool_hints:
return return
if ch and not tool_hint and not ch.send_progress: if ch and not tool_hint and not ch.send_progress:
return return
_print_cli_progress_line(content, _thinking) _print_cli_progress_line(content, _thinking, renderer)
return _cli_progress
if message: if message:
# Single message mode — direct call, no bus needed # Single message mode — direct call, no bus needed
@ -1135,7 +1144,7 @@ def agent(
renderer = StreamRenderer(render_markdown=markdown) renderer = StreamRenderer(render_markdown=markdown)
response = await agent_loop.process_direct( response = await agent_loop.process_direct(
message, session_id, message, session_id,
on_progress=_cli_progress, on_progress=_make_progress(renderer),
on_stream=renderer.on_delta, on_stream=renderer.on_delta,
on_stream_end=renderer.on_end, on_stream_end=renderer.on_end,
) )
@ -1206,6 +1215,7 @@ def agent(
msg, msg,
renderer, renderer,
agent_loop.channels_config, agent_loop.channels_config,
renderer,
): ):
continue continue

View File

@ -1,13 +1,15 @@
"""Streaming renderer for CLI output. """Streaming renderer for CLI output.
Uses Rich Live with auto_refresh=False for stable, flicker-free Uses Rich Live with ``transient=True`` for in-place markdown updates during
markdown rendering during streaming. Ellipsis mode handles overflow. 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 from __future__ import annotations
import sys import sys
import time
from rich.console import Console from rich.console import Console
from rich.live import Live from rich.live import Live
@ -67,27 +69,38 @@ class ThinkingSpinner:
class StreamRenderer: 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 <think> 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: Flow per round:
spinner -> first visible delta -> header + Live renders -> spinner -> first delta -> header + Live updates ->
on_end -> Live stops (content stays on screen) on_end -> stop Live + final render
""" """
def __init__(self, render_markdown: bool = True, show_spinner: bool = True): def __init__(self, render_markdown: bool = True, show_spinner: bool = True):
self._md = render_markdown self._md = render_markdown
self._show_spinner = show_spinner self._show_spinner = show_spinner
self._buf = "" self._buf = ""
self._live: Live | None = None
self._t = 0.0
self.streamed = False self.streamed = False
self._header_printed = False
self._console = _make_console()
self._live: Live | None = None
self._spinner: ThinkingSpinner | None = None self._spinner: ThinkingSpinner | None = None
self._start_spinner() self._start_spinner()
def _render(self): def _renderable(self):
return Markdown(self._buf) if self._md and self._buf else Text(self._buf or "") """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: def _start_spinner(self) -> None:
if self._show_spinner: if self._show_spinner:
@ -99,36 +112,55 @@ class StreamRenderer:
self._spinner.__exit__(None, None, None) self._spinner.__exit__(None, None, None)
self._spinner = 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: async def on_delta(self, delta: str) -> None:
self.streamed = True self.streamed = True
self._buf += delta self._buf += delta
if self._live is None: if not self._header_printed and self._buf.strip():
if not self._buf.strip(): self._console.print()
return self._console.print(f"[cyan]{__logo__} nanobot[/cyan]")
self._header_printed = True
self._stop_spinner() self._stop_spinner()
c = _make_console() if not self._live:
c.print() self._live = Live(
c.print(f"[cyan]{__logo__} nanobot[/cyan]") self._renderable(),
self._live = Live(self._render(), console=c, auto_refresh=False) console=self._console,
auto_refresh=False,
transient=True,
)
self._live.start() self._live.start()
now = time.monotonic() else:
if (now - self._t) > 0.15: self._live.update(self._renderable())
self._live.update(self._render())
self._live.refresh() self._live.refresh()
self._t = now
async def on_end(self, *, resuming: bool = False) -> None: async def on_end(self, *, resuming: bool = False) -> None:
if self._live: 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.refresh()
self._live.stop() self._live.stop()
self._live = None self._live = None
self._stop_spinner() 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: if resuming:
self._buf = "" self._buf = ""
self._start_spinner() self._start_spinner()
else:
_make_console().print()
def stop_for_input(self) -> None: def stop_for_input(self) -> None:
"""Stop spinner before user input to avoid prompt_toolkit conflicts.""" """Stop spinner before user input to avoid prompt_toolkit conflicts."""

View File

@ -17,7 +17,7 @@ async def test_interactive_retry_wait_is_rendered_as_progress_even_when_progress
metadata={"_retry_wait": True}, 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)) calls.append((text, active_thinking))
with patch("nanobot.cli.commands._print_interactive_progress_line", side_effect=fake_print): with patch("nanobot.cli.commands._print_interactive_progress_line", side_effect=fake_print):