Merge branch 'main' into fix/workspace-scoped-cron-store

Keep cron state workspace-scoped while only migrating legacy jobs into the default workspace. This preserves seamless upgrades for existing installs without polluting intentionally new workspaces.
This commit is contained in:
Xubin Ren
2026-03-24 02:41:58 +00:00
70 changed files with 6550 additions and 606 deletions
+192 -120
View File
@@ -2,6 +2,7 @@
import asyncio
from contextlib import contextmanager, nullcontext
import os
import select
import signal
@@ -21,24 +22,25 @@ if sys.platform == "win32":
pass
import typer
from prompt_toolkit import print_formatted_text
from prompt_toolkit import PromptSession
from prompt_toolkit import PromptSession, print_formatted_text
from prompt_toolkit.application import run_in_terminal
from prompt_toolkit.formatted_text import ANSI, HTML
from prompt_toolkit.history import FileHistory
from prompt_toolkit.patch_stdout import patch_stdout
from prompt_toolkit.application import run_in_terminal
from rich.console import Console
from rich.markdown import Markdown
from rich.table import Table
from rich.text import Text
from nanobot import __logo__, __version__
from nanobot.config.paths import get_workspace_path
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner
from nanobot.config.paths import get_workspace_path, is_default_workspace
from nanobot.config.schema import Config
from nanobot.utils.helpers import sync_workspace_templates
app = typer.Typer(
name="nanobot",
context_settings={"help_option_names": ["-h", "--help"]},
help=f"{__logo__} nanobot - Personal AI Assistant",
no_args_is_help=True,
)
@@ -131,17 +133,30 @@ def _render_interactive_ansi(render_fn) -> str:
return capture.get()
def _print_agent_response(response: str, render_markdown: bool) -> None:
def _print_agent_response(
response: str,
render_markdown: bool,
metadata: dict | None = None,
) -> None:
"""Render assistant response with consistent terminal styling."""
console = _make_console()
content = response or ""
body = Markdown(content) if render_markdown else Text(content)
body = _response_renderable(content, render_markdown, metadata)
console.print()
console.print(f"[cyan]{__logo__} nanobot[/cyan]")
console.print(body)
console.print()
def _response_renderable(content: str, render_markdown: bool, metadata: dict | None = None):
"""Render plain-text command output without markdown collapsing newlines."""
if not render_markdown:
return Text(content)
if (metadata or {}).get("render_as") == "text":
return Text(content)
return Markdown(content)
async def _print_interactive_line(text: str) -> None:
"""Print async interactive updates with prompt_toolkit-safe Rich styling."""
def _write() -> None:
@@ -153,7 +168,11 @@ async def _print_interactive_line(text: str) -> None:
await run_in_terminal(_write)
async def _print_interactive_response(response: str, render_markdown: bool) -> None:
async def _print_interactive_response(
response: str,
render_markdown: bool,
metadata: dict | None = None,
) -> None:
"""Print async interactive replies with prompt_toolkit-safe Rich styling."""
def _write() -> None:
content = response or ""
@@ -161,7 +180,7 @@ async def _print_interactive_response(response: str, render_markdown: bool) -> N
lambda c: (
c.print(),
c.print(f"[cyan]{__logo__} nanobot[/cyan]"),
c.print(Markdown(content) if render_markdown else Text(content)),
c.print(_response_renderable(content, render_markdown, metadata)),
c.print(),
)
)
@@ -170,46 +189,13 @@ 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:
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:
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)
@@ -265,6 +251,7 @@ def main(
def onboard(
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
wizard: bool = typer.Option(False, "--wizard", help="Use interactive wizard"),
):
"""Initialize nanobot configuration and workspace."""
from nanobot.config.loader import get_config_path, load_config, save_config, set_config_path
@@ -284,42 +271,69 @@ def onboard(
# Create or update config
if config_path.exists():
console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
console.print(" [bold]y[/bold] = overwrite with defaults (existing values will be lost)")
console.print(" [bold]N[/bold] = refresh config, keeping existing values and adding new fields")
if typer.confirm("Overwrite?"):
config = _apply_workspace_override(Config())
save_config(config, config_path)
console.print(f"[green]✓[/green] Config reset to defaults at {config_path}")
else:
if wizard:
config = _apply_workspace_override(load_config(config_path))
save_config(config, config_path)
console.print(f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)")
else:
console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
console.print(" [bold]y[/bold] = overwrite with defaults (existing values will be lost)")
console.print(" [bold]N[/bold] = refresh config, keeping existing values and adding new fields")
if typer.confirm("Overwrite?"):
config = _apply_workspace_override(Config())
save_config(config, config_path)
console.print(f"[green]✓[/green] Config reset to defaults at {config_path}")
else:
config = _apply_workspace_override(load_config(config_path))
save_config(config, config_path)
console.print(f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)")
else:
config = _apply_workspace_override(Config())
save_config(config, config_path)
console.print(f"[green]✓[/green] Created config at {config_path}")
console.print("[dim]Config template now uses `maxTokens` + `contextWindowTokens`; `memoryWindow` is no longer a runtime setting.[/dim]")
# In wizard mode, don't save yet - the wizard will handle saving if should_save=True
if not wizard:
save_config(config, config_path)
console.print(f"[green]✓[/green] Created config at {config_path}")
# Run interactive wizard if enabled
if wizard:
from nanobot.cli.onboard import run_onboard
try:
result = run_onboard(initial_config=config)
if not result.should_save:
console.print("[yellow]Configuration discarded. No changes were saved.[/yellow]")
return
config = result.config
save_config(config, config_path)
console.print(f"[green]✓[/green] Config saved at {config_path}")
except Exception as e:
console.print(f"[red]✗[/red] Error during configuration: {e}")
console.print("[yellow]Please run 'nanobot onboard' again to complete setup.[/yellow]")
raise typer.Exit(1)
_onboard_plugins(config_path)
# Create workspace, preferring the configured workspace path.
workspace = get_workspace_path(config.workspace_path)
if not workspace.exists():
workspace.mkdir(parents=True, exist_ok=True)
console.print(f"[green]✓[/green] Created workspace at {workspace}")
workspace_path = get_workspace_path(config.workspace_path)
if not workspace_path.exists():
workspace_path.mkdir(parents=True, exist_ok=True)
console.print(f"[green]✓[/green] Created workspace at {workspace_path}")
sync_workspace_templates(workspace)
sync_workspace_templates(workspace_path)
agent_cmd = 'nanobot agent -m "Hello!"'
gateway_cmd = "nanobot gateway"
if config:
agent_cmd += f" --config {config_path}"
gateway_cmd += f" --config {config_path}"
console.print(f"\n{__logo__} nanobot is ready!")
console.print("\nNext steps:")
console.print(f" 1. Add your API key to [cyan]{config_path}[/cyan]")
console.print(" Get one at: https://openrouter.ai/keys")
console.print(f" 2. Chat: [cyan]{agent_cmd}[/cyan]")
if wizard:
console.print(f" 1. Chat: [cyan]{agent_cmd}[/cyan]")
console.print(f" 2. Start gateway: [cyan]{gateway_cmd}[/cyan]")
else:
console.print(f" 1. Add your API key to [cyan]{config_path}[/cyan]")
console.print(" Get one at: https://openrouter.ai/keys")
console.print(f" 2. Chat: [cyan]{agent_cmd}[/cyan]")
console.print("\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]")
@@ -363,9 +377,9 @@ def _onboard_plugins(config_path: Path) -> None:
def _make_provider(config: Config):
"""Create the appropriate LLM provider from config."""
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
from nanobot.providers.base import GenerationSettings
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
model = config.agents.defaults.model
provider_name = config.get_provider_name(model)
@@ -395,6 +409,14 @@ def _make_provider(config: Config):
api_base=p.api_base,
default_model=model,
)
# OpenVINO Model Server: direct OpenAI-compatible endpoint at /v3
elif provider_name == "ovms":
from nanobot.providers.custom_provider import CustomProvider
provider = CustomProvider(
api_key=p.api_key if p else "no-key",
api_base=config.get_api_base(model) or "http://localhost:8000/v3",
default_model=model,
)
else:
from nanobot.providers.litellm_provider import LiteLLMProvider
from nanobot.providers.registry import find_by_name
@@ -434,18 +456,26 @@ def _load_runtime_config(config: str | None = None, workspace: str | None = None
console.print(f"[dim]Using config: {config_path}[/dim]")
loaded = load_config(config_path)
_warn_deprecated_config_keys(config_path)
if workspace:
loaded.agents.defaults.workspace = workspace
return loaded
def _print_deprecated_memory_window_notice(config: Config) -> None:
"""Warn when running with old memoryWindow-only config."""
if config.agents.defaults.should_warn_deprecated_memory_window:
def _warn_deprecated_config_keys(config_path: Path | None) -> None:
"""Hint users to remove obsolete keys from their config file."""
import json
from nanobot.config.loader import get_config_path
path = config_path or get_config_path()
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return
if "memoryWindow" in raw.get("agents", {}).get("defaults", {}):
console.print(
"[yellow]Hint:[/yellow] Detected deprecated `memoryWindow` without "
"`contextWindowTokens`. `memoryWindow` is ignored; run "
"[cyan]nanobot onboard[/cyan] to refresh your config template."
"[dim]Hint: `memoryWindow` in your config is no longer used "
"and can be safely removed.[/dim]"
)
@@ -487,7 +517,6 @@ def gateway(
logging.basicConfig(level=logging.DEBUG)
config = _load_runtime_config(config, workspace)
_print_deprecated_memory_window_notice(config)
port = port if port is not None else config.gateway.port
console.print(f"{__logo__} Starting nanobot gateway version {__version__} on port {port}...")
@@ -496,8 +525,9 @@ def gateway(
provider = _make_provider(config)
session_manager = SessionManager(config.workspace_path)
# Migrate legacy global cron store into workspace (one-time)
_migrate_cron_store(config)
# Preserve existing single-workspace installs, but keep custom workspaces clean.
if is_default_workspace(config.workspace_path):
_migrate_cron_store(config)
# Create cron service with workspace-scoped store
cron_store_path = config.workspace_path / "cron" / "jobs.json"
@@ -539,7 +569,7 @@ def gateway(
if isinstance(cron_tool, CronTool):
cron_token = cron_tool.set_cron_context(True)
try:
response = await agent.process_direct(
resp = await agent.process_direct(
reminder_note,
session_key=f"cron:{job.id}",
channel=job.payload.channel or "cli",
@@ -549,6 +579,8 @@ def gateway(
if isinstance(cron_tool, CronTool) and cron_token is not None:
cron_tool.reset_cron_context(cron_token)
response = resp.content if resp else ""
message_tool = agent.tools.get("message")
if isinstance(message_tool, MessageTool) and message_tool._sent_in_turn:
return response
@@ -594,7 +626,7 @@ def gateway(
async def _silent(*_args, **_kwargs):
pass
return await agent.process_direct(
resp = await agent.process_direct(
tasks,
session_key="heartbeat",
channel=channel,
@@ -602,6 +634,14 @@ def gateway(
on_progress=_silent,
)
# Keep a small tail of heartbeat history so the loop stays bounded
# without losing all short-term context between runs.
session = agent.sessions.get_or_create("heartbeat")
session.retain_recent_legal_suffix(hb_cfg.keep_recent_messages)
agent.sessions.save(session)
return resp.content if resp else ""
async def on_heartbeat_notify(response: str) -> None:
"""Deliver a heartbeat response to the user's channel."""
from nanobot.bus.events import OutboundMessage
@@ -680,14 +720,14 @@ def agent(
from nanobot.cron.service import CronService
config = _load_runtime_config(config, workspace)
_print_deprecated_memory_window_notice(config)
sync_workspace_templates(config.workspace_path)
bus = MessageBus()
provider = _make_provider(config)
# Migrate legacy global cron store into workspace (one-time)
_migrate_cron_store(config)
# Preserve existing single-workspace installs, but keep custom workspaces clean.
if is_default_workspace(config.workspace_path):
_migrate_cron_store(config)
# Create cron service with workspace-scoped store
cron_store_path = config.workspace_path / "cron" / "jobs.json"
@@ -715,7 +755,7 @@ def agent(
)
# Shared reference for progress callbacks
_thinking: _ThinkingSpinner | None = None
_thinking: ThinkingSpinner | None = None
async def _cli_progress(content: str, *, tool_hint: bool = False) -> None:
ch = agent_loop.channels_config
@@ -728,12 +768,20 @@ def agent(
if message:
# Single message mode — direct call, no bus needed
async def run_once():
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)
renderer = StreamRenderer(render_markdown=markdown)
response = await agent_loop.process_direct(
message, session_id,
on_progress=_cli_progress,
on_stream=renderer.on_delta,
on_stream_end=renderer.on_end,
)
if not renderer.streamed:
await renderer.close()
_print_agent_response(
response.content if response else "",
render_markdown=markdown,
metadata=response.metadata if response else None,
)
await agent_loop.close_mcp()
asyncio.run(run_once())
@@ -768,12 +816,28 @@ def agent(
bus_task = asyncio.create_task(agent_loop.run())
turn_done = asyncio.Event()
turn_done.set()
turn_response: list[str] = []
turn_response: list[tuple[str, dict]] = []
renderer: StreamRenderer | None = None
async def _consume_outbound():
while True:
try:
msg = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
if msg.metadata.get("_stream_delta"):
if renderer:
await renderer.on_delta(msg.content)
continue
if msg.metadata.get("_stream_end"):
if renderer:
await renderer.on_end(
resuming=msg.metadata.get("_resuming", False),
)
continue
if msg.metadata.get("_streamed"):
turn_done.set()
continue
if msg.metadata.get("_progress"):
is_tool_hint = msg.metadata.get("_tool_hint", False)
ch = agent_loop.channels_config
@@ -783,13 +847,18 @@ def agent(
pass
else:
await _print_interactive_progress_line(msg.content, _thinking)
continue
elif not turn_done.is_set():
if not turn_done.is_set():
if msg.content:
turn_response.append(msg.content)
turn_response.append((msg.content, dict(msg.metadata or {})))
turn_done.set()
elif msg.content:
await _print_interactive_response(msg.content, render_markdown=markdown)
await _print_interactive_response(
msg.content,
render_markdown=markdown,
metadata=msg.metadata,
)
except asyncio.TimeoutError:
continue
@@ -814,22 +883,28 @@ def agent(
turn_done.clear()
turn_response.clear()
renderer = StreamRenderer(render_markdown=markdown)
await bus.publish_inbound(InboundMessage(
channel=cli_channel,
sender_id="user",
chat_id=cli_chat_id,
content=user_input,
metadata={"_wants_stream": True},
))
nonlocal _thinking
_thinking = _ThinkingSpinner(enabled=not logs)
with _thinking:
await turn_done.wait()
_thinking = None
await turn_done.wait()
if turn_response:
_print_agent_response(turn_response[0], render_markdown=markdown)
content, meta = turn_response[0]
if content and not meta.get("_streamed"):
if renderer:
await renderer.close()
_print_agent_response(
content, render_markdown=markdown, metadata=meta,
)
elif renderer and not renderer.streamed:
await renderer.close()
except KeyboardInterrupt:
_restore_terminal()
console.print("\nGoodbye!")
@@ -946,36 +1021,33 @@ def _get_bridge_dir() -> Path:
@channels_app.command("login")
def channels_login():
"""Link device via QR code."""
import shutil
import subprocess
def channels_login(
channel_name: str = typer.Argument(..., help="Channel name (e.g. weixin, whatsapp)"),
force: bool = typer.Option(False, "--force", "-f", help="Force re-authentication even if already logged in"),
):
"""Authenticate with a channel via QR code or other interactive login."""
from nanobot.channels.registry import discover_all
from nanobot.config.loader import load_config
from nanobot.config.paths import get_runtime_subdir
config = load_config()
bridge_dir = _get_bridge_dir()
channel_cfg = getattr(config.channels, channel_name, None) or {}
console.print(f"{__logo__} Starting bridge...")
console.print("Scan the QR code to connect.\n")
env = {**os.environ}
wa_cfg = getattr(config.channels, "whatsapp", None) or {}
bridge_token = wa_cfg.get("bridgeToken", "") if isinstance(wa_cfg, dict) else getattr(wa_cfg, "bridge_token", "")
if bridge_token:
env["BRIDGE_TOKEN"] = bridge_token
env["AUTH_DIR"] = str(get_runtime_subdir("whatsapp-auth"))
npm_path = shutil.which("npm")
if not npm_path:
console.print("[red]npm not found. Please install Node.js.[/red]")
# Validate channel exists
all_channels = discover_all()
if channel_name not in all_channels:
available = ", ".join(all_channels.keys())
console.print(f"[red]Unknown channel: {channel_name}[/red] Available: {available}")
raise typer.Exit(1)
try:
subprocess.run([npm_path, "start"], cwd=bridge_dir, check=True, env=env)
except subprocess.CalledProcessError as e:
console.print(f"[red]Bridge failed: {e}[/red]")
console.print(f"{__logo__} {all_channels[channel_name].display_name} Login\n")
channel_cls = all_channels[channel_name]
channel = channel_cls(channel_cfg, bus=None)
success = asyncio.run(channel.login(force=force))
if not success:
raise typer.Exit(1)
# ============================================================================
+231
View File
@@ -0,0 +1,231 @@
"""Model information helpers for the onboard wizard.
Provides model context window lookup and autocomplete suggestions using litellm.
"""
from __future__ import annotations
from functools import lru_cache
from typing import Any
def _litellm():
"""Lazy accessor for litellm (heavy import deferred until actually needed)."""
import litellm as _ll
return _ll
@lru_cache(maxsize=1)
def _get_model_cost_map() -> dict[str, Any]:
"""Get litellm's model cost map (cached)."""
return getattr(_litellm(), "model_cost", {})
@lru_cache(maxsize=1)
def get_all_models() -> list[str]:
"""Get all known model names from litellm.
"""
models = set()
# From model_cost (has pricing info)
cost_map = _get_model_cost_map()
for k in cost_map.keys():
if k != "sample_spec":
models.add(k)
# From models_by_provider (more complete provider coverage)
for provider_models in getattr(_litellm(), "models_by_provider", {}).values():
if isinstance(provider_models, (set, list)):
models.update(provider_models)
return sorted(models)
def _normalize_model_name(model: str) -> str:
"""Normalize model name for comparison."""
return model.lower().replace("-", "_").replace(".", "")
def find_model_info(model_name: str) -> dict[str, Any] | None:
"""Find model info with fuzzy matching.
Args:
model_name: Model name in any common format
Returns:
Model info dict or None if not found
"""
cost_map = _get_model_cost_map()
if not cost_map:
return None
# Direct match
if model_name in cost_map:
return cost_map[model_name]
# Extract base name (without provider prefix)
base_name = model_name.split("/")[-1] if "/" in model_name else model_name
base_normalized = _normalize_model_name(base_name)
candidates = []
for key, info in cost_map.items():
if key == "sample_spec":
continue
key_base = key.split("/")[-1] if "/" in key else key
key_base_normalized = _normalize_model_name(key_base)
# Score the match
score = 0
# Exact base name match (highest priority)
if base_normalized == key_base_normalized:
score = 100
# Base name contains model
elif base_normalized in key_base_normalized:
score = 80
# Model contains base name
elif key_base_normalized in base_normalized:
score = 70
# Partial match
elif base_normalized[:10] in key_base_normalized:
score = 50
if score > 0:
# Prefer models with max_input_tokens
if info.get("max_input_tokens"):
score += 10
candidates.append((score, key, info))
if not candidates:
return None
# Return the best match
candidates.sort(key=lambda x: (-x[0], x[1]))
return candidates[0][2]
def get_model_context_limit(model: str, provider: str = "auto") -> int | None:
"""Get the maximum input context tokens for a model.
Args:
model: Model name (e.g., "claude-3.5-sonnet", "gpt-4o")
provider: Provider name for informational purposes (not yet used for filtering)
Returns:
Maximum input tokens, or None if unknown
Note:
The provider parameter is currently informational only. Future versions may
use it to prefer provider-specific model variants in the lookup.
"""
# First try fuzzy search in model_cost (has more accurate max_input_tokens)
info = find_model_info(model)
if info:
# Prefer max_input_tokens (this is what we want for context window)
max_input = info.get("max_input_tokens")
if max_input and isinstance(max_input, int):
return max_input
# Fall back to litellm's get_max_tokens (returns max_output_tokens typically)
try:
result = _litellm().get_max_tokens(model)
if result and result > 0:
return result
except (KeyError, ValueError, AttributeError):
# Model not found in litellm's database or invalid response
pass
# Last resort: use max_tokens from model_cost
if info:
max_tokens = info.get("max_tokens")
if max_tokens and isinstance(max_tokens, int):
return max_tokens
return None
@lru_cache(maxsize=1)
def _get_provider_keywords() -> dict[str, list[str]]:
"""Build provider keywords mapping from nanobot's provider registry.
Returns:
Dict mapping provider name to list of keywords for model filtering.
"""
try:
from nanobot.providers.registry import PROVIDERS
mapping = {}
for spec in PROVIDERS:
if spec.keywords:
mapping[spec.name] = list(spec.keywords)
return mapping
except ImportError:
return {}
def get_model_suggestions(partial: str, provider: str = "auto", limit: int = 20) -> list[str]:
"""Get autocomplete suggestions for model names.
Args:
partial: Partial model name typed by user
provider: Provider name for filtering (e.g., "openrouter", "minimax")
limit: Maximum number of suggestions to return
Returns:
List of matching model names
"""
all_models = get_all_models()
if not all_models:
return []
partial_lower = partial.lower()
partial_normalized = _normalize_model_name(partial)
# Get provider keywords from registry
provider_keywords = _get_provider_keywords()
# Filter by provider if specified
allowed_keywords = None
if provider and provider != "auto":
allowed_keywords = provider_keywords.get(provider.lower())
matches = []
for model in all_models:
model_lower = model.lower()
# Apply provider filter
if allowed_keywords:
if not any(kw in model_lower for kw in allowed_keywords):
continue
# Match against partial input
if not partial:
matches.append(model)
continue
if partial_lower in model_lower:
# Score by position of match (earlier = better)
pos = model_lower.find(partial_lower)
score = 100 - pos
matches.append((score, model))
elif partial_normalized in _normalize_model_name(model):
score = 50
matches.append((score, model))
# Sort by score if we have scored matches
if matches and isinstance(matches[0], tuple):
matches.sort(key=lambda x: (-x[0], x[1]))
matches = [m[1] for m in matches]
else:
matches.sort()
return matches[:limit]
def format_token_count(tokens: int) -> str:
"""Format token count for display (e.g., 200000 -> '200,000')."""
return f"{tokens:,}"
File diff suppressed because it is too large Load Diff
+128
View File
@@ -0,0 +1,128 @@
"""Streaming renderer for CLI output.
Uses Rich Live with auto_refresh=False for stable, flicker-free
markdown rendering during streaming. Ellipsis mode handles overflow.
"""
from __future__ import annotations
import sys
import time
from rich.console import Console
from rich.live import Live
from rich.markdown import Markdown
from rich.text import Text
from nanobot import __logo__
def _make_console() -> Console:
return Console(file=sys.stdout)
class ThinkingSpinner:
"""Spinner that shows 'nanobot is thinking...' with pause support."""
def __init__(self, console: Console | None = None):
c = console or _make_console()
self._spinner = c.status("[dim]nanobot is thinking...[/dim]", spinner="dots")
self._active = False
def __enter__(self):
self._spinner.start()
self._active = True
return self
def __exit__(self, *exc):
self._active = False
self._spinner.stop()
return False
def pause(self):
"""Context manager: temporarily stop spinner for clean output."""
from contextlib import contextmanager
@contextmanager
def _ctx():
if self._spinner and self._active:
self._spinner.stop()
try:
yield
finally:
if self._spinner and self._active:
self._spinner.start()
return _ctx()
class StreamRenderer:
"""Rich Live streaming with markdown. auto_refresh=False avoids render races.
Deltas arrive pre-filtered (no <think> tags) from the agent loop.
Flow per round:
spinner -> first visible delta -> header + Live renders ->
on_end -> Live stops (content stays on screen)
"""
def __init__(self, render_markdown: bool = True, show_spinner: bool = True):
self._md = render_markdown
self._show_spinner = show_spinner
self._buf = ""
self._live: Live | None = None
self._t = 0.0
self.streamed = False
self._spinner: ThinkingSpinner | None = None
self._start_spinner()
def _render(self):
return Markdown(self._buf) if self._md and self._buf else Text(self._buf or "")
def _start_spinner(self) -> None:
if self._show_spinner:
self._spinner = ThinkingSpinner()
self._spinner.__enter__()
def _stop_spinner(self) -> None:
if self._spinner:
self._spinner.__exit__(None, None, None)
self._spinner = None
async def on_delta(self, delta: str) -> None:
self.streamed = True
self._buf += delta
if self._live is None:
if not self._buf.strip():
return
self._stop_spinner()
c = _make_console()
c.print()
c.print(f"[cyan]{__logo__} nanobot[/cyan]")
self._live = Live(self._render(), console=c, auto_refresh=False)
self._live.start()
now = time.monotonic()
if "\n" in delta or (now - self._t) > 0.05:
self._live.update(self._render())
self._live.refresh()
self._t = now
async def on_end(self, *, resuming: bool = False) -> None:
if self._live:
self._live.update(self._render())
self._live.refresh()
self._live.stop()
self._live = None
self._stop_spinner()
if resuming:
self._buf = ""
self._start_spinner()
else:
_make_console().print()
async def close(self) -> None:
"""Stop spinner/live without rendering a final streamed round."""
if self._live:
self._live.stop()
self._live = None
self._stop_spinner()