diff --git a/xiaoli/__main__.py b/xiaoli/__main__.py index 267de34..bcfcb25 100644 --- a/xiaoli/__main__.py +++ b/xiaoli/__main__.py @@ -29,12 +29,13 @@ def main(): # 检查 Textual 可用性 if not args.cli: try: - from textual.app import App + from xiaoli.tui import TEXTUAL_AVAILABLE + if not TEXTUAL_AVAILABLE: + raise ImportError except ImportError: - if not args.cli: - print("⚠️ Textual 未安装,自动切换到命令行模式") - print(" 安装 TUI: pip install textual") - args.cli = True + print("⚠️ Textual 未安装,自动切换到命令行模式") + print(" 安装 TUI: pip install textual rich") + args.cli = True from xiaoli.app import App app = App() diff --git a/xiaoli/app.py b/xiaoli/app.py index 43e7bce..24068bc 100644 --- a/xiaoli/app.py +++ b/xiaoli/app.py @@ -305,14 +305,14 @@ class App: def _run_tui(self): """启动 TUI 模式,不可用时降级到 CLI""" try: - from textual.app import App - from ai_cli import TUIMainApp + from xiaoli.tui import run_tui, TEXTUAL_AVAILABLE + if not TEXTUAL_AVAILABLE: + raise ImportError("Textual 未安装") self.display.info("正在启动 TUI 模式...") - app = TUIMainApp(self) - app.run() + run_tui(self) except ImportError: self.display.warn("Textual 未安装,TUI 不可用") - self.display.info("安装命令: pip install textual") + self.display.info("安装命令: pip install textual rich") self.display.info("已切换到命令行模式") self.run() except Exception as e: diff --git a/xiaoli/tui.py b/xiaoli/tui.py new file mode 100644 index 0000000..03a4d78 --- /dev/null +++ b/xiaoli/tui.py @@ -0,0 +1,476 @@ +""" +小狸 Pro-CLI TUI - 现代化终端界面 +""" +import asyncio +import os +import sys +from datetime import datetime + +# 确保项目根目录在 path 中 +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +try: + from textual.app import App, ComposeResult + from textual.containers import Horizontal, Vertical, VerticalScroll + from textual.widgets import Header, Footer, Input, Static, Rule, Label + from textual.binding import Binding + from textual.reactive import reactive + from rich.text import Text + from rich.markdown import Markdown + from rich.syntax import Syntax + from rich.panel import Panel + from rich.console import Group + TEXTUAL_AVAILABLE = True +except ImportError: + TEXTUAL_AVAILABLE = False + + +# ────────────────────────────────────────────── +# 样式常量 +# ────────────────────────────────────────────── + +CSS = """ +Screen { + background: #0a0e14; + layers: base overlay; +} + +#main-container { + height: 100%; + width: 100%; +} + +#chat-area { + width: 1fr; + height: 1fr; + padding: 0 1; +} + +#chat-log { + height: 1fr; + background: #0a0e14; + border: none; + scrollbar-color: #3d5a80; + scrollbar-color-hover: #5a8ab5; +} + +#input-container { + height: auto; + min-height: 3; + max-height: 8; + padding: 0 0 1 0; +} + +#user-input { + height: auto; + min-height: 3; + background: #1a1e24; + border: tall #3d5a80; + color: #e0e0e0; + padding: 0 1; +} + +#user-input:focus { + border: tall #5a8ab5; +} + +#sidebar { + width: 28; + min-width: 28; + height: 1fr; + background: #0d1117; + border-left: wide #1a2332; + padding: 1 0; +} + +#sidebar-title { + width: 100%; + text-align: center; + color: #5a8ab5; + text-style: bold; + padding: 0 0 1 0; +} + +#engine-list { + height: auto; + max-height: 12; + background: #0d1117; + padding: 0 1; + margin: 0 0 1 0; +} + +#engine-item { + padding: 0 1; + color: #8899aa; +} + +#engine-item-active { + padding: 0 1; + color: #58d68d; + text-style: bold; +} + +#tool-list { + height: 1fr; + background: #0d1117; + padding: 0 1; + overflow-y: auto; +} + +#tool-item { + padding: 0 1; + color: #6688aa; + text-size: 90%; +} + +#status-bar { + height: 1; + width: 100%; + dock: bottom; + background: #1a2332; + color: #5a8ab5; + padding: 0 1; +} + +#welcome { + width: 100%; + text-align: center; + color: #5a8ab5; + padding: 2 0; +} + +#version-info { + width: 100%; + text-align: center; + color: #445566; + padding: 0 0 2 0; +} + +.user-msg { + color: #e0e0e0; + padding: 0 0 0 1; + margin: 0 0 0 0; +} + +.ai-msg { + color: #c0d0e0; + padding: 0 0 0 0; +} + +.tool-msg-ok { + color: #58d68d; + padding: 0 0 0 1; +} + +.tool-msg-err { + color: #e74c3c; + padding: 0 0 0 1; +} + +.thinking-msg { + color: #7f8c8d; + text-style: italic; +} + +.system-msg { + color: #5a8ab5; + padding: 0 0 0 1; +} + +.error-msg { + color: #e74c3c; + padding: 0 0 0 1; +} + +.dim { + color: #556677; +} +""" + + +class ChatLog(VerticalScroll): + """聊天日志区域""" + pass + + +class Sidebar(Vertical): + """侧边栏""" + pass + + +class XiaoliTUI(App): + """小狸 TUI 主应用""" + + CSS = CSS + TITLE = "小狸 Pro-CLI" + SUB_TITLE = "智能编程助手" + + BINDINGS = [ + Binding("ctrl+c", "quit", "退出", show=True), + Binding("ctrl+l", "clear_chat", "清屏", show=True), + Binding("ctrl+n", "new_chat", "新对话", show=True), + Binding("f1", "toggle_sidebar", "侧栏", show=True), + ] + + is_generating = reactive(False) + + def __init__(self, app_instance): + super().__init__() + self.app_inst = app_instance + self.bridge = TUIBridge(app_instance) + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + with Horizontal(id="main-container"): + with Vertical(id="chat-area"): + yield ChatLog(id="chat-log") + with Vertical(id="input-container"): + yield Input( + placeholder=" 输入消息... (Enter 发送, /help 帮助)", + id="user-input" + ) + with Vertical(id="sidebar"): + yield Static("⚙️ AI 引擎", id="sidebar-title") + yield Vertical(id="engine-list") + yield Rule(line_style="ascii") + yield Static("🔧 可用工具", id="sidebar-title") + yield Vertical(id="tool-list") + yield Static(" 就绪", id="status-bar") + + def on_mount(self) -> None: + self._render_welcome() + self._update_engines() + self._update_tools() + self.query_one("#user-input").focus() + + def _render_welcome(self): + chat = self.query_one("#chat-log") + chat.mount(Static("", classes="dim")) + chat.mount(Static(" ╔══════════════════════════════════════╗", classes="system-msg")) + chat.mount(Static(" ║ ║", classes="system-msg")) + chat.mount(Static(" ║ 🐱 小狸 Pro-CLI v3.6 ║", classes="system-msg")) + chat.mount(Static(" ║ 智能编程助手 ║", classes="system-msg")) + chat.mount(Static(" ║ ║", classes="system-msg")) + chat.mount(Static(" ╚══════════════════════════════════════╝", classes="system-msg")) + chat.mount(Static("", classes="dim")) + chat.mount(Static(" 💡 快捷键: Ctrl+L 清屏 | Ctrl+N 新对话 | F1 侧栏", classes="dim")) + chat.mount(Static(" 📝 输入 /help 查看所有命令", classes="dim")) + chat.mount(Static(f" 🔧 当前引擎: {self.bridge.get_current_engine_name()}", classes="dim")) + chat.mount(Static("", classes="dim")) + + def _update_engines(self): + container = self.query_one("#engine-list") + container.remove_children() + current = self.bridge.get_current_engine_name() + for name in self.bridge.get_engines(): + cls = "engine-item-active" if name == current else "engine-item" + prefix = "▸ " if name == current else " " + container.mount(Static(f"{prefix}{name}", id=f"engine-{name}", classes=cls)) + + def _update_tools(self): + container = self.query_one("#tool-list") + container.remove_children() + tools = self.bridge.get_tools() + for tool in tools: + name = tool.get('name', '?') + container.mount(Static(f" • {name}", classes="tool-item")) + + def _update_status(self, text: str): + status = self.query_one("#status-bar") + status.update(f" {text}") + + def _append_chat(self, widget): + chat = self.query_one("#chat-log") + chat.mount(widget) + chat.scroll_end(animate=False) + + def _add_user_msg(self, text: str): + self._append_chat(Static(f" 你: {text}", classes="user-msg")) + + def _add_ai_msg(self, text: str): + # 尝试渲染 Markdown + try: + from rich.markdown import Markdown + md = Markdown(text) + self._append_chat(Static(md, classes="ai-msg")) + except: + self._append_chat(Static(f" ✦ {text}", classes="ai-msg")) + + def _add_tool_ok(self, name: str, args: str): + self._append_chat(Static(f" ✅ {name}: {args[:50]}", classes="tool-msg-ok")) + + def _add_tool_err(self, name: str, args: str): + self._append_chat(Static(f" ❌ {name}: {args[:50]}", classes="tool-msg-err")) + + def _add_system(self, text: str): + self._append_chat(Static(f" ℹ️ {text}", classes="system-msg")) + + def _add_error(self, text: str): + self._append_chat(Static(f" ⚠️ {text}", classes="error-msg")) + + def _add_thinking(self): + self._append_chat(Static(" 💭 AI 正在思考...", classes="thinking-msg")) + + def on_input_submitted(self, event: Input.Submitted): + user_input = event.value.strip() + if not user_input: + return + + event.input.value = "" + + # 命令处理 + if user_input.startswith('/'): + self._handle_command(user_input) + return + + # 显示用户消息 + self._add_user_msg(user_input) + + # 思考提示 + self._add_thinking() + + # 异步生成 + self.is_generating = True + self._update_status("🔄 AI 思考中...") + self.run_worker(self._generate(user_input), exclusive=True) + + def _handle_command(self, cmd: str): + parts = cmd[1:].split(maxsplit=1) + name = parts[0] + args = parts[1] if len(parts) > 1 else "" + + if name == 'help': + self._show_help() + elif name == 'quit': + self.exit() + elif name == 'cli': + self._add_system("切换到 CLI 模式...") + self.app_inst.tui_callback = None + self.exit() + self.app_inst.run() + elif name == 'model' or name == 'engine': + if args.startswith('switch '): + args = args[7:] + if self.bridge.switch_engine(args): + self._add_system(f"已切换到引擎: {args}") + self._update_engines() + else: + self._add_error(f"未找到引擎: {args}") + elif name == 'clear': + self.action_clear_chat() + elif name == 'about': + self._add_system("小狸 Pro-CLI v3.6 - 智能编程助手") + elif name == 'chat': + self._add_system("聊天记录功能请使用 CLI 模式") + else: + self._add_error(f"未知命令: /{name},输入 /help 查看帮助") + + def _show_help(self): + help_text = """ 📖 命令列表: + /help - 帮助信息 + /quit - 退出 + /cli - 切换到命令行模式 + /model <引擎> - 切换 AI 引擎 + /clear - 清屏 + /about - 关于 + + ⌨️ 快捷键: + Ctrl+C 退出 + Ctrl+L 清屏 + Ctrl+N 新对话 + F1 切换侧栏""" + self._add_system(help_text) + + async def _generate(self, user_input: str): + try: + # 清除思考提示 + self.call_after_refresh(self._remove_last_thinking) + + # 回调输出到 TUI + def tui_output(msg): + self.call_after_refresh(self._write_raw, msg) + + original_cb = self.app_inst.tui_callback + self.app_inst.tui_callback = tui_output + + try: + loop = asyncio.get_event_loop() + await loop.run_in_executor( + None, self.app_inst.process_conversation, user_input + ) + finally: + self.app_inst.tui_callback = original_cb + + except Exception as e: + self.call_after_refresh(self._add_error, str(e)) + finally: + self.is_generating = False + self.call_after_refresh(self._update_status, "就绪") + + def _remove_last_thinking(self): + """移除最后一条思考消息""" + chat = self.query_one("#chat-log") + children = list(chat.children) + for child in reversed(children): + if hasattr(child, 'renderable') and '思考' in str(getattr(child, 'renderable', '')): + child.remove() + break + + def _write_raw(self, message: str): + """直接写入消息(来自回调)""" + if '✅' in message or 'OK 工具' in message: + self._append_chat(Static(f" {message}", classes="tool-msg-ok")) + elif '❌' in message or 'X 工具' in message or '错误' in message: + self._append_chat(Static(f" {message}", classes="tool-msg-err")) + elif '✦' in message: + self._append_chat(Static(f" {message}", classes="ai-msg")) + else: + self._append_chat(Static(f" {message}", classes="dim")) + + def action_clear_chat(self): + chat = self.query_one("#chat-log") + chat.remove_children() + self._render_welcome() + + def action_new_chat(self): + self.app_inst.history.clear() + self.action_clear_chat() + self._add_system("已开始新对话") + + def action_toggle_sidebar(self): + sidebar = self.query_one("#sidebar") + sidebar.visible = not sidebar.visible + + +class TUIBridge: + """桥接 TUI 和 App""" + + def __init__(self, app_instance): + self.app = app_instance + + def get_engines(self) -> list: + return self.app.engine_mgr.names() + + def get_current_engine_name(self) -> str: + return self.app.engine_mgr.current_name() + + def get_tools(self) -> list: + if self.app.tool_mgr and hasattr(self.app.tool_mgr, '_tools'): + return list(self.app.tool_mgr._tools.values()) + return [] + + def get_conversation_history(self) -> list: + return self.app.history.get() + + def switch_engine(self, name: str) -> bool: + return self.app._switch_model(name) or False + + +def run_tui(app_instance): + """启动 TUI""" + if not TEXTUAL_AVAILABLE: + print("⚠️ Textual 未安装,无法启动 TUI") + print(" 安装: pip install textual rich") + return + tui = XiaoliTUI(app_instance) + tui.run()