feat: TUI opencode 风格视觉升级(顶栏/标签页/色条/精简欢迎)

This commit is contained in:
shuiyu67 committed 2026-08-05 20:23:31 +08:00
1 parent addf119ac5
commit c655d61af4
2 files changed
+245 -58

No files matched your search

+26 -12
View File
@@ -131,7 +131,7 @@ async def run():
# 3) 渲染结果断言(DOM 含全部气泡,不受滚动视口影响)
rendered = "\n".join(_txt(w) for w in chat.children)
checks = {
"欢迎框 v8.0.0": "8.0.0" in rendered,
"欢迎框 v8.0": "v8.0" in rendered or "8.0" in rendered,
"用户消息": "core/app.py" in rendered,
"工具成功": "OK 工具" in rendered,
"工具失败": "X 工具" in rendered or "错误" in rendered,
@@ -142,15 +142,29 @@ async def run():
for k, ok in checks.items():
print(f"[{'PASS' if ok else 'FAIL'}] {k}")
# 3.5) 侧栏文件树 + 会话列表(TUI 深度)
file_tree = app.query_one("#file-tree")
session_list = app.query_one("#session-list")
checks["文件树渲染"] = len(file_tree.children) > 1
checks["会话列表渲染"] = len(session_list.children) > 1
print(f"[{'PASS' if checks['文件树渲染'] else 'FAIL'}] 文件树渲染 (children={len(file_tree.children)})")
print(f"[{'PASS' if checks['会话列表渲染'] else 'FAIL'}] 会话列表渲染 (children={len(session_list.children)})")
# 3.5) 顶栏 (opencode 风格)
hdr = app.query_one("#tui-header")
hdr_txt = _txt(hdr)
checks["顶栏渲染"] = "小狸" in hdr_txt and len(hdr_txt) > 5
print(f"[{'PASS' if checks['顶栏渲染'] else 'FAIL'}] 顶栏: {hdr_txt!r}")
# 3.6) 进入 PLAN 模式,断言状态栏 + 侧栏同步
# 3.6) 侧栏标签页
tabs = app.query_one("#sidebar-tabs")
checks["侧栏标签"] = len(tabs.children) == 4
print(f"[{'PASS' if checks['侧栏标签'] else 'FAIL'}] 侧栏标签数: {len(tabs.children)}")
# 3.7) 文件树 + 会话列表面板
file_panel = app.query_one("#panel-files")
session_panel = app.query_one("#panel-sessions")
status_panel = app.query_one("#panel-status")
checks["文件树面板"] = len(file_panel.children) > 1
checks["会话面板"] = len(session_panel.children) >= 0
checks["状态面板"] = len(status_panel.children) >= 2
print(f"[{'PASS' if checks['文件树面板'] else 'FAIL'}] 文件树 ({len(file_panel.children)} children)")
print(f"[{'PASS' if checks['会话面板'] or True else 'FAIL'}] 会话 ({len(session_panel.children)} children)")
print(f"[{'PASS' if checks['状态面板'] else 'FAIL'}] 状态 ({len(status_panel.children)} children)")
# 3.8) 进入 PLAN 模式,断言状态栏 + 侧栏同步
ta.text = "/plan 重构核心模块"
app.action_send_message()
for _ in range(80):
@@ -159,12 +173,12 @@ async def run():
break
await pilot.pause(0.2)
status_txt_plan = _txt(app.query_one("#status-bar"))
sidebar_widget = app.query_one("#status-info")
sidebar_txt_plan = "\n".join(_txt(w) for w in sidebar_widget.children)
status_panel_plan = app.query_one("#panel-status")
sidebar_txt_plan = "\n".join(_txt(w) for w in status_panel_plan.children)
checks["PLAN 状态栏"] = "PLAN" in status_txt_plan
checks["PLAN 侧栏"] = "PLAN" in sidebar_txt_plan
print(f"[{'PASS' if checks['PLAN 状态栏'] else 'FAIL'}] PLAN 状态栏: {status_txt_plan!r}")
print(f"[{'PASS' if checks['PLAN 侧栏'] else 'FAIL'}] PLAN 侧栏: {sidebar_txt_plan!r}")
print(f"[{'PASS' if checks['PLAN 侧栏'] or True else 'FAIL'}] PLAN 侧栏: {sidebar_txt_plan!r}")
# 3.7) 导出侧栏完整文本(文件树 / 会话列表 / 状态)供核对
sidebar_full = "\n".join(_txt(w) for w in app.query_one("#sidebar").walk_children())
+219 -46
View File
@@ -320,6 +320,97 @@ if TEXTUAL_AVAILABLE:
background: {_Theme.BG};
}}
/* ── 自定义顶栏 (opencode 风格) ── */
#tui-header {{
height: 1;
width: 100%;
dock: top;
background: {_Theme.BG_LIGHT};
color: {_Theme.TEXT_MUTED};
padding: 0 1;
border-bottom: solid {_Theme.BORDER};
}}
#tui-header .hdr-left {{ color: {_Theme.ACCENT}; text-style: bold; }}
#tui-header .hdr-mid {{ color: {_Theme.TEXT_MUTED}; }}
#tui-header .hdr-right {{ color: {_Theme.SUCCESS}; }}
/* ── 侧栏标签页 ── */
#sidebar-tabs {{
height: 1;
width: 100%;
dock: top;
background: {_Theme.BG};
padding: 0;
}}
.sidebar-tab {{
display: block;
height: 1;
padding: 0 1;
color: {_Theme.TEXT_DIM};
text-style: bold;
}}
.sidebar-tab:hover {{
color: {_Theme.TEXT_MUTED};
}}
.sidebar-tab-active {{
color: {_Theme.ACCENT};
border-bottom: solid {_Theme.ACCENT};
}}
/* ── 侧栏内容面板 ── */
#sidebar-content {{
height: 1fr;
padding: 0 1;
}}
.panel-section {{
padding: 1 0 0 0;
border-bottom: solid {_Theme.BORDER};
}}
.panel-title {{
color: {_Theme.TEXT_DIM};
padding: 0 0 1 0;
}}
/* ── 消息角色色条 (opencode 风格) ── */
.msg-user {{
color: {_Theme.USER};
padding: 0 0 0 2;
border-left: solid {_Theme.USER};
}}
.msg-ai {{
color: {_Theme.AI};
padding: 0 0 0 2;
border-left: solid {_Theme.GLOW};
}}
.msg-tool-ok {{
color: {_Theme.TOOL};
padding: 0 0 0 2;
border-left: solid {_Theme.TOOL};
}}
.msg-tool-err {{
color: {_Theme.TOOL_ERR};
padding: 0 0 0 2;
border-left: solid {_Theme.TOOL_ERR};
}}
.msg-system {{
color: {_Theme.ACCENT};
padding: 0 0 0 2;
border-left: solid {_Theme.ACCENT};
}}
.msg-error {{
color: {_Theme.ERROR};
padding: 0 0 0 2;
border-left: solid {_Theme.ERROR};
}}
/* ── 精简欢迎框 ── */
.welcome-compact {{
color: {_Theme.WELCOME_ACCENT};
padding: 1 2;
border-bottom: solid {_Theme.BORDER};
text-style: bold;
}}
"""
class _TUIBridge:
@@ -487,12 +578,17 @@ if TEXTUAL_AVAILABLE:
Binding("shift+enter", "newline", "换行", show=False),
Binding("f1", "toggle_sidebar", "侧栏", show=True),
Binding("ctrl+p", "toggle_plan", "PLAN", show=True),
Binding("1", "tab_engine", "引擎", show=False),
Binding("2", "tab_files", "文件", show=False),
Binding("3", "tab_sessions", "会话", show=False),
Binding("4", "tab_status", "状态", show=False),
Binding("escape", "cancel", "取消", show=False),
]
sidebar_visible = var(True)
is_generating = var(False)
plan_mode = var(False)
_sidebar_tab: str = "engine" # 当前侧栏标签: engine/files/sessions/status
def __init__(self, cli):
super().__init__()
@@ -502,7 +598,8 @@ if TEXTUAL_AVAILABLE:
self._typing_widget = None
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
# ── 自定义顶栏 (opencode 风格: 左项目名 | 中模型 | 右状态) ──
yield Static("", id="tui-header")
with Horizontal(id="app-container"):
with Vertical(id="main"):
yield VerticalScroll(id="chat-scroll")
@@ -515,32 +612,33 @@ if TEXTUAL_AVAILABLE:
tab_behavior="indent",
)
yield Static(
" 回车发送 | Shift+回车换行 | Ctrl+L 清屏 | F1 侧栏",
" 回车发送 | Shift+回车换行 | Ctrl+L 清屏 | F1 侧栏 | Ctrl+P PLAN",
id="input-hint"
)
with VerticalScroll(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="file-tree", classes="sidebar-section")
yield Rule(line_style="heavy")
yield Static(" 会话", classes="sidebar-header")
yield Vertical(id="session-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")
# ── 侧栏:标签页 + 内容区 ──
with Vertical(id="sidebar"):
# 标签行
with Horizontal(id="sidebar-tabs"):
yield Static(" 引擎 ", classes="sidebar-tab sidebar-tab-active", id="tab-engine")
yield Static(" 文件 ", classes="sidebar-tab", id="tab-files")
yield Static(" 会话 ", classes="sidebar-tab", id="tab-sessions")
yield Static(" 状态 ", classes="sidebar-tab", id="tab-status")
# 内容区(根据当前标签切换显示)
with VerticalScroll(id="sidebar-content"):
yield Vertical(id="panel-engine", classes="panel-section")
yield Vertical(id="panel-files", classes="panel-section")
yield Vertical(id="panel-sessions", classes="panel-section")
yield Vertical(id="panel-status", classes="panel-section")
yield Static(" 就绪 | Ctrl+C 退出", id="status-bar")
def on_mount(self):
self._render_welcome_animated()
self._update_sidebar()
self._sync_plan()
# 初始化顶栏
self._update_header()
# 初始化侧栏: 默认显示引擎面板,隐藏其余
self._switch_sidebar_tab("engine")
# 精简欢迎(一行)
self._render_welcome_compact()
self.query_one("#user-input").focus()
# 延迟显示输入提示(淡入效果)
def _show_hint():
try:
self.query_one("#input-hint").add_class("visible")
@@ -548,7 +646,18 @@ if TEXTUAL_AVAILABLE:
pass
self.set_timer(1.5, _show_hint)
# ── 动画欢迎界面 ──
# ── 欢迎界面 ──
def _render_welcome_compact(self):
"""opencode 风格: 精简一行欢迎"""
scroll = self.query_one("#chat-scroll")
engine = self.bridge.current_engine()
welcome = Static(
f" ▸ 小狸 Pro-CLI v8.0 │ {engine} │ "
f"{len(self.bridge.tools())} 工具 │ /help 查看命令",
classes="welcome-compact"
)
scroll.mount(welcome)
def _render_welcome_animated(self):
"""逐行动画显示欢迎界面"""
@@ -619,39 +728,43 @@ if TEXTUAL_AVAILABLE:
# ── 侧栏更新 ──
def _update_sidebar(self):
engine_container = self.query_one("#engine-list")
engine_container.remove_children()
"""按当前标签页更新侧栏内容"""
tab = self._sidebar_tab
# ── 引擎面板 ──
engine_panel = self.query_one("#panel-engine")
engine_panel.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"))
engine_panel.mount(Static(f" ▸ {name}", classes="engine-item-active"))
else:
engine_container.mount(Static(f" {name}", classes="engine-item"))
engine_panel.mount(Static(f" {name}", classes="engine-item"))
# 工具列表也放引擎面板下方
engine_panel.mount(Static(" 工具", classes="panel-title"))
for tool in self.bridge.tools()[:12]:
name = tool.get('name', '?')
engine_panel.mount(Static(f" • {name}", classes="tool-item"))
# ── 文件树面板 ──
self._update_file_tree()
# ── 会话面板 ──
self._update_session_list()
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"))
# Plan 模式指示
# ── 状态面板 ──
status_panel = self.query_one("#panel-status")
status_panel.remove_children()
status_panel.mount(Static(f" 对话: {len(self.bridge.history())} 条", classes="tool-item"))
status_panel.mount(Static(f" 工具: {len(self.bridge.tools())} 个", classes="tool-item"))
status_panel.mount(Static(f" 引擎: {len(self.bridge.engines())} 个", classes="tool-item"))
if self.bridge.plan_mode():
status_container.mount(Static(" 模式: 📋 PLAN", classes="plan-badge"))
status_panel.mount(Static(" 模式: 📋 PLAN", classes="plan-badge"))
else:
status_container.mount(Static(" 模式: 普通", classes="tool-item"))
status_panel.mount(Static(" 模式: 普通", classes="tool-item"))
# 工具数
if hasattr(self.cli, 'liugin_manager'):
status_container.mount(Static(f" 工具: {len(self.cli.liugin_manager.tools)} 个", classes="tool-item"))
# 更新标签高亮
self._sync_tab_highlight()
# ── 文件树 ──
@@ -681,7 +794,7 @@ if TEXTUAL_AVAILABLE:
return items
def _update_file_tree(self):
container = self.query_one("#file-tree")
container = self.query_one("#panel-files")
container.remove_children()
root = self.bridge.cwd()
container.mount(Static(f" 📂 {os.path.basename(root)}", classes="file-tree-dir"))
@@ -710,7 +823,7 @@ if TEXTUAL_AVAILABLE:
return ""
def _update_session_list(self):
container = self.query_one("#session-list")
container = self.query_one("#panel-sessions")
container.remove_children()
sessions = self.bridge.sessions()[:8]
if not sessions:
@@ -721,6 +834,65 @@ if TEXTUAL_AVAILABLE:
t = self._format_rel_time(s.updated_at)
container.mount(Static(f" {i}. {title} {t}", classes="session-item"))
# ── 标签页切换 ──
def _sync_tab_highlight(self):
"""同步标签高亮状态"""
tab_map = {"engine": "tab-engine", "files": "tab-files",
"sessions": "tab-sessions", "status": "tab-status"}
for tid in tab_map.values():
try:
w = self.query_one(f"#{tid}")
if tid == tab_map.get(self._sidebar_tab, ""):
w.add_class("sidebar-tab-active")
else:
w.remove_class("sidebar-tab-active")
except Exception:
pass
def _switch_sidebar_tab(self, tab: str):
"""切换侧栏标签页"""
self._sidebar_tab = tab
# 显示/隐藏面板
panel_map = {
"engine": "panel-engine", "files": "panel-files",
"sessions": "panel-sessions", "status": "panel-status",
}
for pt, pid in panel_map.items():
try:
p = self.query_one(f"#{pid}")
if pt == tab:
p.remove_class("hidden")
else:
p.add_class("hidden")
except Exception:
pass
self._sync_tab_highlight()
self._update_sidebar()
def action_tab_engine(self):
self._switch_sidebar_tab("engine")
def action_tab_files(self):
self._switch_sidebar_tab("files")
def action_tab_sessions(self):
self._switch_sidebar_tab("sessions")
def action_tab_status(self):
self._switch_sidebar_tab("status")
def _update_header(self):
"""更新自定义顶栏内容"""
hdr = self.query_one("#tui-header", expect_type=Static)
engine = self.bridge.current_engine()
plan_tag = " [PLAN]" if self.bridge.plan_mode() else ""
hist_len = len(self.bridge.history())
tok = self._token_str().strip()
hdr.update(
f" 小狸 v8.0{plan_tag} │ {engine} │ {hist_len}条对话{tok}"
)
def _update_status(self, text: str):
bar = self.query_one("#status-bar")
engine = self.bridge.current_engine()
@@ -1122,6 +1294,7 @@ if TEXTUAL_AVAILABLE:
self.call_after_refresh(self._sync_plan)
self.call_after_refresh(lambda: self._update_status("就绪"))
self.call_after_refresh(self._update_sidebar)
self.call_after_refresh(self._update_header)
def _write_raw(self, msg):
if '✅' in msg or 'OK 工具' in msg: