import asyncio import logging import shlex import signal import sys from uuid import uuid4 from yuxi.channel.context import ChannelContext from yuxi.channel.message.models import MessageType, PeerInfo, PeerKind, UnifiedMessage logger = logging.getLogger(__name__) RICH_AVAILABLE = False _rich_console = None _RICH_THEMES: dict = {} try: from rich.console import Console from rich.markdown import Markdown from rich.theme import Theme _RICH_THEMES = { "dark": None, "light": Theme( { "markdown.code": "bright_black on white", "markdown.heading": "bold blue", } ), "no-color": Theme(inherit=False), } def _init_rich_console(theme: str = "dark") -> None: global _rich_console, RICH_AVAILABLE try: _rich_console = Console(theme=_RICH_THEMES.get(theme)) RICH_AVAILABLE = True except Exception: _rich_console = None RICH_AVAILABLE = False _init_rich_console("dark") except ImportError: pass PROMPT_TOOLKIT_AVAILABLE = False _prompt_session = None _kb = None try: from prompt_toolkit import PromptSession from prompt_toolkit.completion import Completer, Completion from prompt_toolkit.history import InMemoryHistory from prompt_toolkit.key_binding import KeyBindings class _TerminalCompleter(Completer): _COMMANDS = ["help", "exit", "quit", "clear", "history", "model"] def get_completions(self, document, complete_event): text = document.text_before_cursor.strip() if text.startswith("/"): word = text[1:].split()[0] if len(text) > 1 else "" for cmd in self._COMMANDS: if cmd.startswith(word): yield Completion( f"/{cmd}", start_position=-len(text), display_meta=f"/{cmd}", ) _kb = KeyBindings() @_kb.add("escape", "enter") def _multiline_send(event): event.current_buffer.validate_and_handle() try: _prompt_session = PromptSession( history=InMemoryHistory(), completer=_TerminalCompleter(), ) PROMPT_TOOLKIT_AVAILABLE = True except Exception: logger.debug("prompt_toolkit PromptSession unavailable (no console), falling back to readline") except ImportError: pass class TerminalAdapter: def __init__(self): self._msg_counter = 0 self._ctx: ChannelContext | None = None self._original_sigint = None self._typing_task: asyncio.Task | None = None self._is_typing = False self._history: list[str] = [] self._commands = { "help": self._cmd_help, "exit": self._cmd_exit, "quit": self._cmd_exit, "clear": self._cmd_clear, "history": self._cmd_history, "model": self._cmd_model, } def list_account_ids(self, config: dict) -> list[str]: return ["default"] async def resolve_account(self, account_id: str) -> dict: return { "account_id": "default", "enabled": True, "configured": True, "name": "Terminal", } def is_configured(self, account: dict) -> bool: return True def is_enabled(self, account: dict) -> bool: return True async def start(self, ctx: ChannelContext) -> None: logger.info("Terminal channel starting (stdin listener)") self._ctx = ctx if RICH_AVAILABLE: account_config = ctx.config.get("accounts", {}).get("default", {}) theme = account_config.get("color_theme", "dark") _init_rich_console(theme) self._print_greeting() def _sigint_handler(_signum, _frame): sys.stdout.write("\n\n[中断请求,正在取消当前回复...]\n") sys.stdout.flush() ctx.cancel_event.set() self._original_sigint = signal.signal(signal.SIGINT, _sigint_handler) try: while not ctx.cancel_event.is_set(): try: line = await self._read_stdin_line() except EOFError: logger.info("Terminal stdin closed (EOF)") sys.stdout.write("\nGoodbye!\n") sys.stdout.flush() break if line is None: break text = line.strip() if not text: if PROMPT_TOOLKIT_AVAILABLE: continue self._print_prompt() continue if text.startswith("/") and self._handle_command(text): if not PROMPT_TOOLKIT_AVAILABLE: self._print_prompt() continue if text.startswith('"""'): text = await self._read_multiline_input(text) if text is None or not text.strip(): if not PROMPT_TOOLKIT_AVAILABLE: self._print_prompt() continue self._enqueue_message(text) finally: if self._original_sigint is not None: signal.signal(signal.SIGINT, self._original_sigint) self._original_sigint = None async def stop(self, ctx: ChannelContext) -> None: logger.info("Terminal channel stopping") ctx.cancel_event.set() if self._original_sigint is not None: signal.signal(signal.SIGINT, self._original_sigint) self._original_sigint = None await self.clear_typing("terminal-default") sys.stdout.write("\n") sys.stdout.flush() async def send_text(self, target_id: str, content: str) -> None: sys.stdout.write("\n") if RICH_AVAILABLE: _rich_console.print(Markdown(content)) else: sys.stdout.write(content) sys.stdout.write("\n") sys.stdout.flush() if not PROMPT_TOOLKIT_AVAILABLE: self._print_prompt() async def send_typing(self, target_id: str, thread_id: str | None = None) -> None: if not sys.stdin.isatty(): return self._is_typing = True self._typing_task = asyncio.create_task(self._typing_animation()) async def clear_typing(self, target_id: str, thread_id: str | None = None) -> None: self._is_typing = False if self._typing_task and not self._typing_task.done(): self._typing_task.cancel() try: await self._typing_task except asyncio.CancelledError: pass self._typing_task = None sys.stdout.write("\r\033[K") sys.stdout.flush() def _enqueue_message(self, text: str) -> None: self._history.append(text) if len(self._history) > 100: self._history.pop(0) self._msg_counter += 1 msg_id = f"term:{uuid4().hex[:12]}" msg = UnifiedMessage( msg_id=msg_id, channel_type="terminal", account_id="default", content=text, message_type=MessageType.TEXT, sender=PeerInfo( kind=PeerKind.DIRECT, id="terminal-user", display_name="Dev", ), timestamp=None, ) asyncio.create_task(self._put_message(msg)) async def _put_message(self, msg: UnifiedMessage) -> None: if self._ctx is None: return if self._ctx.queue.qsize() >= self._ctx.queue.maxsize * 0.8: self._print_warning("消息队列繁忙,请稍候...") await self._ctx.queue.put(msg) def _handle_command(self, text: str) -> bool: try: parts = shlex.split(text) except ValueError: return False if not parts or not parts[0].startswith("/"): return False cmd_name = parts[0][1:].lower() handler = self._commands.get(cmd_name) if handler and self._ctx: handler(self._ctx) return True return False @staticmethod def _cmd_help(ctx: ChannelContext) -> None: help_text = ( "\n可用命令:\n" " /help 显示此帮助信息\n" " /exit, /quit 退出 Terminal 渠道\n" " /clear 清屏\n" " /history 显示输入历史\n" " /model 切换模型\n" "\n快捷键: Ctrl+C 中断回复 Ctrl+D 退出\n" ) sys.stdout.write(f"{help_text}\n") sys.stdout.flush() @staticmethod def _cmd_exit(ctx: ChannelContext) -> None: sys.stdout.write("\nGoodbye!\n") sys.stdout.flush() ctx.cancel_event.set() @staticmethod def _cmd_clear(ctx: ChannelContext) -> None: sys.stdout.write("\033[2J\033[H") sys.stdout.flush() @staticmethod def _cmd_model(ctx: ChannelContext) -> None: sys.stdout.write("\n模型切换请在系统中配置\n") sys.stdout.flush() def _cmd_history(self, ctx: ChannelContext) -> None: if not self._history: sys.stdout.write("\n暂无历史记录\n") sys.stdout.flush() return lines = ["\n历史记录:"] for i, entry in enumerate(self._history, 1): preview = entry[:60] + "..." if len(entry) > 60 else entry lines.append(f" {i}. {preview}") lines.append("") sys.stdout.write("\n".join(lines) + "\n") sys.stdout.flush() async def _read_stdin_line(self, multiline: bool = False) -> str | None: loop = asyncio.get_event_loop() if PROMPT_TOOLKIT_AVAILABLE: try: line = await loop.run_in_executor( None, lambda: _prompt_session.prompt( "... " if multiline else "> ", multiline=multiline, key_bindings=_kb if multiline else None, ), ) return line except EOFError: raise except KeyboardInterrupt: return "" line = await loop.run_in_executor(None, sys.stdin.readline) if not line: return None return line async def _read_multiline_input(self, first_line: str) -> str | None: remaining = first_line[3:] if PROMPT_TOOLKIT_AVAILABLE: try: loop = asyncio.get_event_loop() return await loop.run_in_executor( None, lambda: _prompt_session.prompt( "... ", multiline=True, key_bindings=_kb, default=remaining, ), ) except (KeyboardInterrupt, EOFError): return None lines = [] if remaining.strip(): lines.append(remaining.strip()) while self._ctx and not self._ctx.cancel_event.is_set(): sys.stdout.write("... ") sys.stdout.flush() try: next_line = await self._read_stdin_line() except EOFError: break if next_line is None: break stripped = next_line.strip() if stripped == '"""': break lines.append(next_line.rstrip("\n")) return "\n".join(lines) if lines else None async def _typing_animation(self) -> None: frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] idx = 0 while self._is_typing: frame = frames[idx % len(frames)] if RICH_AVAILABLE: sys.stdout.write(f"\r[bold yellow]{frame}[/bold yellow] 思考中...") else: sys.stdout.write(f"\r{frame} 思考中...") sys.stdout.flush() idx += 1 await asyncio.sleep(0.1) @staticmethod def _print_greeting() -> None: sys.stdout.write("Terminal 渠道已启动,输入问题与 Agent 对话") sys.stdout.write("\n按 Ctrl+C 中断回复,Ctrl+D 退出\n") if not PROMPT_TOOLKIT_AVAILABLE: sys.stdout.write("\n> ") sys.stdout.flush() @staticmethod def _print_prompt() -> None: sys.stdout.write("> ") sys.stdout.flush() @staticmethod def _print_warning(text: str) -> None: sys.stdout.write(f"\n[{text}]\n") sys.stdout.flush()