diff --git a/README.md b/README.md index c78f113..a1f374c 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,21 @@ ## ๐Ÿ“ข News +- **2026-03-16** ๐Ÿš€ Released **v0.1.4.post5** โ€” a refinement-focused release with stronger reliability and channel support, and a more dependable day-to-day experience. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post5) for details. +- **2026-03-15** ๐Ÿงฉ DingTalk rich media, smarter built-in skills, and cleaner model compatibility. +- **2026-03-14** ๐Ÿ’ฌ Channel plugins, Feishu replies, and steadier MCP, QQ, and media handling. +- **2026-03-13** ๐ŸŒ Multi-provider web search, LangSmith, and broader reliability improvements. +- **2026-03-12** ๐Ÿš€ VolcEngine support, Telegram reply context, `/restart`, and sturdier memory. +- **2026-03-11** ๐Ÿ”Œ WeCom, Ollama, cleaner discovery, and safer tool behavior. +- **2026-03-10** ๐Ÿง  Token-based memory, shared retries, and cleaner gateway and Telegram behavior. +- **2026-03-09** ๐Ÿ’ฌ Slack thread polish and better Feishu audio compatibility. - **2026-03-08** ๐Ÿš€ Released **v0.1.4.post4** โ€” a reliability-packed release with safer defaults, better multi-instance support, sturdier MCP, and major channel and provider improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post4) for details. - **2026-03-07** ๐Ÿš€ Azure OpenAI provider, WhatsApp media, QQ group chats, and more Telegram/Feishu polish. - **2026-03-06** ๐Ÿช„ Lighter providers, smarter media handling, and sturdier memory and CLI compatibility. + +
+Earlier news + - **2026-03-05** โšก๏ธ Telegram draft streaming, MCP SSE support, and broader channel reliability fixes. - **2026-03-04** ๐Ÿ› ๏ธ Dependency cleanup, safer file reads, and another round of test and Cron fixes. - **2026-03-03** ๐Ÿง  Cleaner user-message merging, safer multimodal saves, and stronger Cron guards. @@ -31,10 +43,6 @@ - **2026-02-28** ๐Ÿš€ Released **v0.1.4.post3** โ€” cleaner context, hardened session history, and smarter agent. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post3) for details. - **2026-02-27** ๐Ÿง  Experimental thinking mode support, DingTalk media messages, Feishu and QQ channel fixes. - **2026-02-26** ๐Ÿ›ก๏ธ Session poisoning fix, WhatsApp dedup, Windows path guard, Mistral compatibility. - -
-Earlier news - - **2026-02-25** ๐Ÿงน New Matrix channel, cleaner session context, auto workspace template sync. - **2026-02-24** ๐Ÿš€ Released **v0.1.4.post2** โ€” a reliability-focused release with a redesigned heartbeat, prompt cache optimization, and hardened provider & channel stability. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post2) for details. - **2026-02-23** ๐Ÿ”ง Virtual tool-call heartbeat, prompt cache optimization, Slack mrkdwn fixes. diff --git a/nanobot/__init__.py b/nanobot/__init__.py index d331109..bdaf077 100644 --- a/nanobot/__init__.py +++ b/nanobot/__init__.py @@ -2,5 +2,5 @@ nanobot - A lightweight AI agent framework """ -__version__ = "0.1.4.post4" +__version__ = "0.1.4.post5" __logo__ = "๐Ÿˆ" diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 736dd41..87a27a2 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -1,6 +1,7 @@ """CLI commands for nanobot.""" import asyncio +from contextlib import contextmanager, nullcontext import os import select import signal @@ -168,6 +169,51 @@ async def _print_interactive_response(response: str, render_markdown: bool) -> N await run_in_terminal(_write) +class _ThinkingSpinner: + """Spinner wrapper with pause support for clean progress output.""" + + def __init__(self, enabled: bool): + self._spinner = console.status( + "[dim]nanobot is thinking...[/dim]", spinner="dots" + ) if enabled else None + self._active = False + + def __enter__(self): + if self._spinner: + 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 while printing progress.""" + if self._spinner and self._active: + self._spinner.stop() + try: + yield + finally: + if self._spinner and self._active: + self._spinner.start() + + +def _print_cli_progress_line(text: str, thinking: _ThinkingSpinner | None) -> None: + """Print a CLI progress line, pausing the spinner if needed.""" + with thinking.pause() if thinking else nullcontext(): + console.print(f" [dim]โ†ณ {text}[/dim]") + + +async def _print_interactive_progress_line(text: str, thinking: _ThinkingSpinner | None) -> None: + """Print an interactive progress line, pausing the spinner if needed.""" + with thinking.pause() if thinking else nullcontext(): + await _print_interactive_line(text) + + def _is_exit_command(command: str) -> bool: """Return True when input should end interactive chat.""" return command.lower() in EXIT_COMMANDS @@ -377,7 +423,7 @@ def gateway( _print_deprecated_memory_window_notice(config) port = port if port is not None else config.gateway.port - console.print(f"{__logo__} Starting nanobot gateway on port {port}...") + console.print(f"{__logo__} Starting nanobot gateway version {__version__} on port {port}...") sync_workspace_templates(config.workspace_path) bus = MessageBus() provider = _make_provider(config) @@ -593,13 +639,8 @@ def agent( channels_config=config.channels, ) - # Show spinner when logs are off (no output to miss); skip when logs are on - def _thinking_ctx(): - if logs: - from contextlib import nullcontext - return nullcontext() - # Animated spinner is safe to use with prompt_toolkit input handling - return console.status("[dim]nanobot is thinking...[/dim]", spinner="dots") + # Shared reference for progress callbacks + _thinking: _ThinkingSpinner | None = None async def _cli_progress(content: str, *, tool_hint: bool = False) -> None: ch = agent_loop.channels_config @@ -607,13 +648,16 @@ def agent( return if ch and not tool_hint and not ch.send_progress: return - console.print(f" [dim]โ†ณ {content}[/dim]") + _print_cli_progress_line(content, _thinking) if message: # Single message mode โ€” direct call, no bus needed async def run_once(): - with _thinking_ctx(): + nonlocal _thinking + _thinking = _ThinkingSpinner(enabled=not logs) + with _thinking: response = await agent_loop.process_direct(message, session_id, on_progress=_cli_progress) + _thinking = None _print_agent_response(response, render_markdown=markdown) await agent_loop.close_mcp() @@ -663,7 +707,7 @@ def agent( elif ch and not is_tool_hint and not ch.send_progress: pass else: - await _print_interactive_line(msg.content) + await _print_interactive_progress_line(msg.content, _thinking) elif not turn_done.is_set(): if msg.content: @@ -703,8 +747,11 @@ def agent( content=user_input, )) - with _thinking_ctx(): + nonlocal _thinking + _thinking = _ThinkingSpinner(enabled=not logs) + with _thinking: await turn_done.wait() + _thinking = None if turn_response: _print_agent_response(turn_response[0], render_markdown=markdown) diff --git a/pyproject.toml b/pyproject.toml index 38b7cf7..b9a63b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,8 @@ [project] name = "nanobot-ai" -version = "0.1.4.post4" +version = "0.1.4.post5" description = "A lightweight personal AI assistant framework" +readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" license = {text = "MIT"} authors = [ diff --git a/tests/test_cli_input.py b/tests/test_cli_input.py index 9626120..e77bc13 100644 --- a/tests/test_cli_input.py +++ b/tests/test_cli_input.py @@ -1,5 +1,5 @@ import asyncio -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, call, patch import pytest from prompt_toolkit.formatted_text import HTML @@ -57,3 +57,57 @@ def test_init_prompt_session_creates_session(): _, kwargs = MockSession.call_args assert kwargs["multiline"] is False assert kwargs["enable_open_in_editor"] is False + + +def test_thinking_spinner_pause_stops_and_restarts(): + """Pause should stop the active spinner and restart it afterward.""" + spinner = MagicMock() + + with patch.object(commands.console, "status", return_value=spinner): + thinking = commands._ThinkingSpinner(enabled=True) + with thinking: + with thinking.pause(): + pass + + assert spinner.method_calls == [ + call.start(), + call.stop(), + call.start(), + call.stop(), + ] + + +def test_print_cli_progress_line_pauses_spinner_before_printing(): + """CLI progress output should pause spinner to avoid garbled lines.""" + order: list[str] = [] + spinner = MagicMock() + spinner.start.side_effect = lambda: order.append("start") + spinner.stop.side_effect = lambda: order.append("stop") + + with patch.object(commands.console, "status", return_value=spinner), \ + patch.object(commands.console, "print", side_effect=lambda *_args, **_kwargs: order.append("print")): + thinking = commands._ThinkingSpinner(enabled=True) + with thinking: + commands._print_cli_progress_line("tool running", thinking) + + assert order == ["start", "stop", "print", "start", "stop"] + + +@pytest.mark.asyncio +async def test_print_interactive_progress_line_pauses_spinner_before_printing(): + """Interactive progress output should also pause spinner cleanly.""" + order: list[str] = [] + spinner = MagicMock() + spinner.start.side_effect = lambda: order.append("start") + spinner.stop.side_effect = lambda: order.append("stop") + + async def fake_print(_text: str) -> None: + order.append("print") + + with patch.object(commands.console, "status", return_value=spinner), \ + patch("nanobot.cli.commands._print_interactive_line", side_effect=fake_print): + thinking = commands._ThinkingSpinner(enabled=True) + with thinking: + await commands._print_interactive_progress_line("tool running", thinking) + + assert order == ["start", "stop", "print", "start", "stop"]