Files
xiaoli-cli/xcli_core/tui.py
T

467 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""TUI 界面模块 - Textual TUI 实现"""
from .constants import TEXTUAL_AVAILABLE
if TEXTUAL_AVAILABLE:
import re
import asyncio
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical, VerticalScroll, Container
from textual.widgets import (
Header, Footer, Input, Static, Rule, Label,
DataTable, ProgressBar, TabbedContent, TabPane
)
from textual.reactive import reactive, var
from textual.message import Message
from textual.binding import Binding
from textual import work, on
from rich.text import Text
from rich.markdown import Markdown as RichMarkdown
from rich.syntax import Syntax
from rich.panel import Panel
from rich.table import Table
from rich.tree import Tree as RichTree
from rich.box import ROUNDED, HEAVY, DOUBLE
# ── TUI 主题色 ──
class _Theme:
BG = "#0d1117"
BG_LIGHT = "#161b22"
BG_INPUT = "#0d1117"
BORDER = "#30363d"
BORDER_FOCUS = "#58a6ff"
TEXT = "#c9d1d9"
TEXT_DIM = "#484f58"
TEXT_MUTED = "#8b949e"
ACCENT = "#58a6ff"
SUCCESS = "#3fb950"
WARNING = "#d29922"
ERROR = "#f85149"
USER = "#79c0ff"
AI = "#c9d1d9"
TOOL = "#56d364"
TOOL_ERR = "#f85149"
CODE_BG = "#161b22"
_TUI_CSS = f"""
Screen {{ background: {_Theme.BG}; }}
#app-container {{ height: 100%; width: 100%; }}
#main {{ width: 1fr; height: 1fr; }}
#chat-scroll {{
height: 1fr; background: {_Theme.BG};
scrollbar-color: {_Theme.BORDER};
scrollbar-color-hover: {_Theme.TEXT_MUTED};
padding: 0 1;
}}
#input-area {{
height: auto; min-height: 4; max-height: 10; padding: 1 1 0 1;
}}
#user-input {{
height: auto; min-height: 3;
background: {_Theme.BG_INPUT};
border: tall {_Theme.BORDER};
color: {_Theme.TEXT}; padding: 0 1;
}}
#user-input:focus {{ border: tall {_Theme.BORDER_FOCUS}; }}
#input-hint {{
height: 1; color: {_Theme.TEXT_DIM};
padding: 0 1;
}}
#sidebar {{
width: 32; min-width: 32; height: 1fr;
background: {_Theme.BG_LIGHT};
border-left: wide {_Theme.BORDER};
display: block;
}}
#sidebar.hidden {{ display: none; width: 0; min-width: 0; }}
.sidebar-header {{
width: 100%; text-align: center;
color: {_Theme.ACCENT}; text-style: bold;
padding: 1 0 0 0;
}}
.sidebar-section {{ height: auto; padding: 0 1; margin: 0 0 1 0; }}
.engine-item {{ padding: 0 1; color: {_Theme.TEXT_MUTED}; }}
.engine-item-active {{ padding: 0 1; color: {_Theme.SUCCESS}; text-style: bold; }}
.tool-item {{ padding: 0 0 0 1; color: {_Theme.TEXT_MUTED}; }}
.tool-item-name {{ color: {_Theme.TEXT}; text-style: bold; }}
#status-bar {{
height: 1; width: 100%; dock: bottom;
background: {_Theme.BG_LIGHT};
color: {_Theme.TEXT_MUTED}; padding: 0 1;
}}
.msg-user {{ color: {_Theme.USER}; padding: 1 0 0 1; text-style: bold; }}
.msg-ai {{ color: {_Theme.AI}; padding: 0 1; }}
.msg-system {{ color: {_Theme.ACCENT}; padding: 0 1; }}
.msg-tool-ok {{ color: {_Theme.TOOL}; padding: 0 1; }}
.msg-tool-err {{ color: {_Theme.TOOL_ERR}; padding: 0 1; }}
.msg-thinking {{ color: {_Theme.TEXT_DIM}; text-style: italic; padding: 0 1; }}
.msg-error {{ color: {_Theme.ERROR}; padding: 0 1; }}
.msg-dim {{ color: {_Theme.TEXT_DIM}; padding: 0 1; }}
.msg-welcome {{ color: {_Theme.ACCENT}; padding: 0 1; text-style: bold; }}
.code-block {{
background: {_Theme.CODE_BG};
border: wide {_Theme.BORDER};
padding: 0 1; margin: 0 2 0 2;
}}
Tab {{ background: {_Theme.BG}; }}
Tab.-active {{ background: {_Theme.BG_LIGHT}; }}
TabbedContent > Tabs {{ background: {_Theme.BG}; }}
"""
class _TUIBridge:
"""桥接 TUI ↔ AICLI"""
def __init__(self, cli):
self.cli = cli
def engines(self) -> list:
return list(self.cli.engines.keys())
def current_engine(self) -> str:
return self.cli.get_current_engine_name()
def tools(self) -> list:
return self.cli.liugin_manager.tools
def history(self) -> list:
return self.cli.shared_conversation_history
def switch_engine(self, name: str) -> bool:
if name in self.cli.engines:
self.cli.switch_engine(name)
return True
return False
class XiaoliTUI(App):
"""小狸 TUI v2 - GitHub 暗色主题"""
CSS = _TUI_CSS
TITLE = "🐱 小狸 Pro-CLI"
SUB_TITLE = "智能编程助手"
BINDINGS = [
Binding("ctrl+c", "quit", "退出", show=True),
Binding("ctrl+l", "clear", "清屏", show=True),
Binding("ctrl+n", "new_chat", "新对话", show=True),
Binding("f1", "toggle_sidebar", "侧栏", show=True),
Binding("escape", "cancel", "取消", show=False),
]
sidebar_visible = var(True)
is_generating = var(False)
def __init__(self, cli):
super().__init__()
self.cli = cli
self.bridge = _TUIBridge(cli)
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
with Horizontal(id="app-container"):
with Vertical(id="main"):
yield VerticalScroll(id="chat-scroll")
with Vertical(id="input-area"):
yield Input(
placeholder=" 输入消息... (Enter 发送, /help 帮助)",
id="user-input"
)
yield Static(
" Tab 补全 | ↑↓ 历史 | Ctrl+L 清屏 | F1 侧栏",
id="input-hint"
)
with Vertical(id="sidebar"):
yield Static("⚙️ 引擎", classes="sidebar-header")
yield Vertical(id="engine-list", classes="sidebar-section")
yield Rule(line_style="heavy")
yield Static("🔧 工具", classes="sidebar-header")
yield Vertical(id="tool-list", classes="sidebar-section")
yield Rule(line_style="heavy")
yield Static("📊 状态", classes="sidebar-header")
yield Vertical(id="status-info", classes="sidebar-section")
yield Static(" 就绪 | Ctrl+C 退出", id="status-bar")
def on_mount(self):
self._render_welcome()
self._update_sidebar()
self.query_one("#user-input").focus()
def _render_welcome(self):
scroll = self.query_one("#chat-scroll")
lines = [
("", "msg-dim"),
(" ╔══════════════════════════════════════════════╗", "msg-welcome"),
(" ║ ║", "msg-welcome"),
(" ║ 🐱 小狸 Pro-CLI v5.1 ║", "msg-welcome"),
(" ║ 智能编程助手 · 对标 Claude Code ║", "msg-welcome"),
(" ║ ║", "msg-welcome"),
(" ╚══════════════════════════════════════════════╝", "msg-welcome"),
("", "msg-dim"),
(" 💡 代码编辑 · 代码搜索 · Git 集成 · 多引擎", "msg-system"),
(" 📝 输入 /help 查看命令 | /model 切换引擎", "msg-system"),
(f" 🔧 当前引擎: {self.bridge.current_engine()} | 工具: {len(self.bridge.tools())} 个", "msg-system"),
("", "msg-dim"),
]
for text, cls in lines:
scroll.mount(Static(text, classes=cls))
def _update_sidebar(self):
engine_container = self.query_one("#engine-list")
engine_container.remove_children()
current = self.bridge.current_engine()
for name in self.bridge.engines():
if name == current:
engine_container.mount(Static(f" ▸ {name}", classes="engine-item-active"))
else:
engine_container.mount(Static(f" {name}", classes="engine-item"))
tool_container = self.query_one("#tool-list")
tool_container.remove_children()
for tool in self.bridge.tools():
name = tool.get('name', '?')
tool_container.mount(Static(f" • {name}", classes="tool-item"))
status_container = self.query_one("#status-info")
status_container.remove_children()
status_container.mount(Static(f" 对话: {len(self.bridge.history())} 条", classes="tool-item"))
status_container.mount(Static(f" 工具: {len(self.bridge.tools())} 个", classes="tool-item"))
status_container.mount(Static(f" 引擎: {len(self.bridge.engines())} 个", classes="tool-item"))
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())} 条对话")
def _append(self, widget):
scroll = self.query_one("#chat-scroll")
scroll.mount(widget)
scroll.scroll_end(animate=False)
def _add(self, text, cls="msg-dim"):
self._append(Static(text, classes=cls))
def _user_msg(self, text):
self._add(f" 👤 {text}", "msg-user")
def _ai_msg(self, text):
if "```" in text:
self._render_with_code(text)
else:
self._add(f" ✦ {text}", "msg-ai")
def _render_with_code(self, text):
parts = re.split(r'```(\w*)\n(.*?)```', text, flags=re.DOTALL)
i = 0
while i < len(parts):
if i % 3 == 0:
if parts[i].strip():
for line in parts[i].strip().split('\n'):
self._add(f" ✦ {line}", "msg-ai")
elif i % 3 == 2:
code = parts[i]
lang = parts[i-1] if i > 1 else ""
try:
syntax = Syntax(code, lang or "python", theme="monokai",
line_numbers=True, word_wrap=True)
self._append(Panel(syntax, border_style=f"dim {_Theme.BORDER}",
box=ROUNDED, padding=(0, 1)))
except Exception:
for line in code.split('\n'):
self._add(f" {line}", "msg-dim")
i += 1
def _tool_ok(self, name, args):
self._add(f" ✅ {name}: {args[:60]}", "msg-tool-ok")
def _tool_err(self, name, args):
self._add(f" ❌ {name}: {args[:60]}", "msg-tool-err")
def _thinking(self):
self._add(" 💭 思考中...", "msg-thinking")
def _system(self, text):
self._add(f" ℹ️ {text}", "msg-system")
def _error(self, text):
self._add(f" ⚠️ {text}", "msg-error")
@on(Input.Submitted, "#user-input")
def on_input(self, event):
text = event.value.strip()
if not text:
return
event.input.value = ""
if text.startswith('/'):
self._handle_command(text)
return
self._user_msg(text)
self._thinking()
self.is_generating = True
self._update_status("🔄 思考中...")
self.run_worker(self._generate(text), exclusive=True)
def _handle_command(self, cmd):
parts = cmd[1:].split(maxsplit=1)
name = parts[0].lower()
args = parts[1] if len(parts) > 1 else ""
cmds = {
'help': lambda: self._show_help(),
'quit': lambda: self.exit(),
'q': lambda: self.exit(),
'exit': lambda: self.exit(),
'cli': lambda: self._switch_cli(),
'clear': lambda: self.action_clear(),
'cls': lambda: self.action_clear(),
'model': lambda: self._switch_model(args),
'engine': lambda: self._switch_model(args),
'about': lambda: self._system("🐱 小狸 Pro-CLI v5.1 - 智能编程助手"),
'status': lambda: self._show_status(),
'tools': lambda: self._show_tools(),
'engines': lambda: self._show_engines(),
'tui': lambda: self._system("已在 TUI 模式中"),
}
handler = cmds.get(name)
if handler:
handler()
else:
if name in self.cli.liugin_commands:
try:
result = self.cli.liugin_commands[name](args)
if result:
self._system(result[:500])
except Exception as e:
self._error(f"插件命令失败: {e}")
else:
self._error(f"未知命令: /{name},输入 /help 查看帮助")
def _show_help(self):
help_text = """ 📖 命令:
/help 帮助信息
/quit 退出
/cli 切换命令行模式
/model <引擎> 切换 AI 引擎
/engines 列出引擎
/tools 列出工具
/status 系统状态
/clear 清屏
⌨️ 快捷键:
Ctrl+C 退出 Ctrl+L 清屏
Ctrl+N 新对话 F1 侧栏
Escape 取消生成"""
self._system(help_text)
def _switch_cli(self):
self._system("切换到命令行模式...")
self.cli.tui_output_callback = None
self.exit()
def _switch_model(self, name):
name = name.strip()
if not name:
self._show_engines()
return
if self.bridge.switch_engine(name):
self._system(f"已切换到: {name}")
self._update_sidebar()
else:
self._error(f"未找到引擎: {name}")
def _show_engines(self):
current = self.bridge.current_engine()
lines = [" 可用引擎:"]
for name in self.bridge.engines():
marker = "▸" if name == current else " "
lines.append(f" {marker} {name}")
self._system('\n'.join(lines))
def _show_tools(self):
tools = self.bridge.tools()
lines = [f" 可用工具 ({len(tools)} 个):"]
for t in tools:
name = t.get('name', '?')
desc = t.get('description', '')[:40]
lines.append(f" • {name}: {desc}")
self._system('\n'.join(lines))
def _show_status(self):
self._system(f""" 系统状态:
引擎: {self.bridge.current_engine()}
引擎数: {len(self.bridge.engines())}
工具数: {len(self.bridge.tools())}
对话数: {len(self.bridge.history())}""")
async def _generate(self, user_input):
try:
self.call_after_refresh(self._remove_thinking)
def tui_output(msg):
self.call_after_refresh(self._write_raw, msg)
original = self.cli.tui_output_callback
self.cli.tui_output_callback = tui_output
try:
loop = asyncio.get_running_loop()
await loop.run_in_executor(
None, self.cli.process_conversation, user_input
)
finally:
self.cli.tui_output_callback = original
except Exception as e:
self.call_after_refresh(self._error, str(e))
finally:
self.is_generating = False
self.call_after_refresh(lambda: self._update_status("就绪"))
self.call_after_refresh(self._update_sidebar)
def _remove_thinking(self):
try:
scroll = self.query_one("#chat-scroll")
for child in reversed(list(scroll.children)):
renderable = getattr(child, 'renderable', None)
text = str(renderable) if renderable is not None else ""
if '思考中' in text:
child.remove()
break
except Exception:
pass
def _write_raw(self, msg):
if '✅' in msg or 'OK 工具' in msg:
self._add(f" {msg}", "msg-tool-ok")
elif '❌' in msg or 'X 工具' in msg or '错误' in msg:
self._add(f" {msg}", "msg-tool-err")
elif '✦' in msg:
self._add(f" {msg}", "msg-ai")
elif '工具' in msg and ('调用' in msg or '执行' in msg):
self._add(f" {msg}", "msg-tool-ok")
else:
self._add(f" {msg}", "msg-dim")
def action_clear(self):
scroll = self.query_one("#chat-scroll")
scroll.remove_children()
self._render_welcome()
def action_new_chat(self):
self.cli.shared_conversation_history.clear()
self.action_clear()
self._system("已开始新对话")
def action_toggle_sidebar(self):
sidebar = self.query_one("#sidebar")
sidebar.visible = not sidebar.visible
def action_cancel(self):
if self.is_generating:
self._system("已取消")
self.is_generating = False
else:
# Textual 不可用时的占位类
class XiaoliTUI:
"""Textual 未安装时的占位类"""
def __init__(self, *args, **kwargs):
pass
def run(self, *args, **kwargs):
print("TUI 模式不可用:Textual 库未安装")
print("请运行: pip install textual rich")