refactor(cli): restore context manager pattern for spinner lifecycle

Replace manual _active_spinner + _pause_spinner/_resume_spinner with
_ThinkingSpinner class that owns the spinner lifecycle via __enter__/
__exit__ and provides a pause() context manager for temporarily
stopping the spinner during progress output.

Benefits:
- Restores Pythonic context manager pattern matching original code
- Eliminates duplicated start/stop boilerplate between single-message
  and interactive modes
- pause() context manager guarantees resume even if print raises
- _active flag prevents post-teardown resume from async callbacks
This commit is contained in:
who96
2026-03-16 12:45:44 +08:00
committed by Xubin Ren
parent 48fe92a8ad
commit 9a652fdd35

View File

@@ -1,6 +1,7 @@
"""CLI commands for nanobot.""" """CLI commands for nanobot."""
import asyncio import asyncio
from contextlib import contextmanager
import os import os
import select import select
import signal import signal
@@ -635,29 +636,40 @@ def agent(
) )
# Show spinner when logs are off (no output to miss); skip when logs are on # Show spinner when logs are off (no output to miss); skip when logs are on
def _make_spinner(): class _ThinkingSpinner:
if logs: """Context manager that owns spinner lifecycle with pause support."""
return None
return console.status("[dim]nanobot is thinking...[/dim]", spinner="dots")
# Shared reference so progress callbacks can pause/resume the spinner def __init__(self):
_active_spinner = None self._spinner = None if logs else console.status(
"[dim]nanobot is thinking...[/dim]", spinner="dots"
)
self._active = False
def _pause_spinner() -> None: def __enter__(self):
"""Temporarily stop the spinner before printing progress.""" if self._spinner:
if _active_spinner is not None: self._spinner.start()
self._active = True
return self
def __exit__(self, *exc):
self._active = False
if self._spinner:
self._spinner.stop()
return False
@contextmanager
def pause(self):
"""Temporarily stop spinner for clean console output."""
if self._spinner and self._active:
self._spinner.stop()
try: try:
_active_spinner.stop() yield
except Exception: finally:
pass if self._spinner and self._active:
self._spinner.start()
def _resume_spinner() -> None: # Shared reference for progress callbacks
"""Restart the spinner after printing progress.""" _thinking: _ThinkingSpinner | None = None
if _active_spinner is not None:
try:
_active_spinner.start()
except Exception:
pass
async def _cli_progress(content: str, *, tool_hint: bool = False) -> None: async def _cli_progress(content: str, *, tool_hint: bool = False) -> None:
ch = agent_loop.channels_config ch = agent_loop.channels_config
@@ -665,26 +677,20 @@ def agent(
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
_pause_spinner() if _thinking:
try: with _thinking.pause():
console.print(f" [dim]↳ {content}[/dim]")
else:
console.print(f" [dim]↳ {content}[/dim]") console.print(f" [dim]↳ {content}[/dim]")
finally:
_resume_spinner()
if message: if message:
# Single message mode — direct call, no bus needed # Single message mode — direct call, no bus needed
async def run_once(): async def run_once():
nonlocal _active_spinner nonlocal _thinking
spinner = _make_spinner() _thinking = _ThinkingSpinner()
_active_spinner = spinner with _thinking:
if spinner:
spinner.start()
try:
response = await agent_loop.process_direct(message, session_id, on_progress=_cli_progress) response = await agent_loop.process_direct(message, session_id, on_progress=_cli_progress)
finally: _thinking = None
if spinner:
spinner.stop()
_active_spinner = None
_print_agent_response(response, render_markdown=markdown) _print_agent_response(response, render_markdown=markdown)
await agent_loop.close_mcp() await agent_loop.close_mcp()
@@ -733,12 +739,11 @@ def agent(
pass pass
elif ch and not is_tool_hint and not ch.send_progress: elif ch and not is_tool_hint and not ch.send_progress:
pass pass
else: elif _thinking:
_pause_spinner() with _thinking.pause():
try: await _print_interactive_line(msg.content)
else:
await _print_interactive_line(msg.content) await _print_interactive_line(msg.content)
finally:
_resume_spinner()
elif not turn_done.is_set(): elif not turn_done.is_set():
if msg.content: if msg.content:
@@ -778,17 +783,11 @@ def agent(
content=user_input, content=user_input,
)) ))
nonlocal _active_spinner nonlocal _thinking
spinner = _make_spinner() _thinking = _ThinkingSpinner()
_active_spinner = spinner with _thinking:
if spinner:
spinner.start()
try:
await turn_done.wait() await turn_done.wait()
finally: _thinking = None
if spinner:
spinner.stop()
_active_spinner = None
if turn_response: if turn_response:
_print_agent_response(turn_response[0], render_markdown=markdown) _print_agent_response(turn_response[0], render_markdown=markdown)