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)
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

View File

@ -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 <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:
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."""

View File

@ -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):