From 3561b6a63d255d2c0a2004a94c6f3e02bafd1b60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=B6=94=E7=86=99?= Date: Wed, 11 Feb 2026 10:23:58 +0800 Subject: [PATCH 1/6] feat(cli): rewrite input layer with prompt_toolkit and polish UI - Replaces fragile input() hacks with robust prompt_toolkit.PromptSession - Native support for multiline paste, history, and clean display - Restores animated spinner in _thinking_ctx (now safe) - Replaces boxed Panel with clean header for easier copying - Adds prompt-toolkit dependency - Adds new unit tests for input layer --- nanobot/cli/commands.py | 110 +++++++++++++--------------------------- pyproject.toml | 1 + tests/test_cli_input.py | 58 +++++++++++++++++++++ 3 files changed, 95 insertions(+), 74 deletions(-) create mode 100644 tests/test_cli_input.py diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index a200e67..95aaeb4 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -1,7 +1,6 @@ """CLI commands for nanobot.""" import asyncio -import atexit import os import signal from pathlib import Path @@ -15,6 +14,11 @@ from rich.panel import Panel from rich.table import Table from rich.text import Text +from prompt_toolkit import PromptSession +from prompt_toolkit.formatted_text import HTML +from prompt_toolkit.history import FileHistory +from prompt_toolkit.patch_stdout import patch_stdout + from nanobot import __version__, __logo__ app = typer.Typer( @@ -27,13 +31,10 @@ console = Console() EXIT_COMMANDS = {"exit", "quit", "/exit", "/quit", ":q"} # --------------------------------------------------------------------------- -# Lightweight CLI input: readline for arrow keys / history, termios for flush +# CLI input: prompt_toolkit for editing, paste, history, and display # --------------------------------------------------------------------------- -_READLINE = None -_HISTORY_FILE: Path | None = None -_HISTORY_HOOK_REGISTERED = False -_USING_LIBEDIT = False +_PROMPT_SESSION: PromptSession | None = None _SAVED_TERM_ATTRS = None # original termios settings, restored on exit @@ -64,15 +65,6 @@ def _flush_pending_tty_input() -> None: return -def _save_history() -> None: - if _READLINE is None or _HISTORY_FILE is None: - return - try: - _READLINE.write_history_file(str(_HISTORY_FILE)) - except Exception: - return - - def _restore_terminal() -> None: """Restore terminal to its original state (echo, line buffering, etc.).""" if _SAVED_TERM_ATTRS is None: @@ -84,11 +76,11 @@ def _restore_terminal() -> None: pass -def _enable_line_editing() -> None: - """Enable readline for arrow keys, line editing, and persistent history.""" - global _READLINE, _HISTORY_FILE, _HISTORY_HOOK_REGISTERED, _USING_LIBEDIT, _SAVED_TERM_ATTRS +def _init_prompt_session() -> None: + """Create the prompt_toolkit session with persistent file history.""" + global _PROMPT_SESSION, _SAVED_TERM_ATTRS - # Save terminal state before readline touches it + # Save terminal state so we can restore it on exit try: import termios _SAVED_TERM_ATTRS = termios.tcgetattr(sys.stdin.fileno()) @@ -97,59 +89,22 @@ def _enable_line_editing() -> None: history_file = Path.home() / ".nanobot" / "history" / "cli_history" history_file.parent.mkdir(parents=True, exist_ok=True) - _HISTORY_FILE = history_file - try: - import readline - except ImportError: - return - - _READLINE = readline - _USING_LIBEDIT = "libedit" in (readline.__doc__ or "").lower() - - try: - if _USING_LIBEDIT: - readline.parse_and_bind("bind ^I rl_complete") - else: - readline.parse_and_bind("tab: complete") - readline.parse_and_bind("set editing-mode emacs") - except Exception: - pass - - try: - readline.read_history_file(str(history_file)) - except Exception: - pass - - if not _HISTORY_HOOK_REGISTERED: - atexit.register(_save_history) - _HISTORY_HOOK_REGISTERED = True - - -def _prompt_text() -> str: - """Build a readline-friendly colored prompt.""" - if _READLINE is None: - return "You: " - # libedit on macOS does not honor GNU readline non-printing markers. - if _USING_LIBEDIT: - return "\033[1;34mYou:\033[0m " - return "\001\033[1;34m\002You:\001\033[0m\002 " + _PROMPT_SESSION = PromptSession( + history=FileHistory(str(history_file)), + enable_open_in_editor=False, + multiline=False, # Enter submits (single line mode) + ) def _print_agent_response(response: str, render_markdown: bool) -> None: - """Render assistant response with consistent terminal styling.""" + """Render assistant response with clean, copy-friendly header.""" content = response or "" body = Markdown(content) if render_markdown else Text(content) console.print() - console.print( - Panel( - body, - title=f"{__logo__} nanobot", - title_align="left", - border_style="cyan", - padding=(0, 1), - ) - ) + # Use a simple header instead of a Panel box, making it easier to copy text + console.print(f"{__logo__} [bold cyan]nanobot[/bold cyan]") + console.print(body) console.print() @@ -159,13 +114,25 @@ def _is_exit_command(command: str) -> bool: async def _read_interactive_input_async() -> str: - """Read user input with arrow keys and history (runs input() in a thread).""" + """Read user input using prompt_toolkit (handles paste, history, display). + + prompt_toolkit natively handles: + - Multiline paste (bracketed paste mode) + - History navigation (up/down arrows) + - Clean display (no ghost characters or artifacts) + """ + if _PROMPT_SESSION is None: + raise RuntimeError("Call _init_prompt_session() first") try: - return await asyncio.to_thread(input, _prompt_text()) + with patch_stdout(): + return await _PROMPT_SESSION.prompt_async( + HTML("You: "), + ) except EOFError as exc: raise KeyboardInterrupt from exc + def version_callback(value: bool): if value: console.print(f"{__logo__} nanobot v{__version__}") @@ -473,6 +440,7 @@ def agent( 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") if message: @@ -485,13 +453,10 @@ def agent( asyncio.run(run_once()) else: # Interactive mode - _enable_line_editing() + _init_prompt_session() console.print(f"{__logo__} Interactive mode (type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit)\n") - # input() runs in a worker thread that can't be cancelled. - # Without this handler, asyncio.run() would hang waiting for it. def _exit_on_sigint(signum, frame): - _save_history() _restore_terminal() console.print("\nGoodbye!") os._exit(0) @@ -508,7 +473,6 @@ def agent( continue if _is_exit_command(command): - _save_history() _restore_terminal() console.print("\nGoodbye!") break @@ -517,12 +481,10 @@ def agent( response = await agent_loop.process_direct(user_input, session_id) _print_agent_response(response, render_markdown=markdown) except KeyboardInterrupt: - _save_history() _restore_terminal() console.print("\nGoodbye!") break except EOFError: - _save_history() _restore_terminal() console.print("\nGoodbye!") break diff --git a/pyproject.toml b/pyproject.toml index 3c2fec9..b1b3c81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ dependencies = [ "slack-sdk>=3.26.0", "qq-botpy>=1.0.0", "python-socks[asyncio]>=2.4.0", + "prompt-toolkit>=3.0.0", ] [project.optional-dependencies] diff --git a/tests/test_cli_input.py b/tests/test_cli_input.py new file mode 100644 index 0000000..6f9c257 --- /dev/null +++ b/tests/test_cli_input.py @@ -0,0 +1,58 @@ +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from prompt_toolkit.formatted_text import HTML + +from nanobot.cli import commands + + +@pytest.fixture +def mock_prompt_session(): + """Mock the global prompt session.""" + mock_session = MagicMock() + mock_session.prompt_async = AsyncMock() + with patch("nanobot.cli.commands._PROMPT_SESSION", mock_session): + yield mock_session + + +@pytest.mark.asyncio +async def test_read_interactive_input_async_returns_input(mock_prompt_session): + """Test that _read_interactive_input_async returns the user input from prompt_session.""" + mock_prompt_session.prompt_async.return_value = "hello world" + + result = await commands._read_interactive_input_async() + + assert result == "hello world" + mock_prompt_session.prompt_async.assert_called_once() + args, _ = mock_prompt_session.prompt_async.call_args + assert isinstance(args[0], HTML) # Verify HTML prompt is used + + +@pytest.mark.asyncio +async def test_read_interactive_input_async_handles_eof(mock_prompt_session): + """Test that EOFError converts to KeyboardInterrupt.""" + mock_prompt_session.prompt_async.side_effect = EOFError() + + with pytest.raises(KeyboardInterrupt): + await commands._read_interactive_input_async() + + +def test_init_prompt_session_creates_session(): + """Test that _init_prompt_session initializes the global session.""" + # Ensure global is None before test + commands._PROMPT_SESSION = None + + with patch("nanobot.cli.commands.PromptSession") as MockSession, \ + patch("nanobot.cli.commands.FileHistory") as MockHistory, \ + patch("pathlib.Path.home") as mock_home: + + mock_home.return_value = MagicMock() + + commands._init_prompt_session() + + assert commands._PROMPT_SESSION is not None + MockSession.assert_called_once() + _, kwargs = MockSession.call_args + assert kwargs["multiline"] is False + assert kwargs["enable_open_in_editor"] is False From 33930d1265496027e9a73b9ec1b3528df3f93bda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=B6=94=E7=86=99?= Date: Wed, 11 Feb 2026 10:39:35 +0800 Subject: [PATCH 2/6] feat(cli): revert panel removal (keep frame), preserve input rewrite --- nanobot/cli/commands.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 95aaeb4..d8e48e1 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -98,13 +98,19 @@ def _init_prompt_session() -> None: def _print_agent_response(response: str, render_markdown: bool) -> None: - """Render assistant response with clean, copy-friendly header.""" + """Render assistant response with consistent terminal styling.""" content = response or "" body = Markdown(content) if render_markdown else Text(content) console.print() - # Use a simple header instead of a Panel box, making it easier to copy text - console.print(f"{__logo__} [bold cyan]nanobot[/bold cyan]") - console.print(body) + console.print( + Panel( + body, + title=f"{__logo__} nanobot", + title_align="left", + border_style="cyan", + padding=(0, 1), + ) + ) console.print() From 9d304d8a41a0c495821bc28fe123ace6eca77082 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Wed, 11 Feb 2026 09:37:49 +0000 Subject: [PATCH 3/6] refactor: remove Panel border from CLI output for cleaner copy-paste --- nanobot/cli/commands.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index d8e48e1..aa99d55 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -10,7 +10,6 @@ import sys import typer from rich.console import Console from rich.markdown import Markdown -from rich.panel import Panel from rich.table import Table from rich.text import Text @@ -102,15 +101,8 @@ def _print_agent_response(response: str, render_markdown: bool) -> None: content = response or "" body = Markdown(content) if render_markdown else Text(content) console.print() - console.print( - Panel( - body, - title=f"{__logo__} nanobot", - title_align="left", - border_style="cyan", - padding=(0, 1), - ) - ) + console.print(f"[cyan]{__logo__} nanobot[/cyan]") + console.print(body) console.print() From cbab72ab726ba9c18c0e6494679ea394072930d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20S=C3=A1nchez=20Vall=C3=A9s?= Date: Wed, 11 Feb 2026 13:01:29 +0100 Subject: [PATCH 4/6] fix: pydantic deprecation configdict --- nanobot/config/schema.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index f6c861d..19feba4 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -1,7 +1,7 @@ """Configuration schema using Pydantic.""" from pathlib import Path -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ConfigDict from pydantic_settings import BaseSettings @@ -281,6 +281,7 @@ class Config(BaseSettings): return spec.default_api_base return None - class Config: - env_prefix = "NANOBOT_" - env_nested_delimiter = "__" + model_config = ConfigDict( + env_prefix="NANOBOT_", + env_nested_delimiter="__" + ) From 554ba81473668576af939a1720c36ee7630ee72a Mon Sep 17 00:00:00 2001 From: Re-bin Date: Wed, 11 Feb 2026 14:39:20 +0000 Subject: [PATCH 5/6] docs: update agent community tips --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index 1da4a41..fed25c8 100644 --- a/README.md +++ b/README.md @@ -573,6 +573,17 @@ nanobot gateway +## 🌐 Agent Social Network + +🐈 nanobot is capable of linking to the agent social network (agent community). **Just send one message and your nanobot joins automatically!** + +| Platform | How to Join (send this message to your bot) | +|----------|-------------| +| [**Moltbook**](https://www.moltbook.com/) | `Read https://moltbook.com/skill.md and follow the instructions to join Moltbook` | +| [**ClawdChat**](https://clawdchat.ai/) | `Read https://clawdchat.ai/skill.md and follow the instructions to join ClawdChat` | + +Simply send the command above to your nanobot (via CLI or any chat channel), and it will handle the rest. + ## ⚙️ Configuration Config file: `~/.nanobot/config.json` From b429bf9381d22edd4a5d35eb720cd2704eae63b5 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 12 Feb 2026 01:20:57 +0000 Subject: [PATCH 6/6] fix: improve long-running stability for various channels --- nanobot/channels/dingtalk.py | 11 +++++++++-- nanobot/channels/feishu.py | 13 ++++++++----- nanobot/channels/qq.py | 15 +++++++++------ nanobot/channels/telegram.py | 11 +++++++++-- 4 files changed, 35 insertions(+), 15 deletions(-) diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py index 72d3afd..4a8cdd9 100644 --- a/nanobot/channels/dingtalk.py +++ b/nanobot/channels/dingtalk.py @@ -137,8 +137,15 @@ class DingTalkChannel(BaseChannel): logger.info("DingTalk bot started with Stream Mode") - # client.start() is an async infinite loop handling the websocket connection - await self._client.start() + # Reconnect loop: restart stream if SDK exits or crashes + while self._running: + try: + await self._client.start() + except Exception as e: + logger.warning(f"DingTalk stream error: {e}") + if self._running: + logger.info("Reconnecting DingTalk stream in 5 seconds...") + await asyncio.sleep(5) except Exception as e: logger.exception(f"Failed to start DingTalk channel: {e}") diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 1c176a2..23d1415 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -98,12 +98,15 @@ class FeishuChannel(BaseChannel): log_level=lark.LogLevel.INFO ) - # Start WebSocket client in a separate thread + # Start WebSocket client in a separate thread with reconnect loop def run_ws(): - try: - self._ws_client.start() - except Exception as e: - logger.error(f"Feishu WebSocket error: {e}") + while self._running: + try: + self._ws_client.start() + except Exception as e: + logger.warning(f"Feishu WebSocket error: {e}") + if self._running: + import time; time.sleep(5) self._ws_thread = threading.Thread(target=run_ws, daemon=True) self._ws_thread.start() diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 5964d30..0e8fe66 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -75,12 +75,15 @@ class QQChannel(BaseChannel): logger.info("QQ bot started (C2C private message)") async def _run_bot(self) -> None: - """Run the bot connection.""" - try: - await self._client.start(appid=self.config.app_id, secret=self.config.secret) - except Exception as e: - logger.error(f"QQ auth failed, check AppID/Secret at q.qq.com: {e}") - self._running = False + """Run the bot connection with auto-reconnect.""" + while self._running: + try: + await self._client.start(appid=self.config.app_id, secret=self.config.secret) + except Exception as e: + logger.warning(f"QQ bot error: {e}") + if self._running: + logger.info("Reconnecting QQ bot in 5 seconds...") + await asyncio.sleep(5) async def stop(self) -> None: """Stop the QQ bot.""" diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index ff46c86..1abd600 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING from loguru import logger from telegram import BotCommand, Update from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes +from telegram.request import HTTPXRequest from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus @@ -121,11 +122,13 @@ class TelegramChannel(BaseChannel): self._running = True - # Build the application - builder = Application.builder().token(self.config.token) + # Build the application with larger connection pool to avoid pool-timeout on long runs + req = HTTPXRequest(connection_pool_size=16, pool_timeout=5.0, connect_timeout=30.0, read_timeout=30.0) + builder = Application.builder().token(self.config.token).request(req).get_updates_request(req) if self.config.proxy: builder = builder.proxy(self.config.proxy).get_updates_proxy(self.config.proxy) self._app = builder.build() + self._app.add_error_handler(self._on_error) # Add command handlers self._app.add_handler(CommandHandler("start", self._on_start)) @@ -386,6 +389,10 @@ class TelegramChannel(BaseChannel): except Exception as e: logger.debug(f"Typing indicator stopped for {chat_id}: {e}") + async def _on_error(self, update: object, context: ContextTypes.DEFAULT_TYPE) -> None: + """Log polling / handler errors instead of silently swallowing them.""" + logger.error(f"Telegram error: {context.error}") + def _get_extension(self, media_type: str, mime_type: str | None) -> str: """Get file extension based on media type.""" if mime_type: