Files
xiaoli-cli/xcli_core/tui.py
T
shuiyu1123 de15b54b19 release: v6.1.0 - TUI 体验全面升级 + 背景自定义
TUI 界面改进:
- 全面中文化: CommandPalette/HelpPanel/Footer/Chatbox 边框标题等所有用户可见英文汉化
- 辅助中心 (Ctrl+P/F9): 原"设置"重命名, 含 6 项可切换设置
- 独立帮助浮层 (? 键): 重写帮助页面逻辑, 全中文, 带滑入淡入动画
- 背景切换: 辅助中心 [5] 预设背景, [6] 自定义背景; 新增 /bg 命令支持图片提取主色调
- 换行修复: Ctrl+J 作为主推换行键 (Windows 终端不区分 Shift+Enter)
- 引擎切换修复: 解决 call_from_thread 在主线程崩溃的 RuntimeError

技术改进:
- 文本工具调用解析: 检测模型用 <tool_call> 标签返回的工具调用
- 网络工具优化: network_tools.py 和 ai_search.py 改进请求头/重试/编码
- 流式输出修复: append_chunk 的 refresh 改为 layout=True
- 取消响应修复: 引擎层 _cancelled 标志 + TUI 层 worker 身份跟踪

融合 v6.0.0 工作:
- 保留 terminal 模拟器/operit 桥接/深度思考/工作模式/记忆模式等 v6.0.0 全部功能
- 在此基础上叠加本会话的 TUI 改进
2026-06-29 13:00:19 +08:00

2541 lines
100 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 界面模块 — 基于 elia 开源项目架构重写
原项目: https://github.com/darrenburns/elia (Apache-2.0)
核心组件: Chatbox(Markdown渲染) + ResponseStatus(LoadingIndicator) + @work(thread=True)流式
"""
from .constants import TEXTUAL_AVAILABLE
if TEXTUAL_AVAILABLE:
import re
import os
from functools import partial
from dataclasses import dataclass
from textual.app import App, ComposeResult, SystemCommand
from textual.containers import Horizontal, Vertical, VerticalScroll, Container
from textual.widgets import Header, Static, Rule, TextArea, Footer, LoadingIndicator, Label
from textual.widgets.option_list import Option
from textual.reactive import var, reactive
from textual.binding import Binding
from textual import on, work, events
from textual.message import Message
from textual.widget import Widget
from textual.css.query import NoMatches
from textual.command import CommandPalette, CommandList
from textual.screen import Screen
from rich.console import RenderableType
from rich.markdown import Markdown
from rich.syntax import Syntax
from rich.text import Text
from rich.align import Align
from typing import Iterable
# ═══════════════════════════════════════════════════
# 主题色
# ═══════════════════════════════════════════════════
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 = "#f0f6fc"
TOOL = "#56d364"
CODE_BG = "#161b22"
GLOW = "#1f6feb"
THINKING = "#8b949e"
# ═══════════════════════════════════════════════════
# CSS(融合 elia 样式 + 项目主题色)
# ═══════════════════════════════════════════════════
_TUI_CSS = f"""
Screen {{
background: {_Theme.BG};
}}
#app-container {{
height: 1fr;
width: 100%;
layout: vertical;
}}
#chat-scroll {{
height: 1fr;
background: {_Theme.BG};
scrollbar-color: {_Theme.BORDER};
scrollbar-color-hover: {_Theme.TEXT_MUTED};
padding: 0 1;
}}
/* ── 输入区域 (elia PromptInput 风格) ── */
#input-area {{
height: auto;
min-height: 5;
max-height: 14;
padding: 0 1 1 1;
}}
#input-wrapper {{
height: auto;
min-height: 4;
max-height: 12;
background: {_Theme.BG_INPUT};
border: round {_Theme.BORDER};
padding: 0;
transition: border-color 300ms in_out_cubic;
}}
#input-wrapper:focus-within {{
border: round {_Theme.BORDER_FOCUS};
background: {_Theme.BG};
}}
#user-input {{
height: auto;
min-height: 3;
max-height: 10;
background: transparent;
color: {_Theme.TEXT};
padding: 0 1;
border: none;
}}
#user-input:focus {{
background: transparent;
}}
#user-input .text-area--cursor {{
color: {_Theme.ACCENT};
}}
#user-input .text-area--cursor-line {{
background: {_Theme.BG_LIGHT} 50%;
}}
#user-input .text-area--selection {{
background: {_Theme.GLOW} 40%;
}}
#input-hint {{
height: 1;
color: {_Theme.TEXT_DIM};
padding: 0 1;
opacity: 0;
transition: opacity 500ms in_out_cubic;
}}
#input-hint.visible {{
opacity: 1;
}}
/* ── 命令提示浮层 — 在输入框上方,display:none 时不占空间 ── */
#command-hint {{
layer: overlay;
display: none;
height: auto;
max-height: 10;
width: 1fr;
margin: 0 1 1 1;
padding: 0 1;
background: {_Theme.BG_LIGHT} 98%;
border: round {_Theme.ACCENT};
overflow-y: auto;
}}
.cmd-hint-cmd {{
color: {_Theme.ACCENT};
text-style: bold;
}}
.cmd-hint-desc {{
color: {_Theme.TEXT_DIM};
}}
/* ── 安全审查确认浮层 ── */
#confirm-overlay {{
dock: top;
layer: overlay;
display: none;
height: auto;
max-height: 16;
width: 60;
min-width: 40;
margin: 2 4;
padding: 1 2;
background: {_Theme.BG};
border: heavy {_Theme.ERROR};
overflow-y: auto;
}}
#confirm-overlay.visible {{
display: block;
}}
/* ── 辅助中心 ── 背景遮罩 + 居中卡片 */
#settings-backdrop {{
layer: overlay;
width: 100%;
height: 100%;
display: none;
background: {_Theme.BG} 70%;
offset: 0 0;
opacity: 0;
transition: opacity 220ms out_cubic;
}}
#settings-backdrop.visible {{
display: block;
opacity: 1;
}}
#settings-backdrop.hiding {{
opacity: 0;
transition: opacity 180ms in_cubic;
}}
#settings-overlay {{
layer: overlay;
dock: top;
display: none;
height: auto;
max-height: 28;
width: 76;
min-width: 56;
margin: 2 4;
padding: 1 2;
background: {_Theme.BG_LIGHT} 98%;
border: round {_Theme.ACCENT} 80%;
overflow-y: auto;
offset: 0 -12;
opacity: 0;
transition: offset 320ms out_cubic, opacity 280ms out_cubic;
}}
#settings-overlay.visible {{
display: block;
offset: 0 0;
opacity: 1;
}}
#settings-overlay.hiding {{
offset: 0 -12;
opacity: 0;
transition: offset 240ms in_cubic, opacity 200ms in_cubic;
}}
.settings-title {{
color: {_Theme.ACCENT};
text-style: bold;
text-align: center;
padding: 0 0 1 0;
}}
.settings-section {{
color: {_Theme.TEXT_MUTED};
text-style: bold;
padding: 1 0 0 0;
}}
.settings-line {{
color: {_Theme.TEXT};
padding: 0 1;
}}
.settings-hint {{
color: {_Theme.TEXT_DIM};
text-style: italic;
padding: 1 0 0 0;
}}
.settings-key {{
color: {_Theme.WARNING};
text-style: bold;
}}
.settings-val {{
color: {_Theme.SUCCESS};
text-style: bold;
}}
/* ── 帮助浮层 ── 背景遮罩 + 居中卡片 */
#help-backdrop {{
layer: overlay;
width: 100%;
height: 100%;
display: none;
background: {_Theme.BG} 70%;
offset: 0 0;
opacity: 0;
transition: opacity 220ms out_cubic;
}}
#help-backdrop.visible {{
display: block;
opacity: 1;
}}
#help-backdrop.hiding {{
opacity: 0;
transition: opacity 180ms in_cubic;
}}
#help-overlay {{
layer: overlay;
dock: top;
display: none;
height: auto;
max-height: 32;
width: 80;
min-width: 60;
margin: 1 2;
padding: 1 2;
background: {_Theme.BG_LIGHT} 98%;
border: round {_Theme.ACCENT} 80%;
overflow-y: auto;
offset: 0 -12;
opacity: 0;
transition: offset 320ms out_cubic, opacity 280ms out_cubic;
}}
#help-overlay.visible {{
display: block;
offset: 0 0;
opacity: 1;
}}
#help-overlay.hiding {{
offset: 0 -12;
opacity: 0;
transition: offset 240ms in_cubic, opacity 200ms in_cubic;
}}
/* ── 侧栏 ── 横向滑入滑出 + 淡入淡出 */
#sidebar {{
width: 32;
min-width: 32;
height: 1fr;
dock: right;
background: {_Theme.BG_LIGHT};
border-left: tall {_Theme.BORDER};
/* 用 opacity 控制可见性,offset 制造滑入滑出感 */
opacity: 0;
offset: 16 0;
display: none;
transition: offset 320ms out_cubic, opacity 280ms out_cubic;
}}
#sidebar.visible {{
display: block;
opacity: 1;
offset: 0 0;
}}
#sidebar.hiding {{
opacity: 0;
offset: 16 0;
transition: offset 240ms in_cubic, opacity 200ms in_cubic;
}}
.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};
}}
/* ── 可切换背景效果 — 通过 #chat-scroll 的 class 切换 ──
Textual CSS 不支持 gradient 函数,改用纯色色调切换
bg-grid: 深蓝灰
bg-stars: 深紫
bg-glow: 深蓝
默认(无 class): 原始深黑
*/
#chat-scroll.bg-grid {{
background: #11161e;
}}
#chat-scroll.bg-stars {{
background: #16121f;
}}
#chat-scroll.bg-glow {{
background: #0f1620;
}}
/* ── 状态栏 ── */
#status-bar {{
height: 1;
width: 100%;
dock: bottom;
background: {_Theme.BG_LIGHT};
color: {_Theme.TEXT_MUTED};
padding: 0 1;
}}
#status-bar.pulse {{
color: {_Theme.WARNING};
background: {_Theme.GLOW} 40%;
transition: color 150ms, background 150ms;
}}
/* ── Footer ── */
Footer {{
background: {_Theme.BG_LIGHT};
color: {_Theme.TEXT_MUTED};
border-top: tall {_Theme.BORDER};
}}
Footer > .key {{
background: {_Theme.BG};
color: {_Theme.ACCENT};
}}
/* ── 消息样式 ── */
.msg-system {{
color: {_Theme.ACCENT};
padding: 0 1;
}}
.msg-tool-ok {{
color: {_Theme.SUCCESS};
padding: 0 1;
}}
.msg-tool-err {{
color: {_Theme.ERROR};
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;
}}
/* ── ResponseStatus (elia 原版) ── */
ResponseStatus {{
dock: top;
align-horizontal: right;
display: none;
layer: overlay;
height: 2;
width: auto;
margin-top: 1;
margin-right: 2;
}}
ResponseStatus Label {{
width: auto;
color: {_Theme.WARNING};
}}
ResponseStatus LoadingIndicator {{
width: auto;
color: {_Theme.ACCENT};
height: 1;
margin-top: 1;
dock: right;
}}
ResponseStatus.-awaiting-response LoadingIndicator {{
color: {_Theme.ACCENT};
}}
ResponseStatus.-agent-responding LoadingIndicator {{
color: {_Theme.WARNING};
}}
/* ── Chatbox (elia 原版样式) ── */
Chatbox {{
height: auto;
width: 1fr;
max-width: 1fr;
margin: 0 1;
padding: 0 2;
}}
Chatbox.assistant-message {{
border: round {_Theme.ACCENT};
}}
Chatbox.assistant-message.response-in-progress {{
background: {_Theme.ACCENT} 3%;
}}
Chatbox.human-message {{
border: round {_Theme.BORDER};
}}
/* ── ThinkingBox:深度思考专用 widget(独立显示,灰色斜体) ── */
ThinkingBox {{
height: auto;
width: 1fr;
max-width: 1fr;
margin: 0 1;
padding: 0 2;
border: round {_Theme.TEXT_DIM};
background: {_Theme.BG_LIGHT};
color: {_Theme.TEXT_DIM};
}}
ThinkingBox > .thinking-title {{
color: {_Theme.TEXT_MUTED};
text-style: bold italic;
padding: 0 0 1 0;
}}
ThinkingBox > .thinking-body {{
color: {_Theme.TEXT_DIM};
text-style: italic;
}}
/* ── 引擎切换浮层 ── */
EngineSwitchBanner {{
layer: overlay;
dock: top;
height: 7;
width: auto;
min-width: 44;
max-width: 72;
content-align: center middle;
text-align: center;
background: {_Theme.BG_LIGHT} 96%;
color: {_Theme.ACCENT};
text-style: bold;
border: round {_Theme.ACCENT} 80%;
border-title-color: {_Theme.ACCENT};
border-title-background: {_Theme.BG_LIGHT};
border-title-style: bold;
padding: 1 6;
margin: 2 0;
offset: 0 -8;
opacity: 0;
transition: offset 380ms out_cubic, opacity 380ms out_cubic;
}}
EngineSwitchBanner.show {{
offset: 0 0;
opacity: 1;
}}
EngineSwitchBanner.hide {{
offset: 0 -8;
opacity: 0;
transition: offset 280ms in_cubic, opacity 280ms in_cubic;
}}
"""
# ═══════════════════════════════════════════════════
# 桥接 TUI ↔ AICLI
# ═══════════════════════════════════════════════════
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
# ═══════════════════════════════════════════════════
# Chatbox — 直接复制自 elia,适配为无 litellm 依赖
# 原项目: https://github.com/darrenburns/elia/blob/main/elia_chat/widgets/chatbox.py
# ═══════════════════════════════════════════════════
class Chatbox(Widget, can_focus=True):
"""支持 Markdown 渲染的聊天消息组件 — 流式追加
render() 返回 Rich Markdown 对象,实现真正的代码高亮和格式化。
append_chunk() 用于流式追加文本,只刷新布局不重建 widget。
"""
def __init__(
self,
role: str = "assistant",
content: str = "",
name: str | None = None,
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
) -> None:
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
self.role = role
self.content = content
def on_mount(self) -> None:
if self.role == "assistant":
self.add_class("assistant-message")
self.border_title = "小狸"
else:
self.add_class("human-message")
self.border_title = "你"
def render(self) -> RenderableType:
if not self.content:
return ""
if self.role == "user":
return Syntax(
self.content,
lexer="markdown",
word_wrap=True,
background_color=_Theme.BG,
)
return Markdown(self.content, code_theme="monokai")
def append_chunk(self, chunk: str) -> None:
"""流式追加文本 — 来自 elia 的原版实现"""
self.content += chunk
self.refresh(layout=True)
# ═══════════════════════════════════════════════════
# ThinkingBox — 深度思考专用 widget(独立于 AI 回答框)
# ═══════════════════════════════════════════════════
class ThinkingBox(Widget, can_focus=False):
"""深度思考专用 widget — 灰色斜体,独立显示在 AI 回答框之前
用于显示深度思考模型(如 DeepSeek-R1、QwQ)的推理过程,
与最终回答在视觉上分开。
"""
def __init__(
self,
content: str = "",
name: str | None = None,
id: str | None = None,
classes: str | None = None,
) -> None:
super().__init__(name=name, id=id, classes=classes)
self.content = content
def render(self) -> RenderableType:
if not self.content:
return ""
return Markdown(
f"💭 **深度思考**\n\n{self.content}",
code_theme="monokai",
)
def append_chunk(self, chunk: str) -> None:
"""流式追加思考内容"""
self.content += chunk
self.refresh(layout=True)
# ═══════════════════════════════════════════════════
# ResponseStatus — 直接复制自 elia
# 原项目: https://github.com/darrenburns/elia/blob/main/elia_chat/widgets/agent_is_typing.py
# ═══════════════════════════════════════════════════
class ResponseStatus(Vertical):
"""响应状态指示器 — 使用 Textual 内置 LoadingIndicator
两种状态:
- awaiting-response: 等待 AI 响应(思考中)
- agent-responding: AI 正在输出(流式中)
"""
message: reactive[str] = reactive(" 思考中", recompose=False)
def compose(self) -> ComposeResult:
yield Label(f" {self.message}", id="rs-label")
yield LoadingIndicator()
def watch_message(self, new_value: str) -> None:
try:
self.query_one("#rs-label").update(f" {new_value}")
except NoMatches:
pass
def set_awaiting_response(self) -> None:
self.message = " 思考中"
self.add_class("-awaiting-response")
self.remove_class("-agent-responding")
self.display = True
def set_agent_responding(self) -> None:
self.message = " 输出中"
self.add_class("-agent-responding")
self.remove_class("-awaiting-response")
def hide(self) -> None:
self.display = False
# ═══════════════════════════════════════════════════
# 辅助组件
# ═══════════════════════════════════════════════════
class EngineSwitchBanner(Static):
"""引擎切换浮层 — 居中悬浮卡片,多行带色彩层次"""
DEFAULT_CSS = ""
def __init__(self, old_engine: str, new_engine: str, **kwargs):
from rich.text import Text
# 三行布局:标题行 + 切换行 + 状态行,用 Rich Text 上色
title = Text("⚡ 引擎切换", style=f"bold {self._acc()}", justify="center")
arrow = Text(" → ", style="dim")
old_t = Text(old_engine, style="dim strike")
new_t = Text(new_engine, style=f"bold {self._acc()}")
switch_line = Text.assemble(old_t, arrow, new_t)
hint = Text("切换中…", style="dim italic", justify="center")
content = Text.assemble(title, "\n", switch_line, "\n", hint)
super().__init__(content, **kwargs)
@staticmethod
def _acc():
return "#58a6ff"
class SendTextArea(TextArea):
"""回车发送消息,Ctrl+J 换行的输入框"""
async def _on_key(self, event):
# 命令提示导航:上下键 / Tab(提示可见时)
hint_visible = False
try:
hint = self.app.query_one("#command-hint")
hint_visible = hint.display and bool(hint._items)
if hint_visible:
if event.key == "up":
event.prevent_default()
hint.move_selection(-1)
return
elif event.key == "down":
event.prevent_default()
hint.move_selection(1)
return
elif event.key == "tab":
event.prevent_default()
cmd = hint.get_selected_command()
if cmd:
self.text = cmd + " "
self.cursor_location = (0, len(self.text))
return
elif event.key == "escape":
event.prevent_default()
self.app._hide_command_hint()
return
except Exception:
pass
# 输入历史导航:上下键(提示不可见时)
if not hint_visible and event.key in ("up", "down"):
history = getattr(self.app, '_input_history', [])
if history:
event.prevent_default()
idx = getattr(self.app, '_history_idx', -1)
if event.key == "up":
if idx == -1:
idx = len(history) - 1
else:
idx = max(0, idx - 1)
self.text = history[idx]
else: # down
if idx == -1:
return # 已在最新,无操作
idx += 1
if idx >= len(history):
idx = -1
self.text = ""
else:
self.text = history[idx]
self.app._history_idx = idx
self.cursor_location = (0, len(self.text))
return
# 换行:Shift+回车 / Ctrl+J / Alt+回车
# (多数 Windows 终端不区分 Shift+Enter 与 Enter,故提供 Ctrl+J 作为可靠替代)
if event.key in ("shift+enter", "alt+enter", "ctrl+j"):
event.prevent_default()
event.stop()
self.insert("\n")
self.app.call_after_refresh(self.app._update_command_hint, self.text)
self.app._history_idx = -1
return
# 回车(无修饰键):发送消息
if event.key in ("enter", "\r", "\n"):
event.prevent_default()
try:
self.app.action_send_message()
except Exception:
pass
return
# 其他按键交由父类处理后,刷新命令提示
await super()._on_key(event)
self.app.call_after_refresh(self.app._update_command_hint, self.text)
# 任意编辑键重置历史浏览位置
self.app._history_idx = -1
class CommandHint(Widget, can_focus=False):
"""命令提示浮层 — 输入 / 时显示可用命令"""
BUILTIN_COMMANDS = [
("help", "帮助信息"),
("quit", "退出程序"),
("cli", "切换到 CLI 模式"),
("model", "切换 AI 引擎"),
("engine", "切换 AI 引擎"),
("engines", "列出可用引擎"),
("tools", "列出已加载工具"),
("status", "系统状态"),
("clear", "清屏"),
("safe", "切换安全模式"),
("diff", "切换文件修改展示模式"),
("chat", "对话历史管理 (save/list/open)"),
("settings","打开辅助中心"),
("profile", "引擎配置档案管理 (save/use/del)"),
("about", "关于"),
]
def __init__(self):
super().__init__(id="command-hint")
self._items = []
self._selected = 0
def update_hints(self, text: str):
"""根据输入文本更新提示列表"""
if not text.startswith('/'):
self._items = []
self.display = False
self.refresh()
return
query = text[1:].split(maxsplit=1)[0].lower() if len(text) > 1 else ""
items = []
# 内置命令
for cmd, desc in self.BUILTIN_COMMANDS:
if not query or cmd.startswith(query):
items.append((cmd, desc))
# 插件命令
cli = self.app.cli if hasattr(self.app, 'cli') else None
if cli and hasattr(cli, 'liugin_commands'):
for cmd_name in cli.liugin_commands:
if not query or cmd_name.startswith(query):
items.append((cmd_name, "插件命令"))
# 去重
seen = set()
unique = []
for cmd, desc in items:
if cmd not in seen:
seen.add(cmd)
unique.append((cmd, desc))
self._items = unique[:10]
self._selected = 0
self.display = len(self._items) > 0
self.refresh()
def move_selection(self, delta: int):
if not self._items:
return
self._selected = (self._selected + delta) % len(self._items)
self.refresh()
def get_selected_command(self) -> str:
if not self._items:
return ""
return "/" + self._items[self._selected][0]
def render(self) -> RenderableType:
if not self._items:
return ""
lines = []
for i, (cmd, desc) in enumerate(self._items):
marker = "▸ " if i == self._selected else " "
lines.append(f"{marker}[cmd-hint-cmd]/{cmd}[/] [cmd-hint-desc]{desc}[/]")
return "\n".join(lines)
# ═══════════════════════════════════════════════════
# 中文化命令面板 (CommandPalette)
# ═══════════════════════════════════════════════════
class _CNCommandPalette(CommandPalette):
"""中文化命令面板 — 覆盖占位符、绑定描述与"未找到匹配项"文本"""
# 覆盖绑定描述为中文(Footer 与帮助面板会显示)
BINDINGS = [
Binding("ctrl+end, shift+end", "command_list('last')", "跳到底部", show=False),
Binding("ctrl+home, shift+home", "command_list('first')", "跳到顶部", show=False),
Binding("down", "cursor_down", "下一条命令", show=False),
Binding("escape", "escape", "退出命令面板"),
Binding("pagedown", "command_list('page_down')", "下一页", show=False),
Binding("pageup", "command_list('page_up')", "上一页", show=False),
Binding("up", "command_list('cursor_up')", "上一条命令", show=False),
]
def __init__(self, **kwargs) -> None:
kwargs.setdefault("placeholder", "搜索命令…")
super().__init__(**kwargs)
def _start_no_matches_countdown(self, search_value: str) -> None:
"""覆盖父类方法,将 "No matches found" 替换为中文"""
self._stop_no_matches_countdown()
def _show_no_matches() -> None:
if search_value:
command_list = self.query_one(CommandList)
command_list.add_option(
Option(
Align.center(Text("未找到匹配项", style="not bold")),
disabled=True,
id=self._NO_MATCHES,
)
)
self._list_visible = True
else:
self._list_visible = False
self._no_matches_timer = self.set_timer(
self._NO_MATCHES_COUNTDOWN,
_show_no_matches,
)
# ═══════════════════════════════════════════════════
# TUI 主应用
# ═══════════════════════════════════════════════════
class XiaoliTUI(App):
"""小狸 TUI — 基于 elia 架构"""
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("ctrl+enter", "send_message", "发送", show=False),
Binding("f1", "toggle_sidebar", "侧栏", show=True),
Binding("f2", "switch_engine", "引擎", show=True),
Binding("ctrl+p", "toggle_settings", "辅助中心", show=True),
Binding("f9", "toggle_settings", "辅助中心", show=False),
Binding("question_mark", "toggle_help", "帮助", show=True),
Binding("escape", "cancel", "取消", show=True),
]
sidebar_visible = var(False)
is_generating = var(False)
# 背景效果:0=墨黑 1=深蓝灰 2=深紫 3=深蓝
_BG_CLASSES = ["", "bg-grid", "bg-stars", "bg-glow"]
_BG_LABELS = ["墨黑", "深蓝灰", "深紫", "深蓝"]
def __init__(self, cli):
super().__init__()
self.cli = cli
self.bridge = _TUIBridge(cli)
self._thinking_widget = None
self._cancel_requested = False # 取消标志
self._current_worker = None # 当前 worker 引用
self._confirm_event = None # 安全审查确认 Event
self._confirm_result = False # 安全审查确认结果
self._bg_idx = 0 # 预设背景效果索引
self._bg_image_path = "" # 自定义背景图片路径
self._bg_custom_color = "" # 从图片提取的背景色(hex)
self._input_history = [] # 输入历史(最新在末尾)
self._history_idx = -1 # -1 表示当前未在浏览历史
# ── 命令面板中文化 ──
def action_command_palette(self) -> None:
"""覆盖默认命令面板,使用中文版"""
if self.use_command_palette and not CommandPalette.is_open(self):
self.push_screen(_CNCommandPalette(id="--command-palette"))
def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
"""覆盖系统命令为中文"""
if not self.ansi_color:
yield SystemCommand(
"主题",
"切换当前主题",
self.action_change_theme,
)
yield SystemCommand(
"退出",
"退出应用程序",
self.action_quit,
)
if screen.query("HelpPanel"):
yield SystemCommand(
"快捷键",
"隐藏快捷键帮助面板",
self.action_hide_help_panel,
)
else:
yield SystemCommand(
"快捷键",
"显示快捷键与命令帮助",
self.action_show_help_panel,
)
if screen.maximized is not None:
yield SystemCommand(
"最小化",
"最小化当前控件并恢复原始尺寸",
screen.action_minimize,
)
elif screen.focused is not None and screen.focused.allow_maximize:
yield SystemCommand(
"最大化",
"最大化当前控件",
screen.action_maximize,
)
yield SystemCommand(
"截图",
"保存当前屏幕的 SVG 截图",
lambda: self.set_timer(0.1, self.deliver_screenshot),
)
def search_themes(self) -> None:
"""覆盖主题搜索面板,使用中文占位符"""
from textual.theme import ThemeProvider
self.push_screen(
_CNCommandPalette(
providers=[ThemeProvider],
placeholder="搜索主题…",
),
)
def action_show_help_panel(self) -> None:
"""打开帮助浮层(覆盖 Textual 默认侧边栏 HelpPanel)"""
self._show_help_panel()
def action_hide_help_panel(self) -> None:
"""关闭帮助浮层"""
self._hide_help_panel()
# ── 布局 ──
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
with Vertical(id="app-container"):
yield ResponseStatus(id="response-status")
yield VerticalScroll(id="chat-scroll")
yield Static("", id="confirm-overlay")
yield Static("", id="settings-backdrop")
yield Static("", id="settings-overlay")
yield Static("", id="help-backdrop")
yield Static("", id="help-overlay")
with Vertical(id="input-area"):
yield CommandHint()
with Container(id="input-wrapper"):
yield SendTextArea(
placeholder=" 输入消息... (回车发送, Ctrl+J 换行, / 命令提示)",
id="user-input",
soft_wrap=True,
tab_behavior="indent",
)
yield Static(
" 回车发送 Ctrl+J 换行 Esc 取消 F2 引擎 F1 侧栏 Ctrl+P 辅助中心 Ctrl+C 退出",
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(" 就绪", id="status-bar")
yield Footer()
def on_mount(self):
self._render_welcome()
self.query_one("#user-input").focus()
def _show_hint():
try:
self.query_one("#input-hint").add_class("visible")
except Exception:
pass
self.set_timer(0.5, _show_hint)
# 应用持久化的背景效果
try:
from .config import get_system_config
saved = get_system_config('tui_background', 0)
self._bg_idx = saved if 0 <= saved < len(self._BG_CLASSES) else 0
# 自定义图片背景
self._bg_image_path = get_system_config('tui_bg_image', '') or ''
if self._bg_image_path:
self._extract_color_from_image()
except Exception:
self._bg_idx = 0
self._apply_background()
self._update_status("就绪")
def on_key(self, event):
"""全局按键处理:辅助中心 / 帮助浮层 / 安全审查确认浮层可见时拦截按键"""
try:
# 辅助中心可见时,拦截所有按键
settings = self.query_one("#settings-overlay")
if settings.display and settings.has_class("visible"):
key = event.key.lower() if hasattr(event, 'key') else ""
if key == "escape":
event.prevent_default()
event.stop()
self._hide_settings()
elif key in ("1", "2", "3", "4", "5", "6"):
event.prevent_default()
event.stop()
self._cycle_setting(int(key))
else:
# 其他按键也拦截,避免触发输入框等
event.prevent_default()
event.stop()
return
except Exception:
pass
try:
# 帮助浮层可见时,仅 Esc 关闭,其余按键拦截
help_overlay = self.query_one("#help-overlay")
if help_overlay.display and help_overlay.has_class("visible"):
key = event.key.lower() if hasattr(event, 'key') else ""
if key == "escape":
event.prevent_default()
event.stop()
self._hide_help_panel()
else:
event.prevent_default()
event.stop()
return
except Exception:
pass
try:
overlay = self.query_one("#confirm-overlay")
if overlay.display and self._confirm_event:
key = event.key.lower() if hasattr(event, 'key') else ""
if key == 'y':
event.prevent_default()
event.stop()
self._confirm_result = True
self._confirm_event.set()
self._hide_confirm()
elif key in ('n', 'escape'):
event.prevent_default()
event.stop()
self._confirm_result = False
self._confirm_event.set()
self._hide_confirm()
except Exception:
pass
# ── 欢迎界面 ──
def _render_welcome(self):
scroll = self.query_one("#chat-scroll")
engine = self.bridge.current_engine()
tools = len(self.bridge.tools())
lines = [
("", "msg-dim"),
(" -----------------------------------------------", "msg-welcome"),
(" 小狸 Pro-CLI v6.1.0", "msg-welcome"),
(" 智能编程助手 · TUI 模式", "msg-welcome"),
(" -----------------------------------------------", "msg-welcome"),
("", "msg-dim"),
(f" 引擎: {engine} | 工具: {tools} 个", "msg-system"),
("", "msg-dim"),
(" /help 查看命令 /model 切换引擎 /clear 清屏", "msg-dim"),
("", "msg-dim"),
]
for text, cls in lines:
scroll.mount(Static(text, classes=cls))
scroll.scroll_end(animate=False)
# ── 消息操作 ──
def _append(self, widget):
scroll = self.query_one("#chat-scroll")
scroll.mount(widget)
scroll.scroll_end(animate=False)
def _smart_scroll_end(self):
"""线程安全的智能滚动 — 在主线程中执行"""
try:
scroll = self.query_one("#chat-scroll")
if scroll.scroll_y >= scroll.max_scroll_y - 5:
scroll.scroll_end(animate=False)
except Exception:
pass
def _mount_first_chunk(self, widget):
"""挂载首个流式 chunk 的 widget(主线程中执行,保证原子性)"""
self._remove_thinking()
self._append(widget)
try:
self.query_one("#response-status").set_agent_responding()
except Exception:
pass
self._update_status(" 输出中...")
def _mount_thinking_widget(self, widget):
"""挂载 ThinkingBox — 在聊天区追加(主线程中执行)"""
try:
scroll = self.query_one("#chat-scroll")
scroll.mount(widget)
scroll.scroll_end(animate=False)
except Exception:
pass
def _remove_thinking_widget(self, widget):
"""移除空的 ThinkingBox"""
try:
if widget and widget.is_mounted:
widget.remove()
except Exception:
pass
def _add(self, text, cls="msg-dim"):
self._append(Static(text, classes=cls))
def _user_msg(self, text):
"""用户消息 — 使用 Chatbox (elia 模式),CSS + on_mount 自动处理滑入动画"""
widget = Chatbox(role="user", content=text)
self._append(widget)
def _ai_msg(self, text):
"""AI 消息 — 使用 Chatbox 渲染 Markdown"""
self._append(Chatbox(role="assistant", content=text))
def _system(self, text):
self._add(f" {text}", "msg-system")
def _error(self, text):
self._add(f" [错误] {text}", "msg-error")
# ── 思考指示器 (elia ResponseStatus 模式) ──
def _show_thinking(self):
"""显示思考状态 — 仅右上角 ResponseStatus 浮层
不再往 chat-scroll 里 mount LoadingIndicator,
因为无 CSS 约束的 LoadingIndicator 会撑满整个聊天区(视觉上全屏)。
右上角 ResponseStatus(2 行高小浮层)+ 状态栏文字已足够。
"""
if self._thinking_widget:
try:
self._thinking_widget.remove()
except Exception:
pass
self._thinking_widget = None
try:
rs = self.query_one("#response-status")
rs.set_awaiting_response()
except Exception:
pass
def _remove_thinking(self):
"""移除思考状态"""
try:
self.query_one("#response-status").hide()
except Exception:
pass
if self._thinking_widget:
try:
self._thinking_widget.remove()
except Exception:
pass
self._thinking_widget = None
def _show_typing_indicator(self):
"""兼容旧接口"""
pass
def _remove_typing_indicator(self):
"""兼容旧接口"""
pass
# ── 侧栏 ──
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):
try:
self.query_one("#status-bar").update(f" {text}")
except Exception:
pass
# ── 输入动作 ──
def _update_command_hint(self, text: str):
"""更新命令提示浮层(输入 / 时触发)"""
try:
hint = self.query_one("#command-hint")
hint.update_hints(text)
except Exception:
pass
def _hide_command_hint(self):
"""隐藏命令提示浮层"""
try:
hint = self.query_one("#command-hint")
hint.display = False
hint._items = []
hint.refresh()
except Exception:
pass
def _show_confirm(self, risk_info: str):
"""显示工具调用安全审查确认浮层"""
try:
overlay = self.query_one("#confirm-overlay")
overlay.update(risk_info)
overlay.add_class("visible")
overlay.display = True
except Exception:
pass
def _hide_confirm(self):
"""隐藏确认浮层"""
try:
overlay = self.query_one("#confirm-overlay")
overlay.remove_class("visible")
overlay.display = False
overlay.update("")
except Exception:
pass
def action_send_message(self):
"""回车发送消息"""
if self.is_generating:
return
text_area = self.query_one("#user-input")
text = text_area.text.strip()
if not text:
return
# 记录到输入历史(去重:与最后一条相同则不记)
if not self._input_history or self._input_history[-1] != text:
self._input_history.append(text)
if len(self._input_history) > 100:
self._input_history.pop(0)
self._history_idx = -1
text_area.clear()
self._hide_command_hint()
if text.startswith('/'):
self._handle_command(text)
return
self._user_msg(text)
self.is_generating = True
self._cancel_requested = False # 重置取消标志
self._cancel_generation = getattr(self, '_cancel_generation', 0) + 1 # 新 worker 代次
self._current_worker = self.run_worker(partial(self._generate, text), thread=True, 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_panel(),
'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 v6.1.0 - 智能编程助手"),
'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),
'safe': lambda: self._handle_safe(args),
'diff': lambda: self._handle_diff_mode(args),
'chat': lambda: self._handle_chat(args),
'bg': lambda: self._handle_background(args),
'settings': lambda: self._show_settings(),
'profile': lambda: self._handle_profile(args),
}
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 _handle_safe(self, args):
"""切换安全模式:off=无限制(跳过审查) / on=普通 / manual=人工确认"""
from xcli_core.safety import get_safety, MODE_UNRESTRICTED, MODE_NORMAL, MODE_MANUAL
safety = get_safety()
arg = args.strip().lower() if args else ""
if arg in ('off', 'unrestricted', '无限制'):
safety.set_mode(MODE_UNRESTRICTED)
self._system(" 安全模式: 无限制 (跳过工具审查)")
elif arg in ('on', 'normal', '普通'):
safety.set_mode(MODE_NORMAL)
self._system(" 安全模式: 普通 (AI 识别风险)")
elif arg in ('manual', '人工', 'all'):
safety.set_mode(MODE_MANUAL)
self._system(" 安全模式: 人工确认 (所有工具调用需确认)")
else:
mode_name = safety.cycle_mode()
self._system(f" 安全模式: {mode_name}")
def _handle_diff_mode(self, args):
"""切换文件修改展示模式:popup=弹窗 / inline=终端内 / off=关闭"""
arg = args.strip().lower() if args else ""
# 找到 code_editor 插件
editor = None
if hasattr(self.cli, 'liugin_manager'):
for tool in self.cli.liugin_manager.tools:
if tool.get('name') == 'code_editor':
editor = tool.get('instance')
break
if not editor:
self._error("未找到代码编辑器插件")
return
if arg in ('popup', '弹窗'):
editor.diff_popup_enabled = True
editor.diff_popup_mode = True
self._system(" 文件修改展示: 弹窗模式(新终端窗口)")
elif arg in ('inline', '内联', '终端', 'ssh'):
editor.diff_popup_enabled = True
editor.diff_popup_mode = False
self._system(" 文件修改展示: 终端内显示(SSH 兼容)")
elif arg in ('off', '关', '关闭'):
editor.diff_popup_enabled = False
self._system(" 文件修改展示: 已关闭")
else:
# 无参数:循环切换
if not editor.diff_popup_enabled:
editor.diff_popup_enabled = True
editor.diff_popup_mode = False
self._system(" 文件修改展示: 终端内显示")
elif editor.diff_popup_mode is False:
editor.diff_popup_mode = True
self._system(" 文件修改展示: 弹窗模式")
elif editor.diff_popup_mode is True:
editor.diff_popup_enabled = False
self._system(" 文件修改展示: 已关闭")
else:
editor.diff_popup_mode = False
self._system(" 文件修改展示: 终端内显示")
def _handle_chat(self, args):
"""对话历史管理: save <名称> / list / open <名称|序号>"""
if not getattr(self.cli, 'memory_manager', None):
self._error("记忆系统未初始化")
return
parts = args.strip().split(maxsplit=1)
sub = parts[0].lower() if parts else ""
arg = parts[1].strip() if len(parts) > 1 else ""
if sub == 'save':
if not arg:
self._error("请提供名称。用法: /chat save <名称>")
return
self.cli.memory_manager.auto_save_chat(
self.cli.shared_conversation_history,
label=arg
)
self._system(f" 聊天记录已保存: {arg}")
elif sub == 'list':
chats = self.cli.memory_manager.list_chats(limit=30)
if not chats:
self._system(" 没有已保存的聊天记录")
return
lines = [" 已保存的聊天记录:"]
for i, c in enumerate(chats, 1):
lines.append(f" {i}. {c['name']} ({c['time']}, {c['size_kb']}KB)")
self._system("\n".join(lines))
elif sub == 'open':
if not arg:
self._error("请提供名称或序号。用法: /chat open <名称|序号>")
return
name = arg
# 支持序号加载
if name.isdigit():
chats = self.cli.memory_manager.list_chats(limit=50)
idx = int(name) - 1
if 0 <= idx < len(chats):
name = chats[idx]['name'].replace('.json', '')
else:
self._error(f"序号 {arg} 超出范围")
return
conversation = self.cli.memory_manager.load_chat(name)
if conversation is None and not name.endswith('.json'):
conversation = self.cli.memory_manager.load_chat(name + '.json')
if conversation is None:
self._error(f"聊天记录 '{name}' 不存在")
return
# 替换历史并同步给引擎
self.cli.shared_conversation_history = conversation
self.cli._set_shared_conversation_history()
# 重绘聊天区
scroll = self.query_one("#chat-scroll")
scroll.remove_children()
for msg in conversation:
role = msg.get('role', '')
content = msg.get('content', '')
if not content:
continue
if role == 'user':
self._user_msg(content)
elif role == 'assistant':
self._ai_msg(content)
# system/tool 消息跳过,避免噪音
self._system(f" 已加载聊天记录: {name}({len(conversation)} 条)")
else:
self._error("用法: /chat save <名称> | /chat list | /chat open <名称|序号>")
# ── 辅助中心浮层 / 引擎配置档案 ──
def _mask_key(self, key):
"""脱敏 API Key 用于显示"""
if not key:
return "(空)"
if len(key) <= 8:
return key[:2] + "***"
return key[:4] + "***" + key[-4:]
def _get_setting_state(self):
"""收集辅助中心所有选项的状态,返回 dict 供显示和切换使用"""
from .safety import get_safety, MODE_UNRESTRICTED, MODE_NORMAL, MODE_MANUAL
from .notification import get_notification_manager
from .config import get_system_config
# 安全模式
safety = get_safety()
safety_modes = [(MODE_UNRESTRICTED, "无限制"), (MODE_NORMAL, "普通"), (MODE_MANUAL, "人工确认")]
safety_idx = safety.mode if 0 <= safety.mode <= 2 else 1
safety_label = safety_modes[safety_idx][1]
# 通知开关
nm = get_notification_manager()
notify_label = "开启" if nm.enabled else "关闭"
# diff 模式
diff_editor = None
if hasattr(self.cli, 'liugin_manager'):
for tool in self.cli.liugin_manager.tools:
if tool.get('name') == 'code_editor':
diff_editor = tool.get('instance')
break
diff_modes = [("off", "关闭"), ("inline", "终端内"), ("popup", "弹窗")]
diff_idx = 0
if diff_editor:
if not diff_editor.diff_popup_enabled:
diff_idx = 0
elif diff_editor.diff_popup_mode is False:
diff_idx = 1
else:
diff_idx = 2
diff_label = diff_modes[diff_idx][1]
diff_available = diff_editor is not None
# max_history(从 config 读取真实值,未配置则用默认 50)
max_history_val = get_system_config('max_history', 50)
# 历史档位
history_options = [20, 50, 100, 999999]
try:
history_idx = history_options.index(max_history_val) if max_history_val in history_options else 1
except (ValueError, TypeError):
history_idx = 1
history_label = "无限制" if max_history_val >= 999999 else str(max_history_val)
return {
'safety': {'idx': safety_idx, 'label': safety_label, 'options': safety_modes, 'available': True},
'notify': {'idx': 0 if nm.enabled else 1, 'label': notify_label,
'options': [(True, "开启"), (False, "关闭")], 'available': True},
'diff': {'idx': diff_idx, 'label': diff_label, 'options': diff_modes, 'available': diff_available},
'history': {'idx': history_idx, 'label': history_label,
'options': [(v, "无限制" if v >= 999999 else str(v)) for v in history_options],
'available': True, 'values': history_options},
'background': {'idx': self._bg_idx, 'label': self._BG_LABELS[self._bg_idx],
'options': list(enumerate(self._BG_LABELS)), 'available': True},
'bg_custom': {
'active': bool(self._bg_custom_color),
'label': (os.path.basename(self._bg_image_path) if self._bg_image_path else "无"),
'color': self._bg_custom_color,
},
}
def _build_settings_text(self):
"""构建辅助中心浮层的显示内容 — 全部功能 + 基础设置"""
from rich.text import Text
from .config import get_profiles, get_system_config
engine = self.bridge.current_engine()
engine_name = getattr(engine, 'name', 'unknown')
base_url = getattr(engine, 'base_url', '')
api_key = getattr(engine, 'api_key', '')
model = getattr(engine, 'model', '')
state = self._get_setting_state()
t = Text()
t.append(Text("⚙ 辅助中心\n", style=f"bold {_Theme.ACCENT}", justify="center"))
t.append(Text("─" * 56 + "\n", style="dim"))
# ── 基础设置 ──
t.append(Text("【基础设置】\n", style=f"bold {_Theme.TEXT_MUTED}"))
# 1. 安全模式
s = state['safety']
t.append(Text(" ", style=""))
t.append(Text("[1]", style=f"bold {_Theme.WARNING}"))
t.append(Text(" 安全模式 ", style=_Theme.TEXT))
t.append(Text(s['label'], style=f"bold {_Theme.SUCCESS}"))
if s['available']:
t.append(Text(" (无限制/普通/人工)", style="dim"))
t.append(Text("\n", style=""))
# 2. 通知开关
n = state['notify']
t.append(Text(" ", style=""))
t.append(Text("[2]", style=f"bold {_Theme.WARNING}"))
t.append(Text(" 任务完成通知 ", style=_Theme.TEXT))
t.append(Text(n['label'], style=f"bold {_Theme.SUCCESS}"))
t.append(Text(" (开启/关闭)", style="dim"))
t.append(Text("\n", style=""))
# 3. 对话历史条数
h = state['history']
t.append(Text(" ", style=""))
t.append(Text("[3]", style=f"bold {_Theme.WARNING}"))
t.append(Text(" 对话历史条数 ", style=_Theme.TEXT))
t.append(Text(h['label'], style=f"bold {_Theme.SUCCESS}"))
t.append(Text(" (20/50/100/无限)", style="dim"))
t.append(Text("\n", style=""))
# 4. diff 展示
d = state['diff']
t.append(Text(" ", style=""))
t.append(Text("[4]", style=f"bold {_Theme.WARNING}"))
t.append(Text(" 文件修改展示 ", style=_Theme.TEXT))
if d['available']:
t.append(Text(d['label'], style=f"bold {_Theme.SUCCESS}"))
t.append(Text(" (关闭/终端内/弹窗)", style="dim"))
else:
t.append(Text("(不可用)", style="dim"))
t.append(Text("\n", style=""))
# 5. 预设背景
bg = state['background']
t.append(Text(" ", style=""))
t.append(Text("[5]", style=f"bold {_Theme.WARNING}"))
t.append(Text(" 预设背景 ", style=_Theme.TEXT))
t.append(Text(bg['label'], style=f"bold {_Theme.SUCCESS}"))
t.append(Text(" (墨黑/深蓝灰/深紫/深蓝)", style="dim"))
t.append(Text("\n", style=""))
# 6. 自定义背景
bc = state['bg_custom']
t.append(Text(" ", style=""))
t.append(Text("[6]", style=f"bold {_Theme.WARNING}"))
t.append(Text(" 自定义背景 ", style=_Theme.TEXT))
if bc['active']:
t.append(Text(bc['label'], style=f"bold {_Theme.SUCCESS}"))
t.append(Text(f" ({bc['color']})", style="dim"))
else:
t.append(Text("无", style="dim"))
t.append(Text(" 用 /bg 命令设置", style="dim"))
t.append(Text("\n", style=""))
t.append(Text("\n【引擎配置】\n", style=f"bold {_Theme.TEXT_MUTED}"))
t.append(Text(f" 当前引擎: ", style="dim"))
t.append(Text(f"{engine_name}\n", style=f"bold {_Theme.ACCENT}"))
t.append(Text(f" 地址: ", style="dim"))
t.append(Text(f"{base_url or '(未设置)'}\n", style=_Theme.TEXT))
t.append(Text(f" 模型: ", style="dim"))
t.append(Text(f"{model or '(未设置)'}\n", style=_Theme.TEXT))
t.append(Text(f" 密钥: ", style="dim"))
t.append(Text(f"{self._mask_key(api_key)}\n", style=_Theme.TEXT))
# 已存档案
profiles = get_profiles(engine_name)
if profiles:
t.append(Text("\n 配置档案 (用 /profile use <名称|序号> 切换):\n", style=f"bold {_Theme.TEXT_MUTED}"))
for i, p in enumerate(profiles, 1):
mark = " ←" if (p.get('base_url') == base_url and p.get('model') == model) else ""
t.append(Text(f" {i}. {p.get('name', '未命名')}{mark}", style=_Theme.TEXT))
t.append(Text(f" ({p.get('model', '')})\n", style="dim"))
# 操作提示
t.append(Text("\n" + "─" * 56 + "\n", style="dim"))
t.append(Text(" 数字键 ", style="dim"))
t.append(Text("1-6", style=f"bold {_Theme.WARNING}"))
t.append(Text(" 切换选项 | ", style="dim"))
t.append(Text("Esc", style=f"bold {_Theme.WARNING}"))
t.append(Text(" 关闭\n", style="dim"))
return t
def action_toggle_settings(self):
"""Ctrl+P: 切换辅助中心显示/隐藏"""
overlay = self.query_one("#settings-overlay")
if overlay.has_class("visible"):
self._hide_settings()
else:
self._show_settings()
def _show_settings(self):
"""显示辅助中心:先显示背景遮罩,再滑入卡片"""
backdrop = self.query_one("#settings-backdrop")
overlay = self.query_one("#settings-overlay")
overlay.update(self._build_settings_text())
backdrop.styles.display = "block"
overlay.styles.display = "block"
backdrop.remove_class("hiding")
overlay.remove_class("hiding")
def _show():
backdrop.add_class("visible")
overlay.add_class("visible")
self.call_after_refresh(_show)
def _hide_settings(self):
"""隐藏辅助中心:先滑出卡片,再淡出遮罩"""
backdrop = self.query_one("#settings-backdrop")
overlay = self.query_one("#settings-overlay")
overlay.remove_class("visible")
overlay.add_class("hiding")
backdrop.remove_class("visible")
backdrop.add_class("hiding")
def _finish_hide():
overlay.remove_class("hiding")
overlay.styles.display = "none"
backdrop.remove_class("hiding")
backdrop.styles.display = "none"
self.set_timer(0.26, _finish_hide)
# ── 帮助浮层 ──
def action_toggle_help(self):
"""?: 切换帮助浮层显示/隐藏"""
overlay = self.query_one("#help-overlay")
if overlay.has_class("visible"):
self._hide_help_panel()
else:
self._show_help_panel()
def _show_help_panel(self):
"""显示帮助浮层:先显示背景遮罩,再滑入卡片"""
backdrop = self.query_one("#help-backdrop")
overlay = self.query_one("#help-overlay")
overlay.update(self._build_help_text())
backdrop.styles.display = "block"
overlay.styles.display = "block"
backdrop.remove_class("hiding")
overlay.remove_class("hiding")
def _show():
backdrop.add_class("visible")
overlay.add_class("visible")
self.call_after_refresh(_show)
def _hide_help_panel(self):
"""隐藏帮助浮层:先滑出卡片,再淡出遮罩"""
backdrop = self.query_one("#help-backdrop")
overlay = self.query_one("#help-overlay")
overlay.remove_class("visible")
overlay.add_class("hiding")
backdrop.remove_class("visible")
backdrop.add_class("hiding")
def _finish_hide():
overlay.remove_class("hiding")
overlay.styles.display = "none"
backdrop.remove_class("hiding")
backdrop.styles.display = "none"
self.set_timer(0.26, _finish_hide)
def _build_help_text(self):
"""构建帮助浮层内容 — 全中文,含命令、快捷键、操作说明"""
from rich.text import Text
t = Text()
t.append(Text("📖 帮助\n", style=f"bold {_Theme.ACCENT}", justify="center"))
t.append(Text("─" * 56 + "\n", style="dim"))
# ── 快捷键 ──
t.append(Text("【快捷键】\n", style=f"bold {_Theme.TEXT_MUTED}"))
t.append(Text(" Ctrl+C 退出 Ctrl+L 清屏\n", style=_Theme.TEXT))
t.append(Text(" Ctrl+N 新对话 Ctrl+P 辅助中心\n", style=_Theme.TEXT))
t.append(Text(" 回车 发送消息 Ctrl+J 换行\n", style=_Theme.TEXT))
t.append(Text(" F1 侧栏 F2 切换引擎\n", style=_Theme.TEXT))
t.append(Text(" F9 辅助中心 Esc 取消/关闭浮层\n", style=_Theme.TEXT))
t.append(Text(" ? 帮助 Tab 缩进\n", style=_Theme.TEXT))
# ── 命令 ──
t.append(Text("\n【命令】(在输入框输入 / 触发)\n", style=f"bold {_Theme.TEXT_MUTED}"))
t.append(Text(" /help 帮助信息\n", style=_Theme.TEXT))
t.append(Text(" /quit 退出\n", style=_Theme.TEXT))
t.append(Text(" /cli 切换命令行模式\n", style=_Theme.TEXT))
t.append(Text(" /model <引擎> 切换 AI 引擎\n", style=_Theme.TEXT))
t.append(Text(" /engines 列出引擎\n", style=_Theme.TEXT))
t.append(Text(" /tools 列出工具\n", style=_Theme.TEXT))
t.append(Text(" /status 系统状态\n", style=_Theme.TEXT))
t.append(Text(" /clear 清屏\n", style=_Theme.TEXT))
# ── 安全与展示 ──
t.append(Text("\n【安全与展示】\n", style=f"bold {_Theme.TEXT_MUTED}"))
t.append(Text(" /safe [off|on|manual] 切换安全模式\n", style=_Theme.TEXT))
t.append(Text(" /diff [popup|inline|off] 切换文件修改展示模式\n", style=_Theme.TEXT))
t.append(Text(" /bg <图片路径|off|list> 自定义背景(提取主色调)\n", style=_Theme.TEXT))
# ── 对话历史 ──
t.append(Text("\n【对话历史】\n", style=f"bold {_Theme.TEXT_MUTED}"))
t.append(Text(" /chat save <名称> 保存当前对话\n", style=_Theme.TEXT))
t.append(Text(" /chat list 列出已保存对话\n", style=_Theme.TEXT))
t.append(Text(" /chat open <名称|序号> 加载历史对话\n", style=_Theme.TEXT))
# ── 引擎配置 ──
t.append(Text("\n【引擎配置】\n", style=f"bold {_Theme.TEXT_MUTED}"))
t.append(Text(" /settings 打开辅助中心\n", style=_Theme.TEXT))
t.append(Text(" /profile save <名称> 保存当前 地址/密钥/模型 为档案\n", style=_Theme.TEXT))
t.append(Text(" /profile use <名称|序号> 切换到指定配置档案\n", style=_Theme.TEXT))
t.append(Text(" /profile del <名称> 删除配置档案\n", style=_Theme.TEXT))
# ── 操作提示 ──
t.append(Text("\n" + "─" * 56 + "\n", style="dim"))
t.append(Text(" 按 ", style="dim"))
t.append(Text("Esc", style=f"bold {_Theme.WARNING}"))
t.append(Text(" 关闭\n", style="dim"))
return t
def _cycle_setting(self, idx):
"""数字键切换辅助中心选项"""
from .safety import get_safety, MODE_UNRESTRICTED, MODE_NORMAL, MODE_MANUAL
from .notification import get_notification_manager
from .config import set_system_config
state = self._get_setting_state()
if idx == 1:
# 安全模式循环
safety = get_safety()
new_idx = (state['safety']['idx'] + 1) % 3
mode_map = [MODE_UNRESTRICTED, MODE_NORMAL, MODE_MANUAL]
safety.set_mode(mode_map[new_idx])
label = state['safety']['options'][new_idx][1]
self._update_status(f"安全模式: {label}")
elif idx == 2:
# 通知开关循环
nm = get_notification_manager()
nm.enabled = not nm.enabled
self._update_status(f"通知: {'开启' if nm.enabled else '关闭'}")
elif idx == 3:
# 对话历史条数循环
new_idx = (state['history']['idx'] + 1) % 4
new_val = state['history']['values'][new_idx]
set_system_config('max_history', new_val)
# 同步到 cli 实例和所有引擎
self.cli.max_history = new_val
if hasattr(self.cli, '_set_shared_conversation_history'):
self.cli._set_shared_conversation_history()
label = "无限制" if new_val >= 999999 else str(new_val)
self._update_status(f"历史条数: {label}")
elif idx == 4:
# diff 模式循环
if not state['diff']['available']:
self._error("代码编辑器未加载,无法切换文件对比模式")
return
new_idx = (state['diff']['idx'] + 1) % 3
editor = None
if hasattr(self.cli, 'liugin_manager'):
for tool in self.cli.liugin_manager.tools:
if tool.get('name') == 'code_editor':
editor = tool.get('instance')
break
if editor is None:
return
if new_idx == 0: # off
editor.diff_popup_enabled = False
elif new_idx == 1: # inline
editor.diff_popup_enabled = True
editor.diff_popup_mode = False
else: # popup
editor.diff_popup_enabled = True
editor.diff_popup_mode = True
label = state['diff']['options'][new_idx][1]
self._update_status(f"文件对比: {label}")
elif idx == 5:
# 预设背景循环
self._bg_idx = (state['background']['idx'] + 1) % len(self._BG_CLASSES)
# 切换预设时清除自定义色(避免自定义覆盖预设)
self._bg_custom_color = ""
self._apply_background()
label = self._BG_LABELS[self._bg_idx]
self._update_status(f"背景: {label}")
try:
set_system_config('tui_background', self._bg_idx)
except Exception:
pass
elif idx == 6:
# 自定义背景切换:有自定义则关闭,无则提示用 /bg 命令
if self._bg_custom_color:
self._bg_image_path = ""
self._bg_custom_color = ""
try:
set_system_config('tui_bg_image', '')
except Exception:
pass
self._apply_background()
self._update_status("已清除自定义背景")
else:
self._system("用 /bg <图片路径> 设置自定义背景,/bg off 清除")
else:
return
# 刷新辅助中心卡片内容
overlay = self.query_one("#settings-overlay")
if overlay.has_class("visible"):
overlay.update(self._build_settings_text())
# ── 背景效果 ──
def _apply_background(self):
"""根据 _bg_idx / 自定义色 给 #chat-scroll 切换背景
优先级:自定义图片色 > 预设色调
"""
try:
scroll = self.query_one("#chat-scroll")
# 移除所有预设背景 class
for cls in self._BG_CLASSES:
if cls:
scroll.remove_class(cls)
# 移除自定义背景的内联样式
try:
scroll.styles.background = _Theme.BG
except Exception:
pass
# 自定义图片色优先
if self._bg_custom_color:
try:
scroll.styles.background = self._bg_custom_color
except Exception:
pass
else:
cur = self._BG_CLASSES[self._bg_idx]
if cur:
scroll.add_class(cur)
except Exception:
pass
def _extract_color_from_image(self):
"""从 _bg_image_path 提取主色调,暗化后作为背景色
暗化系数 0.18,避免图片过亮影响文字阅读
"""
if not self._bg_image_path:
self._bg_custom_color = ""
return
try:
from .constants import PIL_AVAILABLE
if not PIL_AVAILABLE:
self._bg_custom_color = ""
self._error("背景图片需要 Pillow 库")
return
from PIL import Image
import os
if not os.path.isfile(self._bg_image_path):
self._bg_custom_color = ""
self._error(f"背景图片不存在: {self._bg_image_path}")
return
with Image.open(self._bg_image_path) as img:
img = img.convert("RGB")
# 缩小到 40x40 加速计算
img.thumbnail((40, 40))
pixels = list(img.getdata())
if not pixels:
self._bg_custom_color = ""
return
# 计算平均色
r = sum(p[0] for p in pixels) // len(pixels)
g = sum(p[1] for p in pixels) // len(pixels)
b = sum(p[2] for p in pixels) // len(pixels)
# 暗化到 18% 亮度,保证文字可读
r = int(r * 0.18)
g = int(g * 0.18)
b = int(b * 0.18)
self._bg_custom_color = f"#{r:02x}{g:02x}{b:02x}"
except Exception as e:
self._bg_custom_color = ""
self._error(f"提取背景色失败: {e}")
def _handle_background(self, args):
"""/bg 命令:设置自定义图片背景或清除
用法: /bg <图片路径> 设置背景(提取主色调)
/bg off 清除自定义,回到预设
/bg list 显示当前背景状态
"""
from .config import set_system_config
arg = args.strip()
if not arg or arg.lower() == "list":
# 显示状态
if self._bg_custom_color:
name = os.path.basename(self._bg_image_path) if self._bg_image_path else ""
self._system(f"当前背景: 自定义 ({name}) 颜色: {self._bg_custom_color}")
else:
self._system(f"当前背景: 预设 ({self._BG_LABELS[self._bg_idx]})")
return
if arg.lower() == "off":
self._bg_image_path = ""
self._bg_custom_color = ""
try:
set_system_config('tui_bg_image', '')
except Exception:
pass
self._apply_background()
self._update_status("背景: 已清除自定义")
# 刷新辅助中心
try:
overlay = self.query_one("#settings-overlay")
if overlay.has_class("visible"):
overlay.update(self._build_settings_text())
except Exception:
pass
return
# 设置图片路径
path = arg.strip().strip('"').strip("'")
# 展开 ~ 和环境变量
path = os.path.expanduser(os.path.expandvars(path))
if not os.path.isfile(path):
self._error(f"图片文件不存在: {path}")
return
self._bg_image_path = path
self._extract_color_from_image()
if self._bg_custom_color:
self._apply_background()
try:
set_system_config('tui_bg_image', path)
except Exception:
pass
name = os.path.basename(path)
self._update_status(f"背景: {name}")
self._system(f"已设置背景: {name} 颜色: {self._bg_custom_color}")
# 刷新辅助中心
try:
overlay = self.query_one("#settings-overlay")
if overlay.has_class("visible"):
overlay.update(self._build_settings_text())
except Exception:
pass
def _handle_profile(self, args):
"""引擎配置档案管理: save/use/del/list"""
from .config import get_profiles, save_profile, delete_profile
parts = args.strip().split(maxsplit=1)
sub = parts[0].lower() if parts else ""
arg = parts[1].strip() if len(parts) > 1 else ""
engine = self.bridge.current_engine()
engine_name = getattr(engine, 'name', 'unknown')
if sub in ('', 'list'):
# 显示辅助中心浮层(等同于 /settings)
self._show_settings()
return
if sub == 'save':
if not arg:
self._error("请提供档案名称。用法: /profile save <名称>")
return
profile = {
"name": arg,
"base_url": getattr(engine, 'base_url', ''),
"api_key": getattr(engine, 'api_key', ''),
"model": getattr(engine, 'model', ''),
}
save_profile(engine_name, profile)
self._system(f" 已保存配置档案: {arg}(引擎: {engine_name})")
# 如果辅助中心浮层可见则刷新
overlay = self.query_one("#settings-overlay")
if overlay.has_class("visible"):
overlay.update(self._build_settings_text())
elif sub == 'use':
if not arg:
self._error("请提供档案名称或序号。用法: /profile use <名称|序号>")
return
profiles = get_profiles(engine_name)
if not profiles:
self._error(f"引擎 {engine_name} 没有已保存的配置档案")
return
target = None
if arg.isdigit():
idx = int(arg) - 1
if 0 <= idx < len(profiles):
target = profiles[idx]
else:
self._error(f"序号 {arg} 超出范围(共 {len(profiles)} 个档案)")
return
else:
for p in profiles:
if p.get('name') == arg:
target = p
break
if not target:
self._error(f"未找到档案: {arg}")
return
# 应用配置到引擎实例
if hasattr(engine, 'apply_config'):
engine.apply_config(
base_url=target.get('base_url'),
api_key=target.get('api_key'),
model=target.get('model'),
)
self._system(f" 已切换到配置档案: {target.get('name')}({engine_name})")
self._update_status(f"引擎: {engine_name} | {target.get('model', '')}")
self._update_sidebar()
# 刷新辅助中心浮层
overlay = self.query_one("#settings-overlay")
if overlay.has_class("visible"):
overlay.update(self._build_settings_text())
else:
self._error(f"引擎 {engine_name} 不支持配置切换")
elif sub in ('del', 'delete'):
if not arg:
self._error("请提供档案名称。用法: /profile del <名称>")
return
profiles = get_profiles(engine_name)
exists = any(p.get('name') == arg for p in profiles)
if not exists:
self._error(f"未找到档案: {arg}")
return
delete_profile(engine_name, arg)
self._system(f" 已删除配置档案: {arg}")
overlay = self.query_one("#settings-overlay")
if overlay.has_class("visible"):
overlay.update(self._build_settings_text())
else:
self._error("用法: /profile save <名称> | use <名称|序号> | del <名称> | list")
def _handle_manual(self, args):
if 'manual' not in self.cli.engines:
self._error("手动引擎未加载")
return
if not args:
if self.bridge.switch_engine('manual'):
self._system("已切换到手动引擎")
self._update_sidebar()
else:
self._error("切换失败")
else:
result = self.cli.engines['manual'].handle_command(args)
if result:
self._system(result)
def _switch_cli(self):
self._system("切换到命令行模式...")
self.cli.tui_output_callback = None
self.cli._cli_requested = True
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):
status_lines = [
f" 系统状态:",
f" 引擎: {self.bridge.current_engine()}",
f" 引擎数: {len(self.bridge.engines())}",
f" 工具数: {len(self.bridge.tools())}",
f" 对话数: {len(self.bridge.history())}",
]
self._system('\n'.join(status_lines))
# ── AI 生成 (elia @work + call_from_thread 模式) ──
def _generate(self, user_input):
"""流式生成 — 基于 elia 的 stream_agent_response 模式
在线程中运行同步的 cli_core,
通过 call_from_thread() 线程安全地更新 UI。
"""
_stream_state = {
"widget": None,
"thinking_widget": None,
"text": "",
"thinking_text": "",
"thinking_removed": False,
"first_chunk": True,
}
def tui_output(msg):
# 引擎切换等操作可能在主线程调用,需做线程判断
import threading as _t
if self._thread_id == _t.get_ident():
self._write_raw(msg)
else:
self.call_from_thread(self._write_raw, msg)
original_cb = self.cli.tui_output_callback
self.cli.tui_output_callback = tui_output
def tui_stream(chunk, kind="content"):
"""流式回调 — kind: 'content' | 'thinking'"""
if self._cancel_requested:
return
if kind == "thinking":
_stream_state["thinking_text"] += chunk
if _stream_state["thinking_widget"] is None:
tw = ThinkingBox(content="")
_stream_state["thinking_widget"] = tw
self.call_from_thread(self._mount_thinking_widget, tw)
self.call_from_thread(
_stream_state["thinking_widget"].append_chunk, chunk
)
self.call_from_thread(self._smart_scroll_end)
return
# kind == "content"
if _stream_state["first_chunk"]:
_stream_state["first_chunk"] = False
_stream_state["thinking_removed"] = True
widget = Chatbox(
role="assistant",
content=chunk,
classes="response-in-progress",
)
_stream_state["widget"] = widget
_stream_state["text"] = chunk
self.call_from_thread(self._mount_first_chunk, widget)
return
_stream_state["text"] += chunk
widget = _stream_state["widget"]
if widget:
self.call_from_thread(widget.append_chunk, chunk)
self.call_from_thread(self._smart_scroll_end)
original_stream_cb = getattr(self.cli, 'tui_stream_callback', None)
self.cli.tui_stream_callback = tui_stream
# 新增:思考专用回调(独立于内容流)
def tui_thinking(chunk):
tui_stream(chunk, kind="thinking")
original_thinking_cb = getattr(self.cli, 'tui_thinking_callback', None)
self.cli.tui_thinking_callback = tui_thinking
# 每轮 AI 生成前: 重新显示思考指示器 (工具调用后 AI 继续)
def tui_before_generate():
_stream_state["thinking_removed"] = False
_stream_state["text"] = ""
_stream_state["thinking_text"] = ""
_stream_state["widget"] = None
_stream_state["thinking_widget"] = None
_stream_state["first_chunk"] = True
self.call_from_thread(self._show_thinking)
self.call_from_thread(lambda: self._update_status(" 思考中..."))
original_before_cb = getattr(self.cli, 'tui_before_generate_callback', None)
self.cli.tui_before_generate_callback = tui_before_generate
# 工具调用审查确认 — 跨线程 Event 实现
import threading as _threading
self._confirm_event = _threading.Event()
def tui_confirm(risk_info):
"""worker 线程调用:显示风险信息并等待用户确认"""
self._confirm_event.clear()
self._confirm_result = False
self.call_from_thread(self._show_confirm, risk_info)
self._confirm_event.wait(timeout=120)
return self._confirm_result
original_confirm_cb = getattr(self.cli, 'tui_confirm_callback', None)
self.cli.tui_confirm_callback = tui_confirm
# 记录当前 worker 的代次,用于 finally 中判断是否已被取消/替换
my_generation = getattr(self, '_cancel_generation', 0)
try:
self.cli.process_conversation(user_input)
except Exception as e:
# 被取消时不报错
if not self._cancel_requested:
self.call_from_thread(self._error, str(e))
finally:
# 只有当前 worker 未被取消/替换时,才恢复回调和状态
current_generation = getattr(self, '_cancel_generation', 0)
if current_generation == my_generation:
self.cli.tui_output_callback = original_cb
self.cli.tui_stream_callback = original_stream_cb
self.cli.tui_thinking_callback = original_thinking_cb
self.cli.tui_before_generate_callback = original_before_cb
self.cli.tui_confirm_callback = original_confirm_cb
# 移除 response-in-progress 样式
if _stream_state["widget"]:
self.call_from_thread(
_stream_state["widget"].remove_class, "response-in-progress"
)
# 清理空的思考 widget
if _stream_state["thinking_widget"] is not None and not _stream_state["thinking_text"]:
self.call_from_thread(self._remove_thinking_widget,
_stream_state["thinking_widget"])
self.call_from_thread(self._remove_thinking)
self.call_from_thread(lambda: self._update_status("就绪"))
self.call_from_thread(lambda: setattr(self, 'is_generating', False))
self.call_from_thread(self._update_sidebar)
_RICH_TAG_RE = re.compile(r'\[/?[a-zA-Z][^\[\]]*?\]')
def _write_raw(self, msg):
"""输出原始消息 — 去掉 Rich 标记"""
# 使用 rich 自身的标记解析(最准确,保留中文方括号)
try:
from rich.text import Text
clean = Text.from_markup(msg).plain
except Exception:
# 降级:手动正则剥离
prev = None
clean = msg
while prev != clean:
prev = clean
clean = self._RICH_TAG_RE.sub('', clean)
if 'OK' in clean or ('工具' in clean and ('调用' in clean or '执行' in clean)):
self._add(f" {clean}", "msg-tool-ok")
elif 'ERROR' in clean or '错误' in clean or '安全' in clean or '拒绝' in clean:
self._add(f" {clean}", "msg-tool-err")
else:
self._add(f" {clean}", "msg-dim")
# ── 动作 ──
def action_clear(self):
if self.is_generating:
return
scroll = self.query_one("#chat-scroll")
scroll.remove_children()
self._render_welcome()
def action_new_chat(self):
if self.is_generating:
return
# 清空前自动保存当前对话(如果有内容且记忆系统可用)
history = self.cli.shared_conversation_history
if history and getattr(self.cli, 'memory_manager', None):
try:
# 用第一条用户消息作为标签
label = ""
for m in history:
if m.get('role') == 'user' and m.get('content'):
label = m['content'][:30]
break
self.cli.memory_manager.auto_save_chat(history, label=label or "未命名对话")
except Exception:
pass
history.clear()
self.action_clear()
self._system("已开始新对话(旧对话已自动保存)")
def action_toggle_sidebar(self):
sidebar = self.query_one("#sidebar")
if sidebar.has_class("visible"):
# 隐藏:先播放滑出动画,结束后再 display:none 释放布局空间
sidebar.remove_class("visible")
sidebar.add_class("hiding")
self.sidebar_visible = False
def _finish_hide():
sidebar.remove_class("hiding")
sidebar.styles.display = "none"
self.set_timer(0.26, _finish_hide)
else:
# 显示:先 display:block 占位,下一帧再加 visible 类触发滑入
sidebar.styles.display = "block"
self._update_sidebar()
def _show():
sidebar.add_class("visible")
self.call_after_refresh(_show)
self.sidebar_visible = True
def action_switch_engine(self):
"""F2: 引擎切换 — 居中浮层丝滑滑入滑出"""
engines = self.bridge.engines()
if len(engines) <= 1:
self._system("只有一个引擎,无法切换")
return
current = self.bridge.current_engine()
try:
idx = engines.index(current)
next_idx = (idx + 1) % len(engines)
next_engine = engines[next_idx]
except ValueError:
next_engine = engines[0]
# 清理旧 banner
for old in self.query("EngineSwitchBanner"):
old.remove()
# 先挂载并显示 banner,再执行切换(营造过渡感)
banner = EngineSwitchBanner(current, next_engine)
self.mount(banner)
status = self.query_one("#status-bar")
def _show():
banner.add_class("show")
status.add_class("pulse")
self._update_status(f"切换中: {current} → {next_engine}")
def _do_switch():
# banner 显示后再真正切换引擎
if not self.bridge.switch_engine(next_engine):
self._error(f"切换引擎失败: {next_engine}")
banner.remove_class("show")
banner.add_class("hide")
self.set_timer(0.3, lambda: banner.remove())
status.remove_class("pulse")
self._update_status("就绪")
return
self._update_status(f"已切换: {next_engine}")
def _hide():
banner.remove_class("show")
banner.add_class("hide")
def _cleanup():
banner.remove()
status.remove_class("pulse")
self._update_status("就绪")
self._update_sidebar()
self.call_after_refresh(_show)
self.set_timer(0.25, _do_switch) # banner 滑入快完成时执行切换
self.set_timer(1.4, _hide) # 显示 1.15s 后开始滑出
self.set_timer(1.75, _cleanup) # 滑出完成后清理
def action_cancel(self):
if not self.is_generating:
return
self._cancel_requested = True
self._system("正在取消...")
# 通知引擎中断(设置 _cancelled 标志,流式循环会检查并退出)
engine = getattr(self.cli, 'current_engine', None)
if engine and hasattr(engine, 'cancel'):
try:
engine.cancel()
except Exception:
pass
# 释放确认等待(如果在工具审查确认期间取消)
if self._confirm_event:
self._confirm_event.set()
# 隐藏确认浮层
self._hide_confirm()
# 取消 worker(标记取消,线程会在下次检查点退出)
if self._current_worker:
try:
self._current_worker.cancel()
except Exception:
pass
self._current_worker = None
# 生成唯一 ID 标记本次取消,防止旧 worker finally 覆盖新 worker 状态
self._cancel_generation = getattr(self, '_cancel_generation', 0) + 1
# UI 收尾
self._finish_cancel()
def _finish_cancel(self):
"""主线程中完成取消的 UI 收尾"""
self._remove_thinking()
self._update_status("已取消")
self.is_generating = False
self._update_sidebar()
else:
class XiaoliTUI:
"""Textual 未安装时的占位类"""
def __init__(self, *args, **kwargs):
pass
def run(self, *args, **kwargs):
print("TUI 模式不可用:Textual 库未安装")
print("请运行: pip install textual rich")