mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 01:48:53 +00:00
429 lines
13 KiB
Python
429 lines
13 KiB
Python
"""Terminal input and rendering helpers for the interactive CLI."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import select
|
||
import sys
|
||
from collections.abc import Callable
|
||
from contextlib import nullcontext, suppress
|
||
from typing import Any, Literal, cast
|
||
|
||
from loguru import logger
|
||
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.key_binding import KeyBindings
|
||
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
|
||
from prompt_toolkit.keys import Keys
|
||
from prompt_toolkit.patch_stdout import patch_stdout
|
||
from rich.console import Console
|
||
from rich.markdown import Markdown
|
||
from rich.text import Text
|
||
|
||
from nanobot import __logo__
|
||
from nanobot.bus.outbound_events import (
|
||
ProgressEvent,
|
||
RetryWaitEvent,
|
||
outbound_event_from_message,
|
||
)
|
||
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner
|
||
from nanobot.utils.helpers import sanitize_surrogates as _sanitize_surrogates
|
||
|
||
__all__ = [
|
||
"_ReasoningBuffer",
|
||
"_ensure_interactive_tty_mode",
|
||
"_flush_cli_reasoning",
|
||
"_flush_pending_tty_input",
|
||
"_init_prompt_session",
|
||
"_is_exit_command",
|
||
"_maybe_print_interactive_progress",
|
||
"_print_agent_response",
|
||
"_print_cli_progress_line",
|
||
"_print_cli_reasoning",
|
||
"_print_interactive_response",
|
||
"_read_interactive_input_async",
|
||
"_restore_terminal",
|
||
]
|
||
|
||
console = Console()
|
||
EXIT_COMMANDS = {"exit", "quit", "/exit", "/quit", ":q"}
|
||
_REASONING_SENTENCE_ENDINGS = (".", "!", "?", "。", "!", "?")
|
||
_REASONING_FLUSH_CHARS = 60
|
||
_prompt_session: PromptSession[str] | None = None
|
||
_saved_term_attrs: list[Any] | None = None
|
||
|
||
|
||
def _ensure_interactive_tty_mode() -> None:
|
||
"""Restore interactive line input after a raw-mode TTY leak."""
|
||
try:
|
||
fd = sys.stdin.fileno()
|
||
if not os.isatty(fd):
|
||
return
|
||
except Exception:
|
||
return
|
||
|
||
with suppress(Exception):
|
||
import termios
|
||
|
||
attrs = termios.tcgetattr(fd)
|
||
required_lflag = termios.ISIG | termios.ICANON | termios.ECHO
|
||
blocked_input_flags = getattr(termios, "IGNCR", 0) | getattr(termios, "INLCR", 0)
|
||
if (
|
||
(attrs[3] & required_lflag) == required_lflag
|
||
and attrs[0] & termios.ICRNL
|
||
and not attrs[0] & blocked_input_flags
|
||
):
|
||
return
|
||
attrs[0] = (attrs[0] | termios.ICRNL) & ~blocked_input_flags
|
||
attrs[3] |= required_lflag
|
||
termios.tcsetattr(fd, termios.TCSANOW, attrs)
|
||
termios.tcflush(fd, termios.TCIFLUSH)
|
||
logger.debug("Restored foreground gateway TTY mode")
|
||
|
||
|
||
class SafeFileHistory(FileHistory):
|
||
"""FileHistory subclass that sanitizes surrogate characters on write.
|
||
|
||
On Windows, special Unicode input (emoji, mixed-script) can produce
|
||
surrogate characters that crash prompt_toolkit's file write.
|
||
See issue #2846.
|
||
"""
|
||
|
||
def store_string(self, string: str) -> None:
|
||
super().store_string(_sanitize_surrogates(string))
|
||
|
||
|
||
def _flush_pending_tty_input() -> None:
|
||
"""Drop unread keypresses typed while the model was generating output."""
|
||
try:
|
||
fd = sys.stdin.fileno()
|
||
if not os.isatty(fd):
|
||
return
|
||
except Exception:
|
||
return
|
||
|
||
with suppress(Exception):
|
||
import termios
|
||
|
||
termios.tcflush(fd, termios.TCIFLUSH)
|
||
return
|
||
|
||
with suppress(Exception):
|
||
while True:
|
||
ready, _, _ = select.select([fd], [], [], 0)
|
||
if not ready:
|
||
break
|
||
if not os.read(fd, 4096):
|
||
break
|
||
|
||
|
||
def _restore_terminal() -> None:
|
||
"""Restore terminal to its original state (echo, line buffering, etc.)."""
|
||
if _saved_term_attrs is None:
|
||
return
|
||
with suppress(Exception):
|
||
import termios
|
||
|
||
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _saved_term_attrs)
|
||
|
||
|
||
def _build_cli_key_bindings() -> KeyBindings:
|
||
"""Key bindings for the interactive prompt.
|
||
|
||
Behaviour:
|
||
* Enter -> submit the current input (keeps the familiar
|
||
single-line Enter-to-send feel even though the buffer
|
||
is multiline-capable).
|
||
* Alt+Enter -> insert a newline for multi-line input.
|
||
* Shift+Enter -> insert a newline on terminals that emit the CSI-u
|
||
(kitty / fixterms) keyboard-protocol encoding for it.
|
||
"""
|
||
# prompt_toolkit does not recognize CSI-u, so register its Shift+Enter
|
||
# sequence as a best-effort addition without overriding existing mappings.
|
||
with suppress(Exception):
|
||
from prompt_toolkit.input import ansi_escape_sequences as _aes
|
||
|
||
_aes.ANSI_SEQUENCES.setdefault("\x1b[13;2u", Keys.ControlF3)
|
||
|
||
kb = KeyBindings()
|
||
|
||
@kb.add("enter")
|
||
def _(event: KeyPressEvent) -> None:
|
||
event.current_buffer.validate_and_handle()
|
||
|
||
@kb.add("escape", "enter") # Alt+Enter / Meta+Enter (ESC + CR, "\x1b\r")
|
||
def _(event: KeyPressEvent) -> None:
|
||
event.current_buffer.insert_text("\n")
|
||
|
||
# LF-as-Enter terminals send Alt+Enter as ESC + LF rather than ESC + CR.
|
||
@kb.add("escape", Keys.ControlJ) # Alt+Enter on LF-as-Enter terminals
|
||
def _(event: KeyPressEvent) -> None:
|
||
event.current_buffer.insert_text("\n")
|
||
|
||
@kb.add(Keys.ControlF3) # Shift+Enter on CSI-u capable terminals
|
||
def _(event: KeyPressEvent) -> None:
|
||
event.current_buffer.insert_text("\n")
|
||
|
||
return kb
|
||
|
||
|
||
def _init_prompt_session() -> None:
|
||
"""Create the prompt_toolkit session with persistent file history."""
|
||
global _prompt_session, _saved_term_attrs
|
||
|
||
# Save terminal state so we can restore it on exit
|
||
with suppress(Exception):
|
||
import termios
|
||
|
||
_saved_term_attrs = termios.tcgetattr(sys.stdin.fileno())
|
||
|
||
from nanobot.config.paths import get_cli_history_path
|
||
|
||
history_file = get_cli_history_path()
|
||
history_file.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
_prompt_session = PromptSession(
|
||
history=SafeFileHistory(str(history_file)),
|
||
enable_open_in_editor=False,
|
||
# Multiline-capable buffer; Enter still submits via the custom key
|
||
# bindings, while Alt+Enter adds a newline.
|
||
multiline=True,
|
||
key_bindings=_build_cli_key_bindings(),
|
||
)
|
||
|
||
|
||
def _make_console() -> Console:
|
||
return Console(file=sys.stdout)
|
||
|
||
|
||
def _render_interactive_ansi(render_fn: Callable[[Console], None]) -> str:
|
||
"""Render Rich output to ANSI so prompt_toolkit can print it safely."""
|
||
ansi_console = Console(
|
||
force_terminal=sys.stdout.isatty(),
|
||
color_system=cast(
|
||
Literal["auto", "standard", "256", "truecolor", "windows"],
|
||
console.color_system or "standard",
|
||
),
|
||
width=console.width,
|
||
)
|
||
with ansi_console.capture() as capture:
|
||
render_fn(ansi_console)
|
||
return capture.get()
|
||
|
||
|
||
def _print_agent_response(
|
||
response: str,
|
||
render_markdown: bool,
|
||
metadata: dict[str, Any] | None = None,
|
||
show_header: bool = True,
|
||
) -> None:
|
||
"""Render assistant response with consistent terminal styling."""
|
||
console = _make_console()
|
||
content = response or ""
|
||
body = _response_renderable(content, render_markdown, metadata)
|
||
if show_header:
|
||
console.print()
|
||
console.print(f"[cyan]{__logo__} nanobot[/cyan]")
|
||
console.print(body)
|
||
console.print()
|
||
|
||
|
||
def _response_renderable(
|
||
content: str, render_markdown: bool, metadata: dict[str, Any] | None = None
|
||
) -> Text | Markdown:
|
||
"""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:
|
||
ansi = _render_interactive_ansi(lambda c: c.print(f" [dim]↳ {text}[/dim]"))
|
||
print_formatted_text(ANSI(ansi), end="")
|
||
|
||
await run_in_terminal(_write)
|
||
|
||
|
||
async def _print_interactive_response(
|
||
response: str,
|
||
render_markdown: bool,
|
||
metadata: dict[str, Any] | None = None,
|
||
) -> None:
|
||
"""Print async interactive replies with prompt_toolkit-safe Rich styling."""
|
||
|
||
def _write() -> None:
|
||
content = response or ""
|
||
|
||
def _render(target: Console) -> None:
|
||
target.print()
|
||
target.print(f"[cyan]{__logo__} nanobot[/cyan]")
|
||
target.print(_response_renderable(content, render_markdown, metadata))
|
||
target.print()
|
||
|
||
ansi = _render_interactive_ansi(_render)
|
||
print_formatted_text(ANSI(ansi), end="")
|
||
|
||
await run_in_terminal(_write)
|
||
|
||
|
||
def _print_cli_progress_line(
|
||
text: str,
|
||
thinking: ThinkingSpinner | None,
|
||
renderer: StreamRenderer | None = None,
|
||
) -> None:
|
||
"""Print a CLI progress line, pausing the spinner if needed."""
|
||
if not text.strip():
|
||
return
|
||
target = renderer.console if renderer else console
|
||
pause = renderer.pause_spinner() if renderer else (thinking.pause() if thinking else nullcontext())
|
||
with pause:
|
||
if renderer:
|
||
renderer.ensure_header()
|
||
target.print(f" [dim]↳ {text}[/dim]")
|
||
|
||
|
||
class _ReasoningBuffer:
|
||
def __init__(self) -> None:
|
||
self._text = ""
|
||
|
||
def add(self, text: str) -> str | None:
|
||
if not text:
|
||
return None
|
||
self._text += text
|
||
if self._should_flush(text):
|
||
return self.flush()
|
||
return None
|
||
|
||
def flush(self) -> str | None:
|
||
text = self._text.strip()
|
||
self._text = ""
|
||
return text or None
|
||
|
||
def clear(self) -> None:
|
||
self._text = ""
|
||
|
||
def _should_flush(self, text: str) -> bool:
|
||
stripped = text.rstrip()
|
||
return (
|
||
"\n" in text
|
||
or stripped.endswith(_REASONING_SENTENCE_ENDINGS)
|
||
or len(self._text) >= _REASONING_FLUSH_CHARS
|
||
)
|
||
|
||
|
||
def _print_cli_reasoning(
|
||
text: str,
|
||
thinking: ThinkingSpinner | None,
|
||
renderer: StreamRenderer | None = None,
|
||
) -> None:
|
||
"""Print reasoning/thinking content in a distinct style."""
|
||
if not text.strip():
|
||
return
|
||
target = renderer.console if renderer else console
|
||
pause = renderer.pause_spinner() if renderer else (thinking.pause() if thinking else nullcontext())
|
||
with pause:
|
||
if renderer:
|
||
renderer.ensure_header()
|
||
target.print(f"[dim italic]✻ {text}[/dim italic]")
|
||
|
||
|
||
def _flush_cli_reasoning(
|
||
reasoning_buffer: _ReasoningBuffer,
|
||
thinking: ThinkingSpinner | None,
|
||
renderer: StreamRenderer | None = None,
|
||
) -> None:
|
||
text = reasoning_buffer.flush()
|
||
if text:
|
||
_print_cli_reasoning(text, thinking, renderer)
|
||
|
||
|
||
async def _print_interactive_progress_line(
|
||
text: str,
|
||
thinking: ThinkingSpinner | None,
|
||
renderer: StreamRenderer | None = None,
|
||
) -> None:
|
||
"""Print an interactive progress line, pausing the spinner if needed."""
|
||
if not text.strip():
|
||
return
|
||
if renderer:
|
||
with renderer.pause_spinner():
|
||
renderer.ensure_header()
|
||
renderer.console.print(f" [dim]↳ {text}[/dim]")
|
||
else:
|
||
with thinking.pause() if thinking else nullcontext():
|
||
await _print_interactive_line(text)
|
||
|
||
|
||
async def _maybe_print_interactive_progress(
|
||
msg: Any,
|
||
thinking: ThinkingSpinner | None,
|
||
channels_config: Any,
|
||
renderer: StreamRenderer | None = None,
|
||
reasoning_buffer: _ReasoningBuffer | None = None,
|
||
) -> bool:
|
||
event = outbound_event_from_message(msg)
|
||
if isinstance(event, RetryWaitEvent):
|
||
await _print_interactive_progress_line(msg.content, thinking, renderer)
|
||
return True
|
||
|
||
if not isinstance(event, ProgressEvent):
|
||
return False
|
||
|
||
reasoning_buffer = reasoning_buffer or _ReasoningBuffer()
|
||
|
||
if event.reasoning_end:
|
||
if channels_config and not channels_config.show_reasoning:
|
||
reasoning_buffer.clear()
|
||
else:
|
||
_flush_cli_reasoning(reasoning_buffer, thinking, renderer)
|
||
return True
|
||
|
||
is_tool_hint = event.tool_hint
|
||
is_reasoning = event.reasoning or event.reasoning_delta
|
||
if is_reasoning:
|
||
if channels_config and not channels_config.show_reasoning:
|
||
reasoning_buffer.clear()
|
||
return True
|
||
text = reasoning_buffer.add(msg.content)
|
||
if text:
|
||
_print_cli_reasoning(text, thinking, renderer)
|
||
return True
|
||
if channels_config and is_tool_hint and not channels_config.send_tool_hints:
|
||
return True
|
||
if channels_config and not is_tool_hint and not channels_config.send_progress:
|
||
return True
|
||
|
||
await _print_interactive_progress_line(msg.content, thinking, renderer)
|
||
return True
|
||
|
||
|
||
def _is_exit_command(command: str) -> bool:
|
||
"""Return True when input should end interactive chat."""
|
||
return command.lower() in EXIT_COMMANDS
|
||
|
||
|
||
async def _read_interactive_input_async() -> str:
|
||
"""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:
|
||
with patch_stdout():
|
||
return await _prompt_session.prompt_async(
|
||
HTML("<b fg='ansiblue'>You:</b> "),
|
||
)
|
||
except EOFError as exc:
|
||
raise KeyboardInterrupt from exc
|