fix(v8.0): 启用真·全屏 textual TUI 并修复 opacity 崩溃

- 修复崩溃: tui.py MessageBubble.on_mount 用 self.animate('opacity'),但
  textual>=8 的 Widget.opacity 为只读属性会抛 'has no setter',导致每条消息
  气泡崩溃 -> 改 styles.animate 并兜底直接设置。
- run_tui 改为默认真·全屏 textual XiaoliTUI(上方滚动聊天区+底部固定输入框+侧栏),
  rich 面板仅作 textual 不可用时的回退(此前默认的是顺序面板,被指出是'假TUI')。
- 版本统一 v8.0.0: 欢迎框 + /about 改用 VERSION 常量。
- 状态栏联动 token 感知: 显示 used/context_window 进度条(引擎支持时)。
- 新增 /snapshot(/screenshot) 命令: 导出当前屏幕 SVG 到 .tui_snapshots/live.svg,
  让实时 TUI 也能被 AI 自验。
- 新增 tools/tui_real_snapshot.py: textual run_test+Pilot 无头驱动真实 TUI,
  喂对话、断言 DOM(用户/工具成功失败/助手/状态栏/token条/欢迎版本),导出 SVG。
  7 项断言全过。
- 注: launcher 启动会重写 plugins_config.py/requirements.txt(含 tkinter 等错误项),
  提交前已还原,避免误提交漂移。
This commit is contained in:
shuiyu67 committed 2026-08-01 10:30:42 +08:00
1 parent 79957eba0f
commit db9203687d
3 files changed
+191 -15

No files matched your search

+133
View File
@@ -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)
+8 -9
View File
@@ -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"""
+50 -6
View File
@@ -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: