feat: TUI opencode 风格视觉升级(顶栏/标签页/色条/精简欢迎)
This commit is contained in:
1 parent
addf119ac5
commit
c655d61af4
2 files changed
+245
-58
No files matched your search
+26
-12
@@ -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
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user