diff --git a/nanobot/cli/stream.py b/nanobot/cli/stream.py index 382ae9aac..899950fb6 100644 --- a/nanobot/cli/stream.py +++ b/nanobot/cli/stream.py @@ -18,6 +18,16 @@ from rich.markdown import Markdown from rich.text import Text +def _clear_current_line(console: Console) -> None: + """Erase a transient status line before printing persistent output.""" + file = console.file + isatty = getattr(file, "isatty", lambda: False) + if not isatty(): + return + file.write("\r\x1b[2K") + file.flush() + + def _make_console() -> Console: """Create a Console that emits plain text when stdout is not a TTY. @@ -37,6 +47,7 @@ class ThinkingSpinner: def __init__(self, console: Console | None = None, bot_name: str = "nanobot"): c = console or _make_console() + self._console = c self._spinner = c.status(f"[dim]{bot_name} is thinking...[/dim]", spinner="dots") self._active = False @@ -48,6 +59,7 @@ class ThinkingSpinner: def __exit__(self, *exc): self._active = False self._spinner.stop() + _clear_current_line(self._console) return False def pause(self): @@ -58,6 +70,7 @@ class ThinkingSpinner: def _ctx(): if self._spinner and self._active: self._spinner.stop() + _clear_current_line(self._console) try: yield finally: diff --git a/tests/cli/test_cli_input.py b/tests/cli/test_cli_input.py index 8b7a79cfc..3f5619c4f 100644 --- a/tests/cli/test_cli_input.py +++ b/tests/cli/test_cli_input.py @@ -1,5 +1,6 @@ import asyncio from contextlib import nullcontext +from io import StringIO from unittest.mock import AsyncMock, MagicMock, call, patch import pytest @@ -97,6 +98,23 @@ def test_print_cli_progress_line_pauses_spinner_before_printing(): assert order == ["start", "stop", "print", "start", "stop"] +def test_thinking_spinner_clears_status_line_when_paused(): + """Stopping the spinner should erase its transient line before output.""" + stream = StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + mock_console = MagicMock() + mock_console.file = stream + spinner = MagicMock() + mock_console.status.return_value = spinner + + thinking = stream_mod.ThinkingSpinner(console=mock_console) + with thinking: + with thinking.pause(): + pass + + assert "\r\x1b[2K" in stream.getvalue() + + 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] = []