chore: import original project baseline

Import the pre-repair source tree as the history baseline.
Runtime data (data/), virtualenvs, bytecode caches and logs are
gitignored so local secrets and user state stay out of the repo.
This commit is contained in:
2026-09-17 16:40:01 +08:00
commit a7412824e0
124 changed files with 26747 additions and 0 deletions
+944
View File
@@ -0,0 +1,944 @@
# -*- 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)