From 48fe92a8adec7e700756ef75cb1f6fcfac0c52c0 Mon Sep 17 00:00:00 2001 From: who96 <825265100@qq.com> Date: Sun, 15 Mar 2026 15:26:26 +0800 Subject: [PATCH 01/10] fix(cli): stop spinner before printing tool progress lines The Rich console.status() spinner ('nanobot is thinking...') was not cleared when tool call progress lines were printed during processing, causing overlapping/garbled terminal output. Replace the context-manager approach with explicit start/stop lifecycle: - _pause_spinner() stops the spinner before any progress line is printed - _resume_spinner() restarts it after printing - Applied to both single-message mode (_cli_progress) and interactive mode (_consume_outbound) Closes #1956 --- nanobot/cli/commands.py | 59 +++++++++++++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 685c1be..de7e6c1 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -635,26 +635,56 @@ def agent( ) # Show spinner when logs are off (no output to miss); skip when logs are on - def _thinking_ctx(): + def _make_spinner(): if logs: - from contextlib import nullcontext - return nullcontext() - # Animated spinner is safe to use with prompt_toolkit input handling + return None return console.status("[dim]nanobot is thinking...[/dim]", spinner="dots") + # Shared reference so progress callbacks can pause/resume the spinner + _active_spinner = None + + def _pause_spinner() -> None: + """Temporarily stop the spinner before printing progress.""" + if _active_spinner is not None: + try: + _active_spinner.stop() + except Exception: + pass + + def _resume_spinner() -> None: + """Restart the spinner after printing progress.""" + if _active_spinner is not None: + try: + _active_spinner.start() + except Exception: + pass + async def _cli_progress(content: str, *, tool_hint: bool = False) -> 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 - console.print(f" [dim]↳ {content}[/dim]") + _pause_spinner() + try: + console.print(f" [dim]↳ {content}[/dim]") + finally: + _resume_spinner() if message: # Single message mode — direct call, no bus needed async def run_once(): - with _thinking_ctx(): + nonlocal _active_spinner + spinner = _make_spinner() + _active_spinner = spinner + if spinner: + spinner.start() + try: response = await agent_loop.process_direct(message, session_id, on_progress=_cli_progress) + finally: + if spinner: + spinner.stop() + _active_spinner = None _print_agent_response(response, render_markdown=markdown) await agent_loop.close_mcp() @@ -704,7 +734,11 @@ def agent( elif ch and not is_tool_hint and not ch.send_progress: pass else: - await _print_interactive_line(msg.content) + _pause_spinner() + try: + await _print_interactive_line(msg.content) + finally: + _resume_spinner() elif not turn_done.is_set(): if msg.content: @@ -744,8 +778,17 @@ def agent( content=user_input, )) - with _thinking_ctx(): + nonlocal _active_spinner + spinner = _make_spinner() + _active_spinner = spinner + if spinner: + spinner.start() + try: await turn_done.wait() + finally: + if spinner: + spinner.stop() + _active_spinner = None if turn_response: _print_agent_response(turn_response[0], render_markdown=markdown) From 9a652fdd359ac2421da831ceac2761a7fb9d3b13 Mon Sep 17 00:00:00 2001 From: who96 <825265100@qq.com> Date: Mon, 16 Mar 2026 12:45:44 +0800 Subject: [PATCH 02/10] 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 --- nanobot/cli/commands.py | 95 ++++++++++++++++++++--------------------- 1 file changed, 47 insertions(+), 48 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index de7e6c1..0c84d1a 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 import os import select import signal @@ -635,29 +636,40 @@ def agent( ) # Show spinner when logs are off (no output to miss); skip when logs are on - def _make_spinner(): - if logs: - return None - return console.status("[dim]nanobot is thinking...[/dim]", spinner="dots") + class _ThinkingSpinner: + """Context manager that owns spinner lifecycle with pause support.""" - # Shared reference so progress callbacks can pause/resume the spinner - _active_spinner = None + def __init__(self): + self._spinner = None if logs else console.status( + "[dim]nanobot is thinking...[/dim]", spinner="dots" + ) + self._active = False - def _pause_spinner() -> None: - """Temporarily stop the spinner before printing progress.""" - if _active_spinner is not None: + 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 for clean console output.""" + if self._spinner and self._active: + self._spinner.stop() try: - _active_spinner.stop() - except Exception: - pass + yield + finally: + if self._spinner and self._active: + self._spinner.start() - def _resume_spinner() -> None: - """Restart the spinner after printing progress.""" - if _active_spinner is not None: - try: - _active_spinner.start() - except Exception: - pass + # 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 @@ -665,26 +677,20 @@ def agent( return if ch and not tool_hint and not ch.send_progress: return - _pause_spinner() - try: + if _thinking: + with _thinking.pause(): + console.print(f" [dim]↳ {content}[/dim]") + else: console.print(f" [dim]↳ {content}[/dim]") - finally: - _resume_spinner() if message: # Single message mode — direct call, no bus needed async def run_once(): - nonlocal _active_spinner - spinner = _make_spinner() - _active_spinner = spinner - if spinner: - spinner.start() - try: + nonlocal _thinking + _thinking = _ThinkingSpinner() + with _thinking: response = await agent_loop.process_direct(message, session_id, on_progress=_cli_progress) - finally: - if spinner: - spinner.stop() - _active_spinner = None + _thinking = None _print_agent_response(response, render_markdown=markdown) await agent_loop.close_mcp() @@ -733,12 +739,11 @@ def agent( pass elif ch and not is_tool_hint and not ch.send_progress: pass - else: - _pause_spinner() - try: + elif _thinking: + with _thinking.pause(): await _print_interactive_line(msg.content) - finally: - _resume_spinner() + else: + await _print_interactive_line(msg.content) elif not turn_done.is_set(): if msg.content: @@ -778,17 +783,11 @@ def agent( content=user_input, )) - nonlocal _active_spinner - spinner = _make_spinner() - _active_spinner = spinner - if spinner: - spinner.start() - try: + nonlocal _thinking + _thinking = _ThinkingSpinner() + with _thinking: await turn_done.wait() - finally: - if spinner: - spinner.stop() - _active_spinner = None + _thinking = None if turn_response: _print_agent_response(turn_response[0], render_markdown=markdown) From 2eceb6ce8a5a479fb28e4d2a15c3591b64cad30c Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 16 Mar 2026 14:13:21 +0000 Subject: [PATCH 03/10] fix(cli): pause spinner cleanly before printing progress output --- nanobot/cli/commands.py | 95 ++++++++++++++++++++++------------------- tests/test_cli_input.py | 56 +++++++++++++++++++++++- 2 files changed, 105 insertions(+), 46 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 0c84d1a..c2ff3ed 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -1,7 +1,7 @@ """CLI commands for nanobot.""" import asyncio -from contextlib import contextmanager +from contextlib import contextmanager, nullcontext import os import select import signal @@ -170,6 +170,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 @@ -635,39 +680,6 @@ def agent( channels_config=config.channels, ) - # Show spinner when logs are off (no output to miss); skip when logs are on - class _ThinkingSpinner: - """Context manager that owns spinner lifecycle with pause support.""" - - def __init__(self): - self._spinner = None if logs else console.status( - "[dim]nanobot is thinking...[/dim]", spinner="dots" - ) - 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 for clean console output.""" - if self._spinner and self._active: - self._spinner.stop() - try: - yield - finally: - if self._spinner and self._active: - self._spinner.start() - # Shared reference for progress callbacks _thinking: _ThinkingSpinner | None = None @@ -677,17 +689,13 @@ def agent( return if ch and not tool_hint and not ch.send_progress: return - if _thinking: - with _thinking.pause(): - console.print(f" [dim]↳ {content}[/dim]") - else: - 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(): nonlocal _thinking - _thinking = _ThinkingSpinner() + _thinking = _ThinkingSpinner(enabled=not logs) with _thinking: response = await agent_loop.process_direct(message, session_id, on_progress=_cli_progress) _thinking = None @@ -739,11 +747,8 @@ def agent( pass elif ch and not is_tool_hint and not ch.send_progress: pass - elif _thinking: - with _thinking.pause(): - await _print_interactive_line(msg.content) else: - await _print_interactive_line(msg.content) + await _print_interactive_progress_line(msg.content, _thinking) elif not turn_done.is_set(): if msg.content: @@ -784,7 +789,7 @@ def agent( )) nonlocal _thinking - _thinking = _ThinkingSpinner() + _thinking = _ThinkingSpinner(enabled=not logs) with _thinking: await turn_done.wait() _thinking = None 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"] From ad1e9b20934182a5d3d3c22a551a0d82e92ffa4e Mon Sep 17 00:00:00 2001 From: Peter van Eijk Date: Sun, 22 Feb 2026 21:09:37 +0700 Subject: [PATCH 04/10] pull remote --- .claude/settings.local.json | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..531d5b4 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(gh pr diff:*)", + "Bash(git checkout:*)", + "Bash(git fetch:*)" + ] + } +} From 93f363d4d3cd896dfcd893370bf796bf721f5164 Mon Sep 17 00:00:00 2001 From: Peter van Eijk Date: Sun, 15 Mar 2026 21:52:50 +0700 Subject: [PATCH 05/10] qol: add version id to logging --- nanobot/cli/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index c2ff3ed..659dd94 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -461,7 +461,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) From 4e67bea6976385d10fef118cb2e2efaa502c27dd Mon Sep 17 00:00:00 2001 From: Peter Date: Sun, 15 Mar 2026 23:06:42 +0700 Subject: [PATCH 06/10] Delete .claude directory --- .claude/settings.local.json | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 531d5b4..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(gh pr diff:*)", - "Bash(git checkout:*)", - "Bash(git fetch:*)" - ] - } -} From dbe9cbc78e317b873aa6f4cb957fdf21e3b7e9de Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 16 Mar 2026 14:27:28 +0000 Subject: [PATCH 07/10] docs: update news section --- README.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 424d290..99f717b 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,20 @@ ## 📢 News +- **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 +42,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. From 337c4600f3d78797bb4ed845b5a02118c7ac2d00 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 16 Mar 2026 15:11:15 +0000 Subject: [PATCH 08/10] bump version to 0.1.4.post5 --- nanobot/__init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/pyproject.toml b/pyproject.toml index ff2891d..b19b690 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nanobot-ai" -version = "0.1.4.post4" +version = "0.1.4.post5" description = "A lightweight personal AI assistant framework" requires-python = ">=3.11" license = {text = "MIT"} From df7ad91c57b13b0e5c2fe88fb65feaf3494d4182 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 16 Mar 2026 15:27:40 +0000 Subject: [PATCH 09/10] docs: update to v0.1.4.post5 release --- README.md | 1 + pyproject.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index 99f717b..f936701 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ ## 📢 News +- **2026-03-16** 🚀 Released **v0.1.4.post5** — a refinement-focused release with stronger reliability, broader provider 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. diff --git a/pyproject.toml b/pyproject.toml index b19b690..25ef590 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,6 +2,7 @@ name = "nanobot-ai" 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 = [ From 84565d702c314e67843751bd8dbd221ad434578d Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 16 Mar 2026 15:28:41 +0000 Subject: [PATCH 10/10] docs: update v0.1.4.post5 release news --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f936701..0b07871 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ ## 📢 News -- **2026-03-16** 🚀 Released **v0.1.4.post5** — a refinement-focused release with stronger reliability, broader provider 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-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.