# -*- coding: utf-8 -*- """右侧任务面板:本次会话的 bash 任务监控(运行中 / 已完成) 统一口径(与用户确认): · 两栏 = 「运行中」/「已完成」(按状态分);两栏【同时可见、不互斥】, 中间是可拖动分隔线(260px 宽若真做左右并排,每栏仅 ~130px, 展开区放不下「参数 + 输出」两块列表,故用上下两栏)。 若要严格左右并排:把 _SPLIT_ORIENTATION 改成 Horizontal 即可。 · 一次 bash = 一层(BashLayer),自上而下按执行先后排列 · 单击层头 = 展开/收起;展开后分「参数」「输出」两块 · 运行中:输出为 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" _CFG_PATH = os.path.join(_ROOT, "data", "config.json") def _cfg_path() -> str: """配置文件路径(测试可用 HAOCODE_CONFIG_FILE 指向临时文件 → 绝不污染真配置)""" return os.environ.get("HAOCODE_CONFIG_FILE") or _CFG_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 = [] # 执行先后 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): 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 run_ids]) shown = done_ids[-LAYER_LIMIT:] self.sec_done.set_layers([self._layers[c] for c in 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"): if which == "running": return [c for c in self._order if c in self._running] if which == "done": return [c for c in self._order if c in self._done] return list(self._order)