Files
sorrow404null 95024785ae fix(ui): main window fixes - event filter, bash layer order, scrollbar styles, rename overlay
- merge duplicated eventFilter paths and gate send-by-Enter (P0-02)
- display bash task layers in reverse start-time order while reusing
  layer instances and preserving scroll/expand state (P2-01)
- add scoped 8px scrollbars with matching corner for the bash code box
  (P2-02)
- rewrite RenameOverlay as a top-level transparent tool window so it
  can cover the native WebView2 child HWND (P2-03)
2026-09-17 16:40:04 +08:00

957 lines
39 KiB
Python
Raw Permalink 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.
# -*- coding: utf-8 -*-
"""右侧任务面板:本次会话的 bash 任务监控(运行中 / 已完成)
统一口径(与用户确认):
· 两栏 = 「运行中」/「已完成」(按状态分);两栏【同时可见、不互斥】,
中间是可拖动分隔线(260px 宽若真做左右并排,每栏仅 ~130px,
展开区放不下「参数 + 输出」两块列表,故用上下两栏)。
若要严格左右并排:把 _SPLIT_ORIENTATION 改成 Horizontal 即可。
· 一次 bash = 一层(BashLayer),自上而下按【启动先后倒序】排列(P2-01:
最新启动的任务在第一项;排序键恒为启动顺序,运行中→完成归位时
不按完成时间重排;已落库时间线无显式时间时,启动序号 = 消息链
顺序 + 时间线内顺序,稳定可复现)
· 单击层头 = 展开/收起;展开后分「参数」「输出」两块
· 运行中:输出为 tool_bash 的实时流(缓冲上限 200KB,超出丢弃最旧并标注)
· 已完成:输出为「进入上下文的原文」(DB messages.timeline[].result,不截断)
· 范围永远是【当前会话】;切换会话跟随刷新
· 面板【不自动收缩、也不自动展开】,开关就在面板自身:
收起态 = 栏正中按钮;展开态 = 标题行右上角按钮(永远只有一个可动按钮)
· 🆕 左边缘可拖拽调宽(最小 200px);松手立即记录到 config.json["bash_panel_width"]
下次按展开按钮自动恢复到记录的宽度
· 「已出上下文」= 该层在最近一次压缩切点之前(其输出已不在 API 上下文里)
"""
import json
import os
import re
from PyQt6 import QtCore, QtGui, QtWidgets
LIVE_BUF_CAP = 200 * 1024 # 每条实时输出缓冲上限
LAYER_LIMIT = 30 # 已完成栏默认只渲染最近 N 层
_SPLIT_ORIENTATION = QtCore.Qt.Orientation.Vertical # 改 Horizontal = 左右并排
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_DUR_RE = re.compile(r"\((\d+(?:\.\d+)?)s\)\s*$")
# ---------------------------------------------------------------- 宽度
PANEL_W_DEFAULT = 260 # 首次运行的默认展开宽度
PANEL_W_MIN = 200 # 🆕 拖拽最小宽度(再窄「参数/输出」两块就挤坏了)
PANEL_W_MAX = 560 # 绝对上限(实际还会受「不超过主窗口 50%」约束)
_CFG_KEY = "bash_panel_width"
# P0-01:配置路径统一走 core.config_paths(环境变量 HAOCODE_CONFIG_FILE 优先)
from core.config_paths import config_path as _unified_config_path # noqa: E402
def _cfg_path() -> str:
"""配置文件路径(P0-01 统一入口:测试可用 HAOCODE_CONFIG_FILE 指向临时文件 → 绝不污染真配置)"""
return _unified_config_path()
def load_panel_width(default: int = PANEL_W_DEFAULT) -> int:
"""读回上次拖拽记录的宽度;缺失/异常 → 默认值(并强制夹在 [MIN, MAX]"""
try:
with open(_cfg_path(), "r", encoding="utf-8") as f:
w = int(json.load(f).get(_CFG_KEY, default))
except Exception:
return default
return max(PANEL_W_MIN, min(PANEL_W_MAX, w))
def save_panel_width(w: int) -> bool:
"""只改这一个键、其余配置原样保留;临时文件 + replace 原子写"""
p = _cfg_path()
try:
cfg = {}
if os.path.exists(p):
with open(p, "r", encoding="utf-8") as f:
cfg = json.load(f) or {}
cfg[_CFG_KEY] = int(w)
tmp = p + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
os.replace(tmp, p)
return True
except Exception as e:
print(f"[UI]: 保存任务面板宽度失败: {e}")
return False
def _svg(name: str) -> str:
return os.path.join(_ROOT, "svg", name)
def _fmt_dur(sec) -> str:
try:
sec = float(sec)
except Exception:
return ""
return f"{sec:.1f}s" if sec < 60 else f"{int(sec // 60)}m{int(sec % 60)}s"
def _parse_dur(text):
"""从上下文文本结尾的 (Ns) 里取耗时;取不到返回 None"""
m = _DUR_RE.search((text or "").strip())
if not m:
return None
try:
return float(m.group(1))
except Exception:
return None
def _cmd_of(args) -> str:
"""args 可能是 JSON 字符串或 dict → 取出 bash 的 command"""
if isinstance(args, dict):
return args.get("command", "") or ""
try:
d = json.loads(args or "{}")
if isinstance(d, dict):
return d.get("command", "") or ""
except Exception:
pass
return str(args or "")
def _iter_bash_entries(timeline):
"""timeline 可能是 JSON 字符串(DB)或 list[dict](内存)→ 产出 name=='bash' 的工具条目"""
data = timeline
if isinstance(timeline, str):
if not timeline.strip():
return
try:
data = json.loads(timeline)
except Exception:
return
if not isinstance(data, list):
return
for e in data:
if isinstance(e, dict) and e.get("t") == "tool" and e.get("name") == "bash":
yield e
class _ClickFrame(QtWidgets.QFrame):
"""整块可单击(单击即触发,不用双击)"""
clicked = QtCore.pyqtSignal()
def mouseReleaseEvent(self, e):
if (e.button() == QtCore.Qt.MouseButton.LeftButton
and self.rect().contains(e.position().toPoint())):
self.clicked.emit()
super().mouseReleaseEvent(e)
# ======================================================================
# 一层 = 一次 bash 执行
# ======================================================================
class BashLayer(QtWidgets.QFrame):
def __init__(self, call_id: str, command: str = "", parent=None):
super().__init__(parent)
self.setObjectName("bl_layer")
self.call_id = call_id
self.command = command or ""
self.ok = None # None=运行中 / True / False=已完成
self.in_context = True
self.expanded = False
self.elapsed = 0
self.timeout = 0
self._live = ""
self._live_truncated = False
self._final = None
self._build()
self._apply_status()
# ---------------- UI ----------------
def _build(self):
v = QtWidgets.QVBoxLayout(self)
v.setContentsMargins(0, 0, 0, 0)
v.setSpacing(0)
self.head = _ClickFrame()
self.head.setObjectName("bl_head")
self.head.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
self.head.setToolTip(self.command or "")
hl = QtWidgets.QHBoxLayout(self.head)
hl.setContentsMargins(8, 6, 8, 6)
hl.setSpacing(6)
self.dot = QtWidgets.QLabel("●")
self.dot.setObjectName("bl_dot")
self.name = QtWidgets.QLabel("bash")
self.name.setObjectName("bl_name")
self.meta = QtWidgets.QLabel("")
self.meta.setObjectName("bl_meta")
self.tag = QtWidgets.QLabel("")
self.tag.setObjectName("bl_tag")
self.cmd = QtWidgets.QLabel(self._preview())
self.cmd.setObjectName("bl_cmd")
self.cmd.setSizePolicy(QtWidgets.QSizePolicy.Policy.Ignored,
QtWidgets.QSizePolicy.Policy.Preferred)
self.chev = QtWidgets.QLabel("▸")
self.chev.setObjectName("bl_chev")
hl.addWidget(self.dot, 0)
hl.addWidget(self.name, 0)
hl.addWidget(self.meta, 0)
hl.addWidget(self.tag, 0)
hl.addWidget(self.cmd, 1)
hl.addWidget(self.chev, 0)
v.addWidget(self.head)
self.body = QtWidgets.QWidget()
self.body.setObjectName("bl_body")
bl = QtWidgets.QVBoxLayout(self.body)
bl.setContentsMargins(8, 0, 8, 8)
bl.setSpacing(4)
self.lbl_arg = QtWidgets.QLabel("参数")
self.lbl_arg.setObjectName("bl_sect_label")
self.arg_box = self._code_box(80, wrap=True)
bl.addWidget(self.lbl_arg)
bl.addWidget(self.arg_box)
self.lbl_out = QtWidgets.QLabel("输出")
self.lbl_out.setObjectName("bl_sect_label")
self.out_box = self._code_box(230, wrap=False)
bl.addWidget(self.lbl_out)
bl.addWidget(self.out_box)
self.body.hide()
v.addWidget(self.body)
self.head.clicked.connect(self.toggle)
def _code_box(self, max_h, wrap):
box = QtWidgets.QPlainTextEdit()
box.setObjectName("bl_code")
box.setReadOnly(True)
box.setMaximumHeight(max_h)
box.setLineWrapMode(
QtWidgets.QPlainTextEdit.LineWrapMode.WidgetWidth if wrap
else QtWidgets.QPlainTextEdit.LineWrapMode.NoWrap)
box.setFrameShape(QtWidgets.QFrame.Shape.NoFrame)
box.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded)
box.setHorizontalScrollBarPolicy(
QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded if not wrap
else QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
return box
def _preview(self) -> str:
s = " ".join((self.command or "").split())
return s[:58] + ("…" if len(s) > 58 else "")
# ---------------- 状态 ----------------
def _apply_status(self):
if self.ok is None:
self.dot.setProperty("state", "run")
self.meta.setText(f"{self.elapsed}/{self.timeout}s" if self.timeout
else (f"{self.elapsed}s" if self.elapsed else "…"))
else:
self.dot.setProperty("state", "ok" if self.ok else "bad")
self.meta.setText(_fmt_dur(self.elapsed) if self.elapsed else "")
if self.ok is not None and not self.in_context:
self.tag.setText("已出上下文")
self.tag.setProperty("kind", "out")
elif self.ok is None:
self.tag.setText("运行中")
self.tag.setProperty("kind", "run")
elif self.ok:
self.tag.setText("已完成")
self.tag.setProperty("kind", "ok")
else:
self.tag.setText("失败")
self.tag.setProperty("kind", "bad")
for w in (self.dot, self.tag):
w.style().unpolish(w)
w.style().polish(w)
def set_running(self, elapsed, timeout):
self.ok = None
self.elapsed = int(elapsed or 0)
self.timeout = int(timeout or 0)
self._apply_status()
if self.expanded:
self._render_output()
def append_live(self, text: str):
if not text:
return
self._live += text
if len(self._live) > LIVE_BUF_CAP:
self._live = self._live[-LIVE_BUF_CAP:]
self._live_truncated = True
if self.expanded:
self._render_output()
def set_finished(self, ok, text):
self.ok = bool(ok)
self._final = text or ""
d = _parse_dur(self._final)
if d is not None:
self.elapsed = d
self._apply_status()
if self.expanded:
self._render_output()
def set_in_context(self, in_ctx: bool):
self.in_context = bool(in_ctx)
self._apply_status()
# ---------------- 展开 ----------------
def toggle(self):
self.expanded = not self.expanded
self.body.setVisible(self.expanded)
self.chev.setText("▾" if self.expanded else "▸")
if self.expanded:
self.arg_box.setPlainText(self.command or "(无参数)")
self._render_output()
def _render_output(self):
if self.ok is None:
body = self._live
if self._live_truncated:
body = "…(实时缓冲已截断,仅保留最后 200KB)\n" + body
self.lbl_out.setText("输出(实时)")
self.out_box.setPlainText(body if body else "(等待输出…)")
sb = self.out_box.verticalScrollBar()
sb.setValue(sb.maximum())
else:
self.lbl_out.setText("输出(进入上下文)")
self.out_box.setPlainText(self._final or "(无输出)")
# ======================================================================
# 一栏:标题(可折叠)+ 层列表
# ======================================================================
class _Section(QtWidgets.QWidget):
def __init__(self, title, empty_hint, on_fold=None, parent=None):
super().__init__(parent)
self.empty_hint = empty_hint
self.folded = False
self._on_fold = on_fold # 回调:交面板做高度动画(抽屉式)
self._effect = None # 内容区淡入淡出用(按需创建,静止时移除)
self._head_hint = 0 # 头部行高缓存(恒定值)
v = QtWidgets.QVBoxLayout(self)
v.setContentsMargins(0, 0, 0, 0)
v.setSpacing(0)
self.head = _ClickFrame()
self.head.setObjectName("bl_sect_head")
self.head.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
hl = QtWidgets.QHBoxLayout(self.head)
hl.setContentsMargins(10, 6, 8, 6)
hl.setSpacing(6)
self.chev = QtWidgets.QLabel("▾")
self.chev.setObjectName("bl_sect_chev")
self.title = QtWidgets.QLabel(title)
self.title.setObjectName("bl_sect_title")
self.count = QtWidgets.QLabel("0")
self.count.setObjectName("bl_sect_count")
hl.addWidget(self.chev, 0)
hl.addWidget(self.title, 0)
hl.addStretch(1)
hl.addWidget(self.count, 0)
v.addWidget(self.head)
self.scroll = QtWidgets.QScrollArea()
self.scroll.setObjectName("bl_scroll")
self.scroll.setWidgetResizable(True)
self.scroll.setFrameShape(QtWidgets.QFrame.Shape.NoFrame)
self.scroll.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
# 🐛 修复:QScrollArea 的 viewport 默认按 palette base(白)自画背景,
# 会把父级 #f7f8fa 盖成白色 → 强制透明
self.scroll.viewport().setStyleSheet("background: transparent;")
self.scroll.viewport().setAutoFillBackground(False)
self.host = QtWidgets.QWidget()
self.host.setObjectName("bl_host")
self.host.setAutoFillBackground(False)
self.lay = QtWidgets.QVBoxLayout(self.host)
self.lay.setContentsMargins(6, 2, 6, 6)
self.lay.setSpacing(5)
self.scroll.setWidget(self.host)
# 🆕 「下面填充的 bash 层」整块放进 body:折叠/展开动画只针对它,
# 头部行(self.head)高度永远不变
self.body = QtWidgets.QWidget()
self.body.setObjectName("bl_body")
self.body.setAutoFillBackground(False)
bv = QtWidgets.QVBoxLayout(self.body)
bv.setContentsMargins(0, 0, 0, 0)
bv.setSpacing(0)
bv.addWidget(self.scroll, 1)
self.hint = QtWidgets.QLabel("")
self.hint.setObjectName("bl_hint")
self.hint.setWordWrap(True)
bv.addWidget(self.hint)
v.addWidget(self.body, 1)
self.head.clicked.connect(self._toggle_fold)
self._set_hint(self.empty_hint)
# 🐛 头部行高【硬固定】:收起时 body 被隐、布局里只剩 head,而 QFrame 默认
# 竖直策略可伸长 → head 会被拉满整栏(实测 793px 巨条 = 「按钮变大很高」)
self._head_hint = max(16, self.head.sizeHint().height())
self.head.setFixedHeight(self._head_hint)
# ---------------- 头部行高(收起/展开恒定的那一行)----------------
def head_height(self) -> int:
# 🐛 必须缓存 sizeHint:若用实时 self.head.height(),会形成正反馈——
# 头部被压矮 → minimumSizeHint 变小 → splitter 允许更矮 → 头部再被压矮…
if self._head_hint <= 0:
self._head_hint = max(16, self.head.sizeHint().height())
return self._head_hint
def minimumSizeHint(self):
# 🐛 关键修复:默认最小高 ≈ 113px(头部 + QScrollArea 最小高 + 提示语),
# QSplitter 完全没法把本栏收缩 → 收起后留一大块空白(用户报的 bug)。
# 改为「只剩头部行」,内容区允许被压到 0(超出部分天然被父级裁切)。
return QtCore.QSize(0, self.head_height())
# ---------------- 内容区透明度(动画帧驱动)----------------
def set_body_opacity(self, op: float):
op = max(0.0, min(1.0, float(op)))
if op >= 0.999:
self._drop_effect()
return
if self._effect is None:
self._effect = QtWidgets.QGraphicsOpacityEffect(self.body)
self.body.setGraphicsEffect(self._effect)
self._effect.setOpacity(op)
def _drop_effect(self):
"""去掉离屏渲染开销(静止时不挂 effect"""
if self.body.graphicsEffect() is not None:
self.body.setGraphicsEffect(None)
self._effect = None
def show_body(self):
self.body.setVisible(True)
self.scroll.setVisible(True)
self.hint.setVisible(bool(self.hint.text()))
def hide_body(self):
self.body.setVisible(False)
def set_folded(self, folded: bool):
"""应用折叠状态(高度由面板的 splitter 动画负责)"""
self.folded = bool(folded)
self.chev.setText("▸" if self.folded else "▾")
if self.folded:
self._drop_effect()
else:
self.show_body()
self.set_body_opacity(1.0)
def _toggle_fold(self):
if self._on_fold is not None:
self._on_fold(self) # 交面板:状态切换 + 高度抽屉动画
return
self.set_folded(not self.folded)
self.scroll.setVisible(not self.folded)
self.hint.setVisible(not self.folded and bool(self.hint.text()))
def _set_hint(self, text):
self.hint.setText(text or "")
self.hint.setVisible(not self.folded and bool(text))
def set_layers(self, layers, total=None):
"""重排层(复用同一批 widget → 实时缓冲不丢)"""
while self.lay.count():
it = self.lay.takeAt(0)
w = it.widget()
if w is not None:
w.setParent(None)
for w in layers:
self.lay.addWidget(w)
w.show()
self.lay.addStretch(1)
self.count.setText(str(len(layers)))
total = len(layers) if total is None else total
if total > len(layers):
self._set_hint(f"仅显示最近 {len(layers)} 层(本会话共 {total} 层)")
else:
self._set_hint("" if layers else self.empty_hint)
# ======================================================================
# 右侧任务面板(外壳,镜像左侧边栏)
# ======================================================================
class _ResizeHandle(QtWidgets.QWidget):
"""面板左边缘的拖拽手柄(4px 隐形条,hover 才亮;光标 SizeHor"""
W = 4
def __init__(self, panel):
super().__init__(panel)
self._panel = panel
self.setObjectName("bl_resize_handle")
# QWidget 子类必须显式开启才会绘制样式表背景(hover 高亮靠它)
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground, True)
self.setCursor(QtCore.Qt.CursorShape.SizeHorCursor)
self.setStyleSheet("#bl_resize_handle:hover { background: #dfe3ea; }")
self.setToolTip("拖拽调整任务面板宽度(最小 %dpx" % PANEL_W_MIN)
self._drag = False
def mousePressEvent(self, e):
if e.button() == QtCore.Qt.MouseButton.LeftButton:
self._drag = True
self._panel._drag_begin(e.globalPosition().x())
e.accept()
return
super().mousePressEvent(e)
def mouseMoveEvent(self, e):
if self._drag:
self._panel._drag_to_global_x(e.globalPosition().x())
e.accept()
return
super().mouseMoveEvent(e)
def mouseReleaseEvent(self, e):
if self._drag:
self._drag = False
self._panel._commit_drag_width()
e.accept()
return
super().mouseReleaseEvent(e)
class BashPanel(QtWidgets.QWidget):
W_EXPAND = PANEL_W_DEFAULT
W_COLLAPSE = 52
W_MIN = PANEL_W_MIN
W_MAX = PANEL_W_MAX
FOLD_MS = 200 # 栏折叠/展开时长(与 ModelSelectPopup.DRAWER_DURATION 一致)
FOLD_STEP = 16 # 帧间隔(与 DRAWER_STEP 一致,~60fps
MIN_BODY_H = 60 # 展开后「下面填充的层」至少保留的高度
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("right_sidebar")
# 🐛 关键修复:QWidget 的自定义子类默认【不绘制样式表背景/边框】
# #sidebar 是原生 QWidget 实例所以正常,BashPanel 是子类所以失效——
# 表现为右侧栏底色变白、左侧分隔线丢失、空间感消失)
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground, True)
# 🆕 展开宽度:优先用上次拖拽记录的值(config.json: bash_panel_width
self.W_EXPAND = load_panel_width()
self._end_w = self.W_EXPAND
self._drag_right = None
self._w = self.W_COLLAPSE
self.setFixedWidth(self._w) # 默认收起
self.collapsed = True
self._target = True
self._anim = None
self._session_id = None
self._db = None
self._layers = {} # call_id -> BashLayer
self._order = [] # 稳定启动序号(0=最早启动;追加顺序 = 启动先后)
self._running = set()
self._done = set()
self._saved_h = [120, 520] # 每栏收起前的高度(展开时恢复)
self._fold = None # 折叠动画状态
self._fold_timer = QtCore.QTimer(self)
self._fold_timer.setInterval(self.FOLD_STEP)
self._fold_timer.timeout.connect(self._fold_tick)
self._build()
# 🆕 左边缘拖拽手柄(浮在最左 4px,收起/展开两种状态都在)
self._handle = _ResizeHandle(self)
self._handle.setFixedWidth(_ResizeHandle.W)
self._handle.raise_()
# ---------------- 外壳 ----------------
def _build(self):
v = QtWidgets.QVBoxLayout(self)
v.setContentsMargins(0, 0, 0, 0)
v.setSpacing(0)
self.stack = QtWidgets.QStackedWidget()
v.addWidget(self.stack)
# ---- 展开页(顶部留白 20px + 36px 标题行,与左侧栏完全对齐)----
self.expand_page = QtWidgets.QWidget()
ev = QtWidgets.QVBoxLayout(self.expand_page)
ev.setContentsMargins(0, 20, 0, 12)
ev.setSpacing(0)
self.header = QtWidgets.QWidget()
self.header.setObjectName("bl_panel_head")
self.header.setFixedHeight(36)
hl = QtWidgets.QHBoxLayout(self.header)
hl.setContentsMargins(10, 0, 10, 0)
hl.setSpacing(6)
self.lbl_title = QtWidgets.QLabel("任务面板")
self.lbl_title.setObjectName("bl_panel_title")
self.lbl_total = QtWidgets.QLabel("")
self.lbl_total.setObjectName("bl_panel_total")
# 🐛 修复双按钮:面板内不再自带收起按钮(唯一开关 = 顶部工具栏按钮),
# 与左侧栏的区别:左侧栏的开关在栏内,右侧栏的开关在顶部(原「导出」位置)
hl.addWidget(self.lbl_title, 0)
hl.addWidget(self.lbl_total, 0)
hl.addStretch(1)
# 🆕 展开态开关:标题行右端(与左侧栏 collapse_btn 完全同规格)
self.fold_btn = QtWidgets.QPushButton("")
self.fold_btn.setObjectName("collapse_btn")
self.fold_btn.setIcon(QtGui.QIcon(_svg("panel_right.svg")))
self.fold_btn.setIconSize(QtCore.QSize(16, 16))
self.fold_btn.setFixedSize(28, 28)
self.fold_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
self.fold_btn.setToolTip("收起任务面板")
hl.addWidget(self.fold_btn, 0)
ev.addWidget(self.header)
ev.addSpacing(10)
self.splitter = QtWidgets.QSplitter(_SPLIT_ORIENTATION)
self.splitter.setObjectName("bl_splitter")
self.splitter.setChildrenCollapsible(False)
self.splitter.setHandleWidth(5)
self.sec_running = _Section("运行中", "暂无正在运行的 bash", on_fold=self._toggle_section)
self.sec_done = _Section("已完成", "本会话还没有已完成的 bash", on_fold=self._toggle_section)
self.splitter.addWidget(self.sec_running)
self.splitter.addWidget(self.sec_done)
# 🆕 底部留白占位:最后一栏收起时由它吸收余量(否则栏头会被顶到面板底部,
# 或被拉伸成巨条)
self.spacer = QtWidgets.QWidget()
self.spacer.setObjectName("bl_spacer")
self.spacer.setAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground, True)
self.spacer.setStyleSheet("#bl_spacer { background: transparent; }")
self.splitter.addWidget(self.spacer)
self.splitter.setCollapsible(2, True) # 占位层允许被压到 0
self.splitter.setSizes(self._saved_h + [0])
_h2 = self.splitter.handle(2)
if _h2 is not None: # 占位层上方的分隔条:不可拖、不可见
_h2.setEnabled(False)
_h2.setStyleSheet("background: transparent;")
ev.addWidget(self.splitter, 1)
self.stack.addWidget(self.expand_page)
# ---- 收起页:栏正中一个展开按钮(与左侧栏 collapse_expand_btn 完全同规格)----
self.collapse_page = QtWidgets.QWidget()
cl = QtWidgets.QVBoxLayout(self.collapse_page)
cl.setContentsMargins(0, 0, 0, 0)
cl.addStretch(1)
self.expand_btn = QtWidgets.QPushButton("")
self.expand_btn.setObjectName("collapse_btn")
self.expand_btn.setIcon(QtGui.QIcon(_svg("panel_right.svg")))
self.expand_btn.setIconSize(QtCore.QSize(18, 18))
self.expand_btn.setFixedSize(34, 34)
self.expand_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
self.expand_btn.setToolTip("展开任务面板")
cl.addWidget(self.expand_btn, 0, QtCore.Qt.AlignmentFlag.AlignHCenter)
cl.addStretch(1)
self.stack.addWidget(self.collapse_page)
# 两个按钮分居 QStackedWidget 两页 → 任何时刻可见开关恒为 1 个
self.stack.setCurrentWidget(self.collapse_page)
self.fold_btn.clicked.connect(self.toggle)
self.expand_btn.clicked.connect(self.toggle)
# ---------------- 两栏折叠/展开:抽屉式高度动画(头部行高恒定)----------------
def _toggle_section(self, sec):
"""点栏头 → 只把「下面填充的 bash 层」的高度收到 0,头部行高度不变"""
self._finish_fold() # 连点:先把上一段动画落定
idx = 0 if sec is self.sec_running else 1
sizes = self.splitter.sizes()
avail = sum(sizes)
head_h = sec.head_height()
other = self.sec_done if idx == 0 else self.sec_running
if not sec.folded and not other.folded:
# 即将收起 + 另一栏展开 → 记住当前高度(用于下次展开恢复);
# 另一栏已收起时不记(此时本栏高度是「余量」而非用户意图高度)
self._saved_h[idx] = max(sizes[idx], head_h + self.MIN_BODY_H)
sec.set_folded(not sec.folded)
target = self._layout_targets(actor=sec)
if not sec.folded: # 展开 → 内容从透明开始淡入
sec.show_body()
sec.set_body_opacity(0.0)
self._sync_handle()
if avail <= 0 or self.splitter.width() <= 0:
self.splitter.setSizes(target)
self._end_fold_visual(sec)
return
self._fold = {"sec": sec, "from": list(sizes), "to": target, "t": 0.0}
self._fold_timer.start()
def _layout_targets(self, actor=None):
"""统一布局策略(层从顶部堆叠):
· 收起栏 = 只剩头部行
· 本次被点开的那栏 → 用记录的高度
· 余量只由「展开着的已完成」吸收(填满到底部);
若「已完成」已收起 → 余量进底部留白(栏头紧跟上方内容)
"""
s = self.splitter.sizes()
avail = sum(s)
hr = self.sec_running.head_height()
hd = self.sec_done.head_height()
h0, h1 = s[0], s[1]
run_f, done_f = self.sec_running.folded, self.sec_done.folded
if run_f:
h0 = hr
if done_f:
h1 = hd
if (not run_f) and actor is self.sec_running:
h0 = self._saved_h[0]
if (not done_f) and actor is self.sec_done:
h1 = self._saved_h[1]
if not done_f: # 已完成展开 → 吃掉全部余量
floor0 = hr if run_f else hr + self.MIN_BODY_H
h1 = max(hd + self.MIN_BODY_H, avail - max(h0, floor0))
h0 = max(hr if run_f else hr + self.MIN_BODY_H, h0)
h1 = max(hd if done_f else hd + self.MIN_BODY_H, h1)
return [h0, h1, max(0, avail - h0 - h1)]
def _fold_tick(self):
"""抽屉式每帧:ease-out 三次方插值高度 + 内容淡入淡出(同模型选择窗口)"""
d = self._fold
if d is None:
self._fold_timer.stop()
return
d["t"] = min(1.0, d["t"] + self.FOLD_STEP / self.FOLD_MS)
e = 1.0 - (1.0 - d["t"]) ** 3
a, b = d["from"], d["to"]
self.splitter.setSizes([int(round(x + (y - x) * e)) for x, y in zip(a, b)])
sec = d["sec"]
sec.set_body_opacity((1.0 - e) if sec.folded else e)
if d["t"] >= 1.0:
self._finish_fold()
def _finish_fold(self):
self._fold_timer.stop()
d = self._fold
if d is None:
return
self._fold = None
self.splitter.setSizes(d["to"])
self._end_fold_visual(d["sec"])
def _end_fold_visual(self, sec):
if sec.folded:
sec.set_body_opacity(0.0)
sec.hide_body() # 隐藏后连离屏渲染也省了
else:
sec.show_body()
sec.set_body_opacity(1.0) # 置 1 → 自动移除 effect
def _sync_handle(self):
"""两栏都展开 → 中间分隔条可拖;任一栏收起 → 禁用(避免「刚收起又被拖大」)"""
h = self.splitter.handle(1)
if h is not None:
ok = not (self.sec_running.folded or self.sec_done.folded)
h.setEnabled(ok)
h.setCursor(QtCore.Qt.CursorShape.SplitVCursor if ok
else QtCore.Qt.CursorShape.ArrowCursor)
def _repin_fold(self):
"""窗口/面板尺寸变化后,按统一策略重新钉住(收起栏 = 只剩头部行)"""
if self._fold is not None or self.collapsed:
return
if not (self.sec_running.folded or self.sec_done.folded):
return
want = self._layout_targets()
if want != self.splitter.sizes():
self.splitter.setSizes(want)
def get_panelWidth(self):
return self._w
def set_panelWidth(self, w):
self._w = int(w)
self.setFixedWidth(int(w))
panelWidth = QtCore.pyqtProperty(int, fget=get_panelWidth, fset=set_panelWidth)
def toggle(self):
self._set_collapsed(not self._target)
def _set_collapsed(self, collapsed: bool):
self._stop_anim()
self._target = collapsed
self.stack.setCurrentWidget(
self.collapse_page if collapsed else self.expand_page)
start_w = self.width()
# 展开时若记录的宽度超出当前窗口可承受范围 → 夹一下
self._end_w = self.W_COLLAPSE if collapsed else self._clamp_w(self.W_EXPAND)
end_w = self._end_w
self._anim = QtCore.QPropertyAnimation(self, b"panelWidth")
self._anim.setDuration(260)
self._anim.setStartValue(float(start_w))
self._anim.setEndValue(float(end_w))
self._anim.setEasingCurve(QtCore.QEasingCurve.Type.InOutCubic)
self._anim.finished.connect(self._on_anim_finished)
self._anim.start()
def _on_anim_finished(self):
self.setFixedWidth(self.W_COLLAPSE if self._target else self._end_w)
self.collapsed = self._target
self._stop_anim()
# ---------------- 宽度拖拽(右边界固定,左边界跟随鼠标)----------------
def _stop_anim(self):
if self._anim is not None:
try:
self._anim.stop()
self._anim.deleteLater()
except Exception:
pass
self._anim = None
def _clamp_w(self, w) -> int:
"""夹在 [W_MIN, min(W_MAX, 主窗口 50%)](最小宽度就在这里生效)"""
hi = self.W_MAX
par = self.parentWidget()
if par is not None and par.width() > 0:
hi = min(hi, max(self.W_MIN, int(par.width() * 0.5)))
return max(self.W_MIN, min(hi, int(w)))
def resizeEvent(self, e):
super().resizeEvent(e)
h = getattr(self, "_handle", None)
if h is not None:
h.setGeometry(0, 0, _ResizeHandle.W, self.height())
h.raise_()
self._repin_fold()
def _drag_begin(self, gx):
self._stop_anim()
# 动画中途按下 → 以当前实际宽度为准确定状态
self.collapsed = self.width() <= self.W_COLLAPSE + 8
self._target = self.collapsed
self.stack.setCurrentWidget(
self.collapse_page if self.collapsed else self.expand_page)
self._drag_right = self.mapToGlobal(QtCore.QPoint(self.width(), 0)).x()
def _drag_to_global_x(self, gx):
if self._drag_right is None:
return
w = self._clamp_w(self._drag_right - int(gx))
if self.collapsed:
if w <= self.W_COLLAPSE + 8:
return # 还在收起条内拖 → 不响应
self.collapsed = False # 从收起态直接拖开 → 立即进展开态
self._target = False
self.stack.setCurrentWidget(self.expand_page)
self._w = w
self.setFixedWidth(w)
def _commit_drag_width(self):
self._drag_right = None
if self.collapsed:
return
self.W_EXPAND = self._w
if save_panel_width(self._w):
print(f"[UI]: 任务面板宽度 -> {self._w}px(已记录,下次展开自动使用)")
# ---------------- 数据 ----------------
def set_session(self, session_id, db, active_stream=None):
"""切换会话 → 全量重建。已完成来自 DB 链;未落库的在跑任务来自 active_stream。"""
self._session_id = session_id
self._db = db
self._layers.clear()
self._order.clear()
self._running.clear()
self._done.clear()
# ① DB:本会话链上的历史 bash(标注是否还在上下文内)
try:
chain = db.get_message_chain(session_id) if db else []
except Exception:
chain = []
mark_idx = -1
for i, m in enumerate(chain):
if m.get("role") == "compaction":
mark_idx = i
for i, m in enumerate(chain):
msg_id = m.get("id") or ""
for e in _iter_bash_entries(m.get("timeline")):
cid = e.get("id") or f"dbmsg_{msg_id}_{i}"
if cid in self._layers:
continue
lay = BashLayer(cid, _cmd_of(e.get("args")))
lay.set_finished(bool(e.get("ok")), e.get("result") or "")
lay.set_in_context(i > mark_idx)
self._layers[cid] = lay
self._order.append(cid)
self._done.add(cid)
# ② 内存:正在进行的这一轮(包括已开始但未落库、以及刚结束未落库的)
for e in _iter_bash_entries((active_stream or {}).get("timeline")):
cid = e.get("id")
if not cid or cid in self._layers:
continue
lay = BashLayer(cid, _cmd_of(e.get("args")))
if e.get("ok") is None:
lay.set_running(0, 0)
if e.get("result"):
lay.append_live(str(e.get("result"))) # 尽力回放(上游上限 4000 字符)
self._running.add(cid)
else:
lay.set_finished(bool(e.get("ok")), e.get("result") or "")
self._done.add(cid)
self._layers[cid] = lay
self._order.append(cid)
self._refresh()
def _refresh(self):
# P2-01:两栏都按【启动顺序倒序】显示(最新启动在第一项)。
# 排序键恒为 self._order 中的位置(稳定启动序号)——
# · 运行中→已完成 的任务仍占原启动位置(绝不按完成时间重排);
# · 已完成栏的限量窗口 = 最近启动的 LAYER_LIMIT 个(窗口成员不变,仅显示顺序反转)。
# 重排走 set_layers(复用同一批 BashLayer 实例):展开/折叠状态、实时输出、
# 代码框水平/垂直滚动值、两栏 section 滚动位置全部保持(同对象,不重建)。
run_ids = [c for c in self._order if c in self._running]
done_ids = [c for c in self._order if c in self._done]
self.sec_running.set_layers([self._layers[c] for c in reversed(run_ids)])
shown = done_ids[-LAYER_LIMIT:]
self.sec_done.set_layers([self._layers[c] for c in reversed(shown)], total=len(done_ids))
self.lbl_total.setText(f"共 {len(self._order)}" if self._order else "")
# ---------------- 事件 ----------------
def on_started(self, call_id, name, args):
if name != "bash" or not call_id or call_id in self._layers:
return
lay = BashLayer(call_id, _cmd_of(args))
lay.set_running(0, 0)
self._layers[call_id] = lay
self._order.append(call_id)
self._running.add(call_id)
self._refresh()
def on_output(self, call_id, text):
lay = self._layers.get(call_id)
if lay is not None and lay.ok is None:
lay.append_live(text)
def on_timed(self, call_id, elapsed, timeout):
lay = self._layers.get(call_id)
if lay is not None and lay.ok is None:
lay.set_running(elapsed, timeout)
def on_finished(self, call_id, name, ok, text):
lay = self._layers.get(call_id)
if lay is None:
if name != "bash" or not call_id:
return
lay = BashLayer(call_id, "")
self._layers[call_id] = lay
self._order.append(call_id)
self._running.discard(call_id)
self._done.add(call_id)
lay.set_finished(bool(ok), text or "")
lay.set_in_context(True)
self._refresh()
def clear_all(self):
self._layers.clear()
self._order.clear()
self._running.clear()
self._done.clear()
self._refresh()
# ---------------- 测试/调试辅助 ----------------
def layer_ids(self, which="all"):
# running/done 返回真实显示顺序(启动倒序);"all" = 原始启动序号(正序)
if which == "running":
return [c for c in self._order if c in self._running][::-1]
if which == "done":
return [c for c in self._order if c in self._done][::-1]
return list(self._order)