diff --git a/tools/tui_real_snapshot.py b/tools/tui_real_snapshot.py new file mode 100644 index 0000000..e1c2506 --- /dev/null +++ b/tools/tui_real_snapshot.py @@ -0,0 +1,133 @@ +""" +v8.0 真·全屏 TUI(textual)无头自验 + 截图 +========================================== +用 textual 的 App.run_test() + Pilot 无头驱动真实 XiaoliTUI: +- 断言 DOM 结构(滚动聊天区 / 底部输入框 / 侧栏 / 状态栏) +- 模拟输入并发送一轮对话,驱动 cli.tui_output_callback 渲染气泡 +- 断言渲染结果,导出 SVG 截图到 .tui_snapshots/real_demo.svg +AI 可据此自验"真 TUI 长啥样",无需人工截图。 +""" +import os +import sys +import asyncio + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from xcli_core.tui import XiaoliTUI, TEXTUAL_AVAILABLE + +OUT_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".tui_snapshots") + + +# ── 模拟 cli(满足 _TUIBridge 依赖)── +class _MockEngine: + name = "openai" + model = "deepseek-chat" + last_prompt_tokens = 47000 + def context_window(self): + return 64000 + + +class _MockMgr: + tools = [ + {"name": "file_manager", "description": "读写文件"}, + {"name": "git_tools", "description": "git 操作"}, + {"name": "memory", "description": "记忆系统"}, + ] + + +class _MockCLI: + def __init__(self): + self.engines = {"openai": _MockEngine()} + self.current_engine = self.engines["openai"] # 状态栏 token 进度条依赖此 + self.liugin_manager = _MockMgr() + self.shared_conversation_history = [] + self.liugin_commands = {} + self.tui_output_callback = None + + def get_current_engine_name(self): + return "openai" + + def switch_engine(self, name): + return name in self.engines + + def process_conversation(self, user_input): + """模拟真实流程:经 tui_output_callback 喂 assistant/tool 输出""" + self.shared_conversation_history.append({"role": "user", "content": user_input}) + cb = self.tui_output_callback + if cb: + cb(" 好的,我先看一下文件结构。") + cb(" OK 工具: file_manager") + cb(" 已读取 core/app.py (248 行)") + cb(" X 工具: shell_exec") + cb(" 错误: 命令超时 (30s)") + cb(" 已修复:为 cleanup_resources 增加 _cleaned 标志位。") + self.shared_conversation_history.append({"role": "assistant", "content": "已修复。"}) + + +def _txt(w): + """提取 widget 文本(textual 8: Static.content)""" + return str(getattr(w, "content", "") or getattr(w, "renderable", "") or "") + + +async def run(): + app = XiaoliTUI(_MockCLI()) + async with app.run_test(size=(120, 64)) as pilot: + await pilot.pause() + + # 1) DOM 结构断言 + chat = app.query_one("#chat-scroll") + ta = app.query_one("#user-input") + sidebar = app.query_one("#sidebar") + statusbar = app.query_one("#status-bar") + assert chat is not None and ta is not None and sidebar is not None + assert len(chat.children) > 0, "欢迎界面应已渲染" + + # 2) 模拟输入并发送一轮 + ta.text = "帮我看下 core/app.py 有没有内存泄漏" + app.action_send_message() + + # 等待后台 worker(executor 线程)完成 + for _ in range(80): + await pilot.pause(0.05) + if not app.is_generating: + break + await pilot.pause(0.2) + + # 3) 渲染结果断言(DOM 含全部气泡,不受滚动视口影响) + rendered = "\n".join(_txt(w) for w in chat.children) + checks = { + "欢迎框 v8.0.0": "8.0.0" in rendered, + "用户消息": "core/app.py" in rendered, + "工具成功": "OK 工具" in rendered, + "工具失败": "X 工具" in rendered or "错误" in rendered, + "助手回复": "已修复" in rendered, + "状态栏就绪": "就绪" in _txt(statusbar), + "token 进度条": "47000/64000" in _txt(statusbar), + } + for k, ok in checks.items(): + print(f"[{'PASS' if ok else 'FAIL'}] {k}") + + # 4) 导出 SVG 截图(AI/人都能看) + os.makedirs(OUT_DIR, exist_ok=True) + svg_path = os.path.join(OUT_DIR, "real_demo.svg") + svg = app.export_screenshot() + with open(svg_path, "w", encoding="utf-8") as f: + f.write(svg) + print(f"\nSVG 截图已保存: {svg_path} ({len(svg)} bytes)") + + # 5) 同时导出文本快照(供 AI 快速读回核对) + text_path = os.path.join(OUT_DIR, "real_demo.txt") + with open(text_path, "w", encoding="utf-8") as f: + f.write(rendered) + print(f"文本快照已保存: {text_path}") + + ok = all(checks.values()) + print("\n自验结论:", "全部通过 ✅" if ok else "存在失败项 ❌") + return ok + + +if __name__ == "__main__": + if not TEXTUAL_AVAILABLE: + print("textual 未安装,无法运行。pip install textual") + sys.exit(1) + sys.exit(0 if asyncio.run(run()) else 1) diff --git a/xcli_core/cli_core.py b/xcli_core/cli_core.py index 22d7f03..892f388 100644 --- a/xcli_core/cli_core.py +++ b/xcli_core/cli_core.py @@ -999,7 +999,13 @@ multi 操作支持一次修改多处: # ── CLI 主循环 ── def run_tui(self): - """运行 TUI 模式(v8.0 默认 Rich 面板 TUI,支持快照自验;textual 作为可选回退)""" + """运行 TUI 模式(v8.0 默认真·全屏 textual TUI;textual 不可用时回退 rich 面板)""" + if TEXTUAL_AVAILABLE: + from .tui import XiaoliTUI + app = XiaoliTUI(self) + app.run() + return + # 回退 rich 面板 TUI(textual 不可用时) try: from .rich_tui import RichTUI from xcli_core.constants import VERSION as _V @@ -1013,15 +1019,8 @@ multi 操作支持一次修改多处: self.tui_output_callback = tui.push tui.run() self.tui_output_callback = None - return except ImportError: - # 回退到旧版 textual TUI - if not TEXTUAL_AVAILABLE: - print(f"{Fore.RED}TUI 不可用: 请安装 rich (pip install rich){Style.RESET_ALL}") - return - from .tui import XiaoliTUI - app = XiaoliTUI(self) - app.run() + print(f"{Fore.RED}TUI 不可用: 请安装 textual (pip install textual){Style.RESET_ALL}") def run(self): """运行 CLI""" diff --git a/xcli_core/tui.py b/xcli_core/tui.py index 659b35a..36581f7 100644 --- a/xcli_core/tui.py +++ b/xcli_core/tui.py @@ -1,6 +1,6 @@ """TUI 界面模块 - Textual TUI 实现 (动画增强版 + 多行输入)""" -from .constants import TEXTUAL_AVAILABLE +from .constants import TEXTUAL_AVAILABLE, VERSION if TEXTUAL_AVAILABLE: import re @@ -379,7 +379,12 @@ if TEXTUAL_AVAILABLE: self.styles.opacity = 0 def on_mount(self): - self.animate("opacity", value=1.0, duration=0.3, easing="out_cubic") + # textual >= 8: Widget.opacity 是只读属性,直接 animate 会抛 + # "property 'opacity' has no setter"。改为在 styles 上动画,失败则直接到位。 + try: + self.styles.animate("opacity", value=1.0, duration=0.3, easing="out_cubic") + except Exception: + self.styles.opacity = 1.0 class TypingIndicator(Static): @@ -498,7 +503,7 @@ if TEXTUAL_AVAILABLE: "", " ╔══════════════════════════════════════════════╗", " ║ ║", - " ║ 小狸 Pro-CLI v5.4.1 ║", + " ║ 小狸 Pro-CLI v8.0.0 ║", " ║ 智能编程助手 · 动画增强版 ║", " ║ ║", " ╚══════════════════════════════════════════════╝", @@ -542,7 +547,7 @@ if TEXTUAL_AVAILABLE: ("", "msg-dim"), (" ╔══════════════════════════════════════════════╗", "msg-welcome"), (" ║ ║", "msg-welcome"), - (" ║ 小狸 Pro-CLI v5.4.1 ║", "msg-welcome"), + (" ║ 小狸 Pro-CLI v8.0.0 ║", "msg-welcome"), (" ║ 智能编程助手 · 动画增强版 ║", "msg-welcome"), (" ║ ║", "msg-welcome"), (" ╚══════════════════════════════════════════════╝", "msg-welcome"), @@ -587,7 +592,28 @@ if TEXTUAL_AVAILABLE: def _update_status(self, text: str): bar = self.query_one("#status-bar") engine = self.bridge.current_engine() - bar.update(f" {text} | {engine} | {len(self.bridge.history())} 条对话") + bar.update(f" {text} | {engine} | {len(self.bridge.history())} 条对话{self._token_str()}") + + def _token_str(self): + """token 用量进度(v8.0 token 感知压缩联动,引擎支持时显示)""" + e = getattr(self.cli, 'current_engine', None) + if not e: + return "" + used = getattr(e, 'last_prompt_tokens', None) + win = None + if hasattr(e, 'context_window'): + try: + win = e.context_window() + except Exception: + win = None + if used and win: + ratio = min(1.0, used / win) + filled = int(ratio * 10) + bar_s = "▓" * filled + "░" * (10 - filled) + return f" | {used}/{win} {bar_s}" + if used: + return f" | {used} tok" + return "" # ── 消息追加 ── @@ -743,12 +769,14 @@ if TEXTUAL_AVAILABLE: 'cls': lambda: self.action_clear(), 'model': lambda: self._switch_model(args), 'engine': lambda: self._switch_model(args), - 'about': lambda: self._system(" 小狸 Pro-CLI v5.4.1 - 智能编程助手"), + 'about': lambda: self._system(f" 小狸 Pro-CLI v{VERSION} - 智能编程助手"), 'status': lambda: self._show_status(), 'tools': lambda: self._show_tools(), 'engines': lambda: self._show_engines(), 'tui': lambda: self._system("已在 TUI 模式中"), 'manual': lambda: self._handle_manual(args), + 'snapshot': lambda: self._save_screenshot(), + 'screenshot': lambda: self._save_screenshot(), } handler = cmds.get(name) @@ -918,6 +946,22 @@ if TEXTUAL_AVAILABLE: self._system("已取消") self.is_generating = False + def _save_screenshot(self): + """导出当前 TUI 屏幕截图(SVG)——AI 可据此自验实时渲染(/snapshot)""" + import os + out_dir = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".tui_snapshots" + ) + os.makedirs(out_dir, exist_ok=True) + path = os.path.join(out_dir, "live.svg") + try: + svg = self.export_screenshot() + with open(path, "w", encoding="utf-8") as f: + f.write(svg) + self._system(f"截图已保存: {path}") + except Exception as e: + self._error(f"截图失败: {e}") + else: # Textual 不可用时的占位类 class XiaoliTUI: