Improve agent CLI chat UX with markdown output and clearer interaction feedback

This commit is contained in:
Chris Alexander 2026-02-08 20:50:31 +00:00
parent 8af98004b3
commit 0a2d557268
No known key found for this signature in database

View File

@ -10,7 +10,10 @@ 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
from nanobot import __version__, __logo__
@ -21,6 +24,30 @@ app = typer.Typer(
)
console = Console()
EXIT_COMMANDS = {"exit", "quit", "/exit", "/quit", ":q"}
def _print_agent_response(response: str, render_markdown: bool) -> None:
"""Render assistant response with consistent terminal styling."""
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()
def _is_exit_command(command: str) -> bool:
"""Return True when input should end interactive chat."""
return command.lower() in EXIT_COMMANDS
# ---------------------------------------------------------------------------
# Lightweight CLI input: readline for arrow keys / history, termios for flush
@ -44,6 +71,7 @@ def _flush_pending_tty_input() -> None:
try:
import termios
termios.tcflush(fd, termios.TCIFLUSH)
return
except Exception:
@ -75,6 +103,7 @@ def _restore_terminal() -> None:
return
try:
import termios
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _SAVED_TERM_ATTRS)
except Exception:
pass
@ -87,6 +116,7 @@ def _enable_line_editing() -> None:
# Save terminal state before readline touches it
try:
import termios
_SAVED_TERM_ATTRS = termios.tcgetattr(sys.stdin.fileno())
except Exception:
pass
@ -148,9 +178,7 @@ def version_callback(value: bool):
@app.callback()
def main(
version: bool = typer.Option(
None, "--version", "-v", callback=version_callback, is_eager=True
),
version: bool = typer.Option(None, "--version", "-v", callback=version_callback, is_eager=True),
):
"""nanobot - Personal AI Assistant."""
pass
@ -191,10 +219,10 @@ def onboard():
console.print("\nNext steps:")
console.print(" 1. Add your API key to [cyan]~/.nanobot/config.json[/cyan]")
console.print(" Get one at: https://openrouter.ai/keys")
console.print(" 2. Chat: [cyan]nanobot agent -m \"Hello!\"[/cyan]")
console.print("\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]")
console.print(' 2. Chat: [cyan]nanobot agent -m "Hello!"[/cyan]')
console.print(
"\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]"
)
def _create_workspace_templates(workspace: Path):
@ -272,6 +300,7 @@ This file stores important information that should persist across sessions.
def _make_provider(config):
"""Create LiteLLMProvider from config. Exits if no API key found."""
from nanobot.providers.litellm_provider import LiteLLMProvider
p = config.get_provider()
model = config.agents.defaults.model
if not (p and p.api_key) and not model.startswith("bedrock/"):
@ -309,6 +338,7 @@ def gateway(
if verbose:
import logging
logging.basicConfig(level=logging.DEBUG)
console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
@ -347,12 +377,16 @@ def gateway(
)
if job.payload.deliver and job.payload.to:
from nanobot.bus.events import OutboundMessage
await bus.publish_outbound(OutboundMessage(
channel=job.payload.channel or "cli",
chat_id=job.payload.to,
content=response or ""
))
await bus.publish_outbound(
OutboundMessage(
channel=job.payload.channel or "cli",
chat_id=job.payload.to,
content=response or "",
)
)
return response
cron.on_job = on_cron_job
# Create heartbeat service
@ -364,7 +398,7 @@ def gateway(
workspace=config.workspace_path,
on_heartbeat=on_heartbeat,
interval_s=30 * 60, # 30 minutes
enabled=True
enabled=True,
)
# Create channel manager
@ -399,8 +433,6 @@ def gateway(
asyncio.run(run())
# ============================================================================
# Agent Commands
# ============================================================================
@ -410,17 +442,29 @@ def gateway(
def agent(
message: str = typer.Option(None, "--message", "-m", help="Message to send to the agent"),
session_id: str = typer.Option("cli:default", "--session", "-s", help="Session ID"),
markdown: bool = typer.Option(
True, "--markdown/--no-markdown", help="Render assistant output as Markdown"
),
logs: bool = typer.Option(
False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"
),
):
"""Interact with the agent directly."""
from nanobot.config.loader import load_config
from nanobot.bus.queue import MessageBus
from nanobot.agent.loop import AgentLoop
from loguru import logger
config = load_config()
bus = MessageBus()
provider = _make_provider(config)
if logs:
logger.enable("nanobot")
else:
logger.disable("nanobot")
agent_loop = AgentLoop(
bus=bus,
provider=provider,
@ -433,8 +477,9 @@ def agent(
if message:
# Single message mode
async def run_once():
response = await agent_loop.process_direct(message, session_id)
console.print(f"\n{__logo__} {response}")
with console.status("[dim]Nanobot is thinking...[/dim]", spinner="dots"):
response = await agent_loop.process_direct(message, session_id)
_print_agent_response(response, render_markdown=markdown)
asyncio.run(run_once())
else:
@ -457,16 +502,25 @@ def agent(
try:
_flush_pending_tty_input()
user_input = await _read_interactive_input_async()
if not user_input.strip():
command = user_input.strip()
if not command:
continue
response = await agent_loop.process_direct(user_input, session_id)
console.print(f"\n{__logo__} {response}\n")
if _is_exit_command(command):
console.print("\nGoodbye!")
break
with console.status("[dim]Nanobot is thinking...[/dim]", spinner="dots"):
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:
console.print("\nGoodbye!")
break
asyncio.run(run_interactive())
@ -494,27 +548,15 @@ def channels_status():
# WhatsApp
wa = config.channels.whatsapp
table.add_row(
"WhatsApp",
"" if wa.enabled else "",
wa.bridge_url
)
table.add_row("WhatsApp", "" if wa.enabled else "", wa.bridge_url)
dc = config.channels.discord
table.add_row(
"Discord",
"" if dc.enabled else "",
dc.gateway_url
)
table.add_row("Discord", "" if dc.enabled else "", dc.gateway_url)
# Telegram
tg = config.channels.telegram
tg_config = f"token: {tg.token[:10]}..." if tg.token else "[dim]not configured[/dim]"
table.add_row(
"Telegram",
"" if tg.enabled else "",
tg_config
)
table.add_row("Telegram", "" if tg.enabled else "", tg_config)
console.print(table)
@ -628,6 +670,7 @@ def cron_list(
table.add_column("Next Run")
import time
for job in jobs:
# Format schedule
if job.schedule.kind == "every":
@ -640,7 +683,9 @@ def cron_list(
# Format next run
next_run = ""
if job.state.next_run_at_ms:
next_time = time.strftime("%Y-%m-%d %H:%M", time.localtime(job.state.next_run_at_ms / 1000))
next_time = time.strftime(
"%Y-%m-%d %H:%M", time.localtime(job.state.next_run_at_ms / 1000)
)
next_run = next_time
status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]"
@ -659,7 +704,9 @@ def cron_add(
at: str = typer.Option(None, "--at", help="Run once at time (ISO format)"),
deliver: bool = typer.Option(False, "--deliver", "-d", help="Deliver response to channel"),
to: str = typer.Option(None, "--to", help="Recipient for delivery"),
channel: str = typer.Option(None, "--channel", help="Channel for delivery (e.g. 'telegram', 'whatsapp')"),
channel: str = typer.Option(
None, "--channel", help="Channel for delivery (e.g. 'telegram', 'whatsapp')"
),
):
"""Add a scheduled job."""
from nanobot.config.loader import get_data_dir
@ -673,6 +720,7 @@ def cron_add(
schedule = CronSchedule(kind="cron", expr=cron_expr)
elif at:
import datetime
dt = datetime.datetime.fromisoformat(at)
schedule = CronSchedule(kind="at", at_ms=int(dt.timestamp() * 1000))
else:
@ -768,8 +816,12 @@ def status():
console.print(f"{__logo__} nanobot Status\n")
console.print(f"Config: {config_path} {'[green]✓[/green]' if config_path.exists() else '[red]✗[/red]'}")
console.print(f"Workspace: {workspace} {'[green]✓[/green]' if workspace.exists() else '[red]✗[/red]'}")
console.print(
f"Config: {config_path} {'[green]✓[/green]' if config_path.exists() else '[red]✗[/red]'}"
)
console.print(
f"Workspace: {workspace} {'[green]✓[/green]' if workspace.exists() else '[red]✗[/red]'}"
)
if config_path.exists():
from nanobot.providers.registry import PROVIDERS
@ -789,7 +841,9 @@ def status():
console.print(f"{spec.label}: [dim]not set[/dim]")
else:
has_key = bool(p.api_key)
console.print(f"{spec.label}: {'[green]✓[/green]' if has_key else '[dim]not set[/dim]'}")
console.print(
f"{spec.label}: {'[green]✓[/green]' if has_key else '[dim]not set[/dim]'}"
)
if __name__ == "__main__":