Repository navigation
Expand file tree
/
Copy pathoverlay.js
More file actions
1570 lines (1539 loc) · 96.6 KB
/
Copy pathoverlay.js
File metadata and controls
1570 lines (1539 loc) · 96.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/* ZCode token 用量状态条 v58(渲染进程注入,自包含 IIFE)。
* 形态:输入框视觉卡片正下方的悬浮胶囊条 —— 给卡片加 margin-bottom 上移让位,
* 条 fixed 悬浮在卡片边框外、窗口底边上的空带里,rAF 每帧跟随,左缘与卡片对齐。
* (条不能放在输入框中心点所在矩形内:命中检测自遮挡 = 周期性闪烁,v12-v14 实测。)
* 视觉(v28):半透明胶囊底 + 组间 │ 分隔 + 上下文微进度条(三档色)+ 子代理呼吸灯;
* 条面只放主数值,明细进 hover title。
* 视觉(v54 重写):玻璃拟态胶囊(blur+内高光+分层投影)、发丝分隔线替代 │ 字符、
* 条目悬浮底色提示可交互、行内 SVG 线性图标、tabular 数字、exc 呼吸闪烁、
* 面板/tooltip/气泡统一圆角玻璃系;行为机制(tooltip 生命周期/遮挡豁免/面板互斥)零改动。
* 主题(v58):配色全量跟随客户端主题 —— 颜色一律 var(--color-*, 兜底值) 引用客户端语义
* 变量(<html> 的 .dark 类翻转整套值,zai-light/zai-dark 特殊主题同样适配),
* MutationObserver 观察该类同步 .zu-light(仅剩阴影/光晕程度性差异)。
* 数据:主进程泵推 window.__zusageUpdate;显示项可在 ⚙ 面板配置(localStorage 持久化)。
* 分屏(v61.1):协调器每 600ms 扫描可见输入框,按 pane(data-pane-id)派发实例——
* 一个 pane 一条,各自显示 composer 祖先链 data-session-id 指向的会话;
* 各 pane 会话 id 汇总挂 window.__zusageWantSids 交泵强制拉取(旧泵忽略)。
* 当前会话(v34):泵按窗口注入 payload.mine(客户端渲染端经 IPC 向主进程上报的焦点会话 id,
* 可为空串);overlay 优先显示 mine 对应的池条目,池里还没有(新会话没数据)就显示
* 零值 —— 绝不回退到"别的会话"的数字(多窗口共享 localStorage 时旧启发式必错,实测)。
* mine 缺失(辅助窗口/旧泵)才走 localStorage 键启发式 pickCurrent。
* 热更新:主进程 mtime 检测 + new Function 语法校验后重注入;代际守卫防旧实例打架。
* 诊断:异常与挂载信息写 window.__zusageDiag,由主进程泵取回写 diag-<n>.json。 */
(function () {
if (window.__zusageOverlay) return;
window.__zusageOverlay = true;
/* 代际守卫:热更新后旧实例(interval/rAF 闭包无法外部清除)发现代号落后即永久罢工 */
var MY_GEN = (window.__zusageGen = (window.__zusageGen || 0) + 1);
function stale() { return MY_GEN !== window.__zusageGen; }
var FATAL = (window.__zusageDiag = {});
/* 多实例(v61.1):热更新时泵只按 id 清 0 号条,其余实例的 DOM(条/tip/气泡)
* 在新代码启动时按类名统一自清;旧实例闭包靠 stale() 罢工。 */
try {
document.querySelectorAll(".zusage-root,.zusage-tip-root,.zusage-exc").forEach(function (el) { el.remove(); });
} catch (e) { }
/* ---------- 共享状态(多实例一致):显示项/语言/上下文覆盖/最近一次数据 ---------- */
var LS = { show: "zusage3.show", ctxOv: "zusage3.ctxOv", lang: "zusage3.lang" };
var state = {
show: { win: 1, ctx: 1, today: 1, turn: 1, sub: 1, tools: 1 },
ctxOv: "", data: null,
lang: "zh",
};
/* ---------- i18n(v57):L(中文, English) 内联双语文案,语言存 localStorage ---------- */
function L(zh, en) { return state.lang === "en" ? en : zh; }
function ls(k, d) { try { return localStorage.getItem(k) ?? d; } catch (e) { return d; } }
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) { } }
try {
var s0 = JSON.parse(ls(LS.show, "null"));
if (s0 && typeof s0 === "object") {
for (var k0 in state.show) if (k0 in s0) state.show[k0] = s0[k0] ? 1 : 0;
}
state.ctxOv = ls(LS.ctxOv, "");
if (ls(LS.lang, "") === "en") state.lang = "en";
} catch (e) { }
function persist() {
lsSet(LS.show, JSON.stringify(state.show));
lsSet(LS.ctxOv, state.ctxOv);
lsSet(LS.lang, state.lang);
}
/* ---------- 多实例注册表(v61.1):每个分屏 pane 一个条实例 ---------- */
var instances = []; // spawnInstance 返回的 api:{pid, attach, render, ...}
var nextSlot = 0; // 实例序号;0 号保留兼容 id(#zusage-bar,泵热更新按它清理)
function renderAll(rebuildPanels) {
for (var i = 0; i < instances.length; i++) {
if (rebuildPanels) instances[i].rebuild(); // 语言切换等文案重建(各实例面板独立 DOM)
if (state.data) instances[i].render(state.data);
}
}
window.__zusageUpdate = function (d) {
if (stale()) return;
state.data = d;
for (var i = 0; i < instances.length; i++) {
try { instances[i].render(d); } catch (e) { FATAL.updateErr = String((e && e.stack) || e); }
}
};
function refreshWants() {
/* 各 pane 会话 id 汇总给泵:__zusageWantSids(新泵按数组强制纳入快照),
* __zusageWantSid 保底兼容旧泵单值通路(取 0 号实例的会话) */
try {
var arr = [];
for (var i = 0; i < instances.length; i++) {
var s1 = instances[i].getSid();
if (s1 && arr.indexOf(s1) < 0) arr.push(s1);
}
window.__zusageWantSids = arr;
if (arr.length) window.__zusageWantSid = arr[0];
} catch (e) { }
}
setTimeout(function () {
try { coordinator(); } catch (e) {
FATAL.fatal = String((e && e.stack) || e);
var b = document.getElementById("zusage-bar");
if (b) b.style.cssText = "position:fixed;right:16px;bottom:16px;font:12px monospace;color:#ff7a59;" +
"background:#1a0f0d;border:1px solid #ff7a59;border-radius:6px;padding:4px 10px;z-index:2147483647";
}
}, 0);
function coordinator() {
/* 协调器(v61.1):扫描主文档里全部下半屏可见输入框,按 pane 键(data-pane-id,
* 无属性回退 "auto")分组派发给实例;一个 pane 一条,pane 消失对应条自藏。 */
function paneKeyOf(el) {
for (var p = el; p && p !== document.body; p = p.parentElement) {
if (p.hasAttribute && p.hasAttribute("data-pane-id")) return p.getAttribute("data-pane-id") || "auto";
}
return "auto";
}
function sessIdOf(el) {
for (var p = el; p && p !== document.body; p = p.parentElement) {
if (p.hasAttribute && p.hasAttribute("data-session-id")) return p.getAttribute("data-session-id") || "";
}
return "";
}
var SEL = 'textarea, [contenteditable="true"], [role="textbox"], .ProseMirror, .ql-editor';
function tick() {
if (stale()) return;
try {
var best = {};
document.querySelectorAll(SEL).forEach(function (el) {
if (el.closest && el.closest('[role="dialog"]')) return; // v62:弹窗内的编辑框不是聊天输入框,不派实例(issue #9)
var r = el.getBoundingClientRect();
if (!(r.width > 40 && r.height > 8 && r.bottom > 0 && r.top < innerHeight)) return;
if (r.top < innerHeight * 0.45) return; // 聊天输入框特征:视口下半部(排除设置页等处元素)
var k = paneKeyOf(el);
if (!best[k] || r.top > best[k].top) best[k] = { el: el, top: r.top };
});
var keys = Object.keys(best);
if (!keys.length && !instances.length) best["auto"] = { el: null, top: 0 }; // 主文档找不到(shadow/iframe 场景)→ 0 号实例走自带 deepFind 兜底
keys.forEach(function (k) {
var inst = null;
for (var i = 0; i < instances.length; i++) if (instances[i].pid === k) { inst = instances[i]; break; }
if (!inst && instances.length < 4) inst = spawnInstance(k, nextSlot++);
if (inst) inst.attach(best[k].el, sessIdOf(best[k].el));
});
instances.forEach(function (inst) {
if (keys.indexOf(inst.pid) < 0) inst.attach(null, ""); // pane 关闭:条随 composer 缺失自藏,实例保留待 pane 重开复用
});
FATAL.insts = instances.map(function (a) { return a.pid + "=" + (a.getSid() ? a.getSid().slice(-8) : "-"); }).join(" | ");
} catch (e) { FATAL.coordErr = String((e && e.stack) || e); }
}
tick();
setInterval(tick, 600);
}
/* ---------- 实例工厂(v61.1):一个 pane 一套 条/面板/气泡/tooltip/定位循环。
* slot 0 保留 #zusage-bar/#zusage-tip 兼容 id(泵热更新按它清理),其余实例仅类名。 */
function spawnInstance(pid, slot) {
var VERSION = "v62"; // v62:模态弹窗(模型配置等)打开时整体收起 + 弹窗内编辑框不再被当输入框(issue #9);零值会话的来源提示(issue #14) // 随提交递增(悬停 ⚙ 面板可见);未提交的中间迭代不涨号
/* ---------- 状态(实例级) ---------- */
var excOn = false, // 本 pane 会话处于"上下文超限被拒"状态(render 时按 picked 会话计算)
excGone = false, // 用户点 ✕ 关闭了本次气泡;超限解除后自动复位
nativeCtxVal = 0, // 本 pane 输入框卡片上读到的原生上下文总量
lastSub = null, // 子代理明细面板数据源
instSid = "", // 本 pane 当前会话(composer 祖先链 data-session-id)
pendingSid = "", wantedEl = null; // 协调器派发的最新 composer/会话
/* ---------- DOM ---------- */
/* 样式表挂在 bar 内部而非 head:React 可能清理 head 里的外来 style,
* CSS 一旦失效条就退化成 static 占位元素("输入框下方空白"的历史根因)。 */
var style = document.createElement("style");
style.textContent =
/* 胶囊条:半透明底 + 微边框。配色全量跟随客户端主题(v58):颜色一律
* var(--color-*, 旧值兜底) 引用客户端语义变量(<html> 的 .dark 类翻转整套值,
* zai-light/zai-dark 特殊主题同样自动适配);color-mix 处加同值前置声明,
* 不支持 color-mix 的旧引擎落到实色行不至于透明。 */
".zusage-root{position:fixed;font:14px/1.3 Consolas,'Cascadia Mono',Menlo,'Microsoft YaHei UI','Microsoft YaHei',monospace;" +
"font-variant-numeric:tabular-nums;color:var(--color-foreground-subtle,#8791a3);" +
"background:rgba(15,18,25,.86);background:color-mix(in srgb,var(--color-background,#0f1219) 86%,transparent);" +
"backdrop-filter:blur(14px) saturate(1.3);-webkit-backdrop-filter:blur(14px) saturate(1.3);" +
"border:1px solid var(--color-border,rgba(255,255,255,.07));border-radius:9px;padding:2px 6px;user-select:none;" +
"box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 4px 14px rgba(0,0,0,.38),0 1px 3px rgba(0,0,0,.28);" +
"display:flex;align-items:center;gap:2px;white-space:nowrap;" +
/* 条不能 overflow:hidden:设置面板是条的子元素、展开在条外上方,裁剪会吞掉面板(v15"点设置看不到窗口"根因)。
* 超宽截断由 #zu-main 自己负责。 */
"z-index:50}" +
".zu-main{overflow:hidden;min-width:0;flex:0 1 auto;display:flex;align-items:center;gap:1px}" +
/* zu-main 不得带 .it class:querySelectorAll('.it') 收集 tooltip 时会把它算进第 0 位,
* tips 全部错位一位且末项为 null —— v31"悬停只有空胶囊/内容错位"的根因 */
".it{display:flex;align-items:center;gap:4px;padding:2px 6px;border-radius:6px;" +
"transition:background-color .12s ease-out}" +
".it:hover{background:var(--color-hover,rgba(255,255,255,.06))}" +
".sep{width:1px;height:15px;background:var(--color-border,rgba(255,255,255,.09));flex:0 0 auto;margin:0 1px}" +
".k{color:#7e8899}.v{color:var(--color-foreground,#e9edf4);font-weight:600}" +
".pct{font-weight:700}.dim{color:var(--color-foreground-subtle,#667082)}" +
/* 三档状态色刻意不走客户端变量(v58.1):terminal-bright-* 是语法高亮色板,
* 绿档在条面小字号/细进度条上发白(用户实测);改用高对比自调档,
* 浅色加深版在 .zu-light 组。 */
".ok{color:#3ecf8e}.warm{color:#f5b944}.hot{color:#ff6b57}" +
/* 超限=亮红呼吸闪烁(v38 定性"亮红闪烁优先于一切档位",v54 落成动画) */
"@keyframes zuexc{0%,100%{opacity:1}50%{opacity:.4}}" +
".exc{color:var(--color-destructive,#ff2d55);text-shadow:0 0 8px rgba(255,45,85,.5);animation:zuexc 1.1s ease-in-out infinite}" +
".btn{cursor:pointer;padding:2px 6px;border-radius:6px;color:var(--color-foreground-subtle,#7e8899);" +
"transition:background-color .12s ease-out,color .12s ease-out}" +
".btn:hover{color:var(--color-foreground,#e9edf4);background:var(--color-hover,rgba(255,255,255,.07))}" +
".btn:active{transform:scale(.96)}" +
".zu-gear{flex:0 0 auto;font-size:17px;border-radius:6px}" +
/* 行内 SVG 线性图标(v54):currentColor 跟随文字色,一处定义全局换色 */
".ico{width:12px;height:12px;flex:0 0 auto;color:var(--color-foreground-subtle,#7e8899);opacity:.85}" +
/* 工具错误数徽标 */
".eb{background:rgba(255,107,87,.14);background:color-mix(in srgb,var(--color-destructive,#ff6b57) 14%,transparent);" +
"color:var(--color-destructive,#ff8a73);border-radius:999px;padding:0 6px;line-height:16px;font-weight:600;display:inline-flex;align-items:center;gap:2px}" +
".eb .ico{color:inherit}" +
/* 上下文微进度条:量感一眼可读,填充色随占比三档。
* 填充块 background:currentColor —— 三档色类只给 color,填充靠 currentColor 着色
* (v28 只写了 height 没写背景,填充块全透明 = "空条"根因)。 */
".cbar{display:inline-block;width:46px;height:5px;border-radius:999px;" +
"background:var(--color-hover,rgba(255,255,255,.1));overflow:hidden;flex:0 0 auto}" +
".cbar>i{display:block;height:100%;border-radius:999px;background:currentColor}" +
/* 子代理运行中呼吸灯 */
"@keyframes zupulse{0%,100%{opacity:1}50%{opacity:.2}}" +
".dot{animation:zupulse 1.6s ease-in-out infinite;font-size:14px;line-height:1}" +
".panel{position:absolute;bottom:calc(100% + 10px);left:0;background:rgba(19,22,30,.97);" +
"background:color-mix(in srgb,var(--color-card,#13161e) 96%,transparent);" +
"backdrop-filter:blur(18px) saturate(1.3);-webkit-backdrop-filter:blur(18px) saturate(1.3);" +
"border:1px solid var(--color-border,rgba(255,255,255,.09));border-radius:12px;padding:12px 14px;display:none;" +
"flex-direction:column;gap:4px;font:13px/1.6 Consolas,Menlo,'Microsoft YaHei UI',monospace;color:var(--color-foreground,#c6cdd9);" +
"box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 12px 32px rgba(0,0,0,.5),0 2px 8px rgba(0,0,0,.35);" +
"min-width:280px;max-width:480px;max-height:72vh;overflow:auto;white-space:normal;z-index:2147483647;" +
"scrollbar-width:thin;scrollbar-color:var(--color-border,rgba(255,255,255,.14)) transparent}" +
".panel.open{display:flex}" +
".panel::-webkit-scrollbar{width:8px}" +
".panel::-webkit-scrollbar-thumb{background:var(--color-border,rgba(255,255,255,.14));border-radius:999px}" +
".panel label{display:flex;align-items:flex-start;gap:8px;cursor:pointer;padding:5px 8px;margin:0 -8px;" +
"border-radius:8px;transition:background-color .12s ease-out}" +
".panel input[type=text]{width:110px;background:var(--color-input,rgba(0,0,0,.3));border:1px solid var(--color-border,rgba(255,255,255,.14));" +
"color:var(--color-foreground,#e9edf4);border-radius:6px;padding:2px 7px;font:inherit;outline:none}" +
".panel input[type=text]:focus{border-color:var(--color-brand,#57c7ff);box-shadow:0 0 0 2px rgba(87,199,255,.15)}" +
".panel .hr{border-top:1px solid var(--color-border,rgba(255,255,255,.08));margin:5px 0}" +
".panel .cap{color:var(--color-foreground-subtlest,#6b7484);margin:4px 0 2px;font-size:12px;letter-spacing:.08em}" +
".panel .phead{font-weight:700;color:var(--color-foreground,#eef2f8);font-size:16px;margin-bottom:5px;display:flex;align-items:center;gap:8px}" +
".panel .pver{color:var(--color-brand,#57c7ff);font-weight:400;font-size:12px;background:rgba(87,199,255,.12);background:color-mix(in srgb,var(--color-brand,#57c7ff) 12%,transparent);border-radius:999px;padding:1px 8px}" +
".panel label em{font-style:normal;color:var(--color-foreground-subtle,#6f7989);display:block}" +
".panel input[type=checkbox]{accent-color:var(--color-brand,#57c7ff);margin-top:3px}" +
".panel .pnote{line-height:1.6;color:var(--color-foreground-subtle,#6f7989);margin-top:3px}" +
/* 子代理明细面板(v49):点击条目弹出的固定面板(与设置面板同机制,互斥打开);
* 面板不随鼠标消失,绕开悬停+tab 的全部几何问题。 */
".zu-sub{cursor:pointer}" +
".zu-sub:hover .v{color:var(--color-foreground,#fff)}" +
".panel.subp{width:420px;max-width:60vw;gap:8px}" +
".subrow{padding:8px 10px;background:var(--color-surface,rgba(255,255,255,.03));border:1px solid var(--color-border,rgba(255,255,255,.06));border-radius:8px}" +
".subname{font-weight:600;color:var(--color-foreground,#e9edf4);font-size:14px;overflow-wrap:anywhere}" +
".substat{color:var(--color-foreground-subtle,#8791a3);margin-top:2px}" +
".sublive{color:#3ecf8e;font-size:12px;font-weight:400;background:rgba(62,207,142,.12);border-radius:999px;padding:0 7px;margin-left:7px}" +
/* 自绘 tooltip(v31):向上弹出(原生 title 方向不可控且会被窗口下缘遮挡),
* 支持多 tab;white-space:pre-line 保留数据里的 \n 换行。
* v32:fixed 挂 body —— 原 absolute 挂 bar,被页面消息流的层叠上下文盖住
* (diag 实证 disp=block 但不可见),挂 body 用视口坐标独立定位。 */
".tip{position:fixed;background:rgba(19,22,30,.98);background:color-mix(in srgb,var(--color-background,#13161e) 98%,transparent);" +
"backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);" +
"border:1px solid var(--color-border,rgba(255,255,255,.1));border-radius:10px;padding:8px 12px;" +
"font:13px/1.6 Consolas,'Microsoft YaHei UI',monospace;color:var(--color-foreground,#c6cdd9);" +
"box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 10px 28px rgba(0,0,0,.5),0 2px 6px rgba(0,0,0,.3);" +
"white-space:pre-line;z-index:2147483646;max-width:560px;display:none;scrollbar-width:thin}" +
".ttabs{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:7px}" +
".ttab{cursor:pointer;padding:2px 10px;border-radius:999px;background:var(--color-hover,rgba(255,255,255,.06));" +
"color:var(--color-foreground-subtle,#8791a3);white-space:nowrap;font-size:13px;" +
"transition:background-color .12s ease-out,color .12s ease-out}" +
".ttab:hover{color:var(--color-foreground,#e9edf4);background:rgba(255,255,255,.1);background:color-mix(in srgb,var(--color-foreground,#fff) 10%,transparent)}" +
".ttab.on{background:rgba(87,199,255,.16);background:color-mix(in srgb,var(--color-brand,#5ac8ff) 16%,transparent);color:var(--color-brand,#5ac8ff)}" +
".tbody{max-height:60vh;overflow:auto;scrollbar-width:thin}" +
".tbody::-webkit-scrollbar{width:8px}" +
".tbody::-webkit-scrollbar-thumb{background:var(--color-border,rgba(255,255,255,.14));border-radius:999px}" +
/* 超限告警气泡(v39):挂 body 的独立浮层(与 .tip 同套路,免受条面重建影响),
* 红边警示 + 建议步骤 + 可点击复制的会话 ID;user-select:text 允许手动选中兜底。
* 底色 = 客户端 destructive 掺背景色:深色下深红黑、浅色下淡粉,自动适配。 */
".zusage-exc{position:fixed;max-width:470px;" +
"background:linear-gradient(180deg,rgba(41,18,22,.98),rgba(27,14,16,.98));" +
"background:linear-gradient(180deg,color-mix(in srgb,var(--color-destructive,#ff2d55) 10%,var(--color-background,#1b0e10)),color-mix(in srgb,var(--color-destructive,#ff2d55) 5%,var(--color-background,#1b0e10)));" +
"border:1px solid rgba(255,45,85,.4);border-color:color-mix(in srgb,var(--color-destructive,#ff2d55) 40%,transparent);" +
"border-radius:12px;padding:12px 15px;" +
"font:13px/1.7 Consolas,'Microsoft YaHei UI',monospace;color:var(--color-foreground,#e9edf4);" +
"box-shadow:0 12px 32px rgba(0,0,0,.5),0 4px 18px rgba(255,45,85,.12);white-space:normal;z-index:2147483647;" +
"user-select:text;display:none}" +
".zusage-exc .xb-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}" +
".zusage-exc .xb-title{font-weight:700;color:var(--color-destructive,#ff5c77)}" +
".zusage-exc .xb-step{color:var(--color-foreground-subtle,#c6cdd9)}" +
".zusage-exc .xb-close{cursor:pointer;color:var(--color-foreground-subtlest,#8791a5);padding:2px 5px;border-radius:6px;font-size:16px;line-height:1}" +
".zusage-exc .xb-close:hover{color:var(--color-foreground,#fff);background:var(--color-hover,rgba(255,255,255,.08))}" +
".zusage-exc .xb-sid{color:var(--color-brand,#57c7ff);cursor:pointer;text-decoration:underline dotted}" +
".zusage-exc .xb-sid:hover{text-decoration-style:solid}" +
".zusage-exc .xb-copied{color:#3ecf8e;margin-left:6px;display:none}" +
/* 白色主题程度性覆盖(v58.1):颜色已全量走客户端变量随 .dark 自动翻转,
* 这里只兜两类 —— 程度差(浅色下阴影/内高光减重、红字光晕收掉)与
* 三档状态色加深版(高对比自调档,见 .ok 注释)。 */
".zusage-root.zu-light{box-shadow:inset 0 1px 0 rgba(255,255,255,.85),0 4px 14px rgba(15,23,42,.12),0 1px 3px rgba(15,23,42,.08)}" +
".zu-light .panel{box-shadow:inset 0 1px 0 rgba(255,255,255,.9),0 12px 32px rgba(15,23,42,.14),0 2px 8px rgba(15,23,42,.08)}" +
".tip.zu-light{box-shadow:inset 0 1px 0 rgba(255,255,255,.9),0 10px 28px rgba(15,23,42,.16),0 2px 6px rgba(15,23,42,.08)}" +
".zusage-exc.zu-light{box-shadow:0 12px 32px rgba(15,23,42,.14),0 4px 18px rgba(220,38,38,.1)}" +
".zu-light .exc{text-shadow:none}" +
".zu-light .ok{color:#0f9d6c}.zu-light .warm{color:#b6791a}.zu-light .hot{color:#d8482f}" +
".zu-light .sublive{color:#0c8a63;background:rgba(15,140,102,.12)}" +
".zu-light .xb-copied{color:#0c8a63}" +
/* 减少动态偏好:关闭呼吸/闪烁与悬浮过渡 */
"@media (prefers-reduced-motion:reduce){.exc,.dot{animation:none}" +
".it,.btn,.ttab,.panel label{transition:none}}";
/* 结构:文字项 + ⚙ 紧跟其后(无弹性空隙,不再推到最右);面板绝对定位向上展开 */
var bar = document.createElement("div");
bar.className = "zusage-root";
if (slot === 0) bar.id = "zusage-bar";
bar.style.display = "none"; // 定位成功前不显示(避免占位)
bar.innerHTML = '<span class="zu-main">…</span>' +
'<span class="btn zu-gear">⚙</span>';
var panel = document.createElement("div");
panel.className = "panel";
/* 面板文案可随语言重建(事件走 panel 级委托,重建不丢监听) */
function buildPanel() {
panel.innerHTML =
'<div class="phead">⚙ ' + L("状态条设置", "Status bar settings") + '<span class="pver">' + VERSION + '</span></div>' +
'<div class="cap">' + L("显示项(条面)", "Display items (bar)") + '</div>' +
'<label><input type="checkbox" data-k="ctx"><span>' + L("上下文", "Context") + '<em>' + L("进度条 + 百分比,颜色随占比变化", "Progress bar + percentage, color by usage") + '</em></span></label>' +
'<label><input type="checkbox" data-k="turn"><span>' + L("本轮", "Current turn") + '<em>' + L("tokens / 次数 / 单次耗时 / 首字", "tokens / requests / duration / first token") + '</em></span></label>' +
'<label><input type="checkbox" data-k="win"><span>' + L("会话累计", "Session total") + '<em>' + L("当前会话 tokens / 轮数 / 次数", "session tokens / turns / requests") + '</em></span></label>' +
'<label><input type="checkbox" data-k="tools"><span>' + L("工具调用", "Tool calls") + '<em>' + L("当前会话,悬停看各工具明细与错误", "per-session, hover for per-tool details and errors") + '</em></span></label>' +
'<label><input type="checkbox" data-k="today"><span>' + L("今日合计", "Today's total") + '<em>' + L("今天所有会话的消耗", "consumption of all sessions today") + '</em></span></label>' +
'<label><input type="checkbox" data-k="sub"><span>' + L("子代理", "Sub-agents") + '<em>' + L("后台子代理消耗,点击条目看明细面板", "background sub-agent usage, click the item for the detail panel") + '</em></span></label>' +
'<div class="hr"></div>' +
'<div class="cap">' + L("上下文窗口", "Context window") + '</div>' +
'<label><input type="text" class="zu-ctxov" placeholder="' + L("自动(按模型)", "auto") + '"><span class="dim">' + L("留空 = 自动(原生UI > 模型目录)", "leave empty = auto (native UI > model catalog)") + '</span></label>' +
'<div class="hr"></div>' +
'<div class="cap">Language / 语言</div>' +
'<label><input type="radio" name="zu-lang" value="zh"><span>中文</span></label>' +
'<label><input type="radio" name="zu-lang" value="en"><span>English</span></label>' +
'<div class="hr"></div>' +
'<div class="pnote">' + L("悬停条面各项看明细;点击子代理项看全部明细面板。数据源 ~/.zcode/cli/db/db.sqlite(只读),请求完成后才落库,数值随上次完成请求变化。",
"Hover bar items for details; click the sub-agent item for the full detail panel. Data source ~/.zcode/cli/db/db.sqlite (read-only); rows are written when requests complete, numbers follow the latest completed request.") + '</div>';
panel.querySelectorAll('input[name=zu-lang]').forEach(function (r) {
r.checked = state.lang === r.value;
});
/* 重建后回填全部控件状态(语言切换路径不走 syncPanel):checkbox 按 state.show、
上下文窗口输入框按 state.ctxOv——否则切语言后勾选态清空、首次点击语义反转 */
panel.querySelectorAll("input[type=checkbox]").forEach(function (cb) {
cb.checked = !!state.show[cb.dataset.k];
});
var ovInput = panel.querySelector(".zu-ctxov");
if (ovInput) ovInput.value = state.ctxOv;
}
buildPanel();
bar.appendChild(panel);
/* 子代理明细面板(v49):与设置面板同款 .panel 机制,点击条目开关,互斥打开 */
var subPanel = document.createElement("div");
subPanel.className = "panel subp";
bar.appendChild(subPanel);
/* 自绘 tooltip:向上弹出(v45 起配空中走廊,移入点击不再被移开即隐掐断);取代原生 title(方向不可控,在窗口底边会朝下被遮挡) */
/* 自绘 tooltip 挂 body(fixed 视口坐标):挂 bar 内会被消息流的层叠上下文盖住(v31 实证) */
var tip = document.createElement("div");
tip.className = "tip zusage-tip-root";
if (slot === 0) tip.id = "zusage-tip";
tip.style.display = "none";
document.body.appendChild(tip);
bar.appendChild(style); // 样式随条走,不进 head(防清理)
/* 内联兜底:即使样式表意外失效,定位行为也不退化。
* z-index 50:压得过聊天列表/输入框外层容器(Tailwind z-20),又不盖权限菜单等弹层(实测值)。 */
bar.style.position = "fixed";
bar.style.zIndex = "50";
document.body.appendChild(bar);
/* ---------- 超限告警气泡(v39):当前会话"上下文超限被拒"时自动弹出,随条定位 ---------- */
var excBubble = document.createElement("div");
excBubble.className = "zusage-exc";
/* 热更新防重:上一实例的气泡可能残留(收尾清理只删 bar/tip),先移除旧的再挂新的。
* 仅 0 号执行——后 spawn 的实例会删掉先 spawn 实例的气泡 DOM(多实例 v61.1)。 */
if (slot === 0) {
try {
document.querySelectorAll(".zusage-exc").forEach(function (el) { if (el !== excBubble) el.remove(); });
} catch (e) { }
}
/* 文案只进 textContent/静态 innerHTML;会话 ID 是动态数据,一律走 textContent 防注入。
* 面板语言切换时 rebuildExcBubble() 重建气泡文案。 */
var excSid, excCopied;
function rebuildExcBubble() {
excBubble.innerHTML =
'<div class="xb-head"><span class="xb-title">' + L("⚠ 上下文超限", "⚠ Context over limit") + '</span>' +
'<span class="xb-close" aria-label="' + L("关闭,本次不再提醒", "Close; do not remind again this session") + '">✕</span></div>' +
'<div class="xb-step">' + L("最近一次请求因超出上下文窗口容量被拒绝,本轮对话暂时无法继续。建议依次尝试:",
"The latest request was rejected for exceeding the context window; this turn cannot continue for now. Try these in order:") + '</div>' +
'<div class="xb-step">' + L("① 回滚上一轮对话,去掉超限的那次请求后继续;",
"① Roll back the previous turn, drop the over-limit request and continue;") + '</div>' +
'<div class="xb-step">' + L("② 换用上下文窗口更大的模型继续,或压缩 / 精简本会话;",
"② Continue with a larger-window model, or compress / trim this session;") + '</div>' +
'<div class="xb-step">' + L("③ 仍无法解决时,新开一个对话,把下面的会话 ID(必要时连同工作区路径)发给它,让新会话读取本会话的记录文件接手修复。",
"③ If that still fails, start a new conversation and send it the session ID below (workspace path if needed) — the new session can pick up this session's rollout files to take over.") + '</div>' +
'<div>' + L("会话 ID:", "Session ID: ") + '<span class="xb-sid"></span><span class="xb-copied">' + L("已复制", "Copied") + '</span></div>';
excSid = excBubble.querySelector(".xb-sid");
excCopied = excBubble.querySelector(".xb-copied");
excSid.addEventListener("click", onExcSidCopy);
excBubble.querySelector(".xb-close").addEventListener("click", onExcClose);
}
function onExcSidCopy() {
if (stale() || !excSid.textContent) return;
try {
var ta = document.createElement("textarea");
ta.value = excSid.textContent;
ta.style.cssText = "position:fixed;left:-9999px;top:0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
} catch (e) { }
excCopied.style.display = "inline";
clearTimeout(excCopied.timer);
excCopied.timer = setTimeout(function () { excCopied.style.display = "none"; }, 1500);
}
function onExcClose() {
if (stale()) return;
excGone = true;
syncExcBubble();
}
rebuildExcBubble();
document.body.appendChild(excBubble);
/* ---------- 主题跟随(v58):深浅由客户端根元素 .dark 类驱动 ----------
* 客户端主题切换时往 <html> 挂/摘 .dark(zai-dark 时挂;theme-zai-* 同步),整套
* CSS 变量(--color-background/foreground/border/hover/card/destructive/brand/
* terminal-* …)随级联翻转,上面的配色自动换装 —— 本块只负责观察 .dark 摘挂,
* 同步 .zu-light(仅剩阴影/光晕程度性覆盖)。观察类属性而非 matchMedia:
* 信号源与变量翻转同源,客户端强制浅色+系统深色等错位态不会劈叉。 */
var rootEl = document.documentElement;
function themeIsDark() {
try { return rootEl.classList.contains("dark"); } catch (e) { return true; }
}
var themeDark = themeIsDark();
function applyTheme() {
bar.classList.toggle("zu-light", !themeDark);
tip.classList.toggle("zu-light", !themeDark);
excBubble.classList.toggle("zu-light", !themeDark);
}
try {
new MutationObserver(function () {
var d = themeIsDark();
if (d !== themeDark) { themeDark = d; applyTheme(); }
}).observe(rootEl, { attributes: true, attributeFilter: ["class"] });
} catch (e) { }
applyTheme();
var $ = function (id) { return bar.querySelector(id); };
var main = bar.querySelector(".zu-main"), gear = bar.querySelector(".zu-gear");
/* ---------- 渲染 ---------- */
function fmt(n) {
n = n || 0;
if (n >= 1e6) return (n / 1e6).toFixed(2) + "M";
if (n >= 1e3) return (n / 1e3).toFixed(1) + "K";
return String(n);
}
function sec(ms) { return ms ? (ms / 1000).toFixed(1) + "s" : "–"; }
/* 超限判定:最近一次"上下文超限被拒"晚于最近成功请求 = 仍处于超限状态(该请求
* status=error,不进任何 completed 统计,只能这样单独检测)。html() 与气泡共用。 */
function excActive(s) {
return !!(s && s.ctx_exc > 0 && s.ctx_exc >= (s.last_at || 0));
}
function cachePct(cache, input) {
// 无原生 title:原生提示向下弹且与自绘 tooltip 双显闪烁(v33 移除),定义见各项 tooltip 明细
return input > 0 ? '<span class="dim">' + Math.round(cache / input * 100) + "%</span>" : "";
}
/* 显示顺序:本轮 → 上下文 → 会话 → 工具 → 今日 → 子代理;token 后带缓存命中率。
* v31:条面只放主数值;悬停改自绘 tooltip(向上弹),随渲染以 tips[]
* 按 .it 顺序挂到元素 __tip,全部为字符串单页({tabs:[...]} 多 tab 机制 v49 起
* 无条目使用已删除:子代理改点击面板,tooltip 不再承载 tab 切换)。
* 缓存写入/思考/重试/错误均 >0 才显示,0 时不产生噪音。 */
function fmtTime(ms) {
var d = ms ? new Date(ms) : null;
return d ? (d.getHours() < 10 ? "0" : "") + d.getHours() + ":" +
(d.getMinutes() < 10 ? "0" : "") + d.getMinutes() + ":" +
(d.getSeconds() < 10 ? "0" : "") + d.getSeconds() : "?";
}
function ioc(inp, out, cache, rea, cw) { // 悬停明细行:输入/输出/缓存命中/缓存写入/思考
return L("输入 ", "Input ") + fmt(inp) + L(" / 输出 ", " / Output ") + fmt(out) + L(" / 缓存命中 ", " / Cache read ") + fmt(cache) +
(cw > 0 ? L(" / 缓存写入 ", " / Cache write ") + fmt(cw) : "") +
(rea > 0 ? L(" / 思考 ", " / Thinking ") + fmt(rea) : "");
}
/* 行内 SVG 线性图标(v54):stroke 走 currentColor,颜色由 .ico 控制统一换色 */
function ico(paths) {
return '<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + paths + "</svg>";
}
function html(d) {
var sess = d.session || {}, lt = d.last_turn || {}, today = d.today || {}, last = d.last || {};
var tls = d.tools || { total: 0, errors: 0, list: [] };
var items = [], tips = [];
function it(inner, tip, cls) { items.push('<span class="it' + (cls ? " " + cls : "") + '">' + inner + "</span>"); tips.push(tip || null); }
if (d.remote) { // v9:SSH 远程会话(数据取自远端机),置顶徽标标明数据源
it(ico('<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"/>') +
'<span class="dim">' + L("远端", "remote") + "</span>",
L("数据源:SSH 远程服务器 ", "Data source: remote server over SSH ") + (d.remote_host || "?") + "\n" +
L("会话数据实时取自远端机的 ZCode 数据库,「今日合计」亦为远端值", "Session data is fetched live from the remote machine's ZCode database; today's total is the remote value") +
(d.remote_error ? "\n⚠ " + L("最近一次远端查询失败:", "Last remote query failed: ") + d.remote_error : "") +
(d.remote_empty ? "\n⚠ " + L("远端库里这个会话还没有已完成的请求(各项为 0)——确认远端 zusage.py 读到的库与远端 ZCode 实际写入的库是同一个",
"The remote database has no completed requests for this session (all items are 0) — check that the remote zusage.py reads the same database the remote ZCode actually writes") : ""));
}
if (d.not_local) { // v62(issue #14):本地库没有这个会话 —— 全 0 不是插件坏了,是数据不在这儿
it(ico('<circle cx="12" cy="12" r="9"/><path d="M12 16h.01"/><path d="M12 8h.01"/>') +
'<span class="dim">' + L("不在本地库", "not in local db") + "</span>",
L("这个会话不在本地数据库里(~/.zcode/cli/db/db.sqlite 查不到它),所以各项显示 0 —— 不是插件坏了。",
"This session is not in the local database (~/.zcode/cli/db/db.sqlite), which is why every item reads 0 — the plugin is not broken.") + "\n" +
(d.remote_enabled
? L("已配置 remote 段,但远端查询没有返回它:", "A remote section is configured, but the remote query did not return it: ")
+ (d.remote_error ? L("查询失败 ", "query failed: ") + d.remote_error
: L("远端库里也没有这个会话(核对远端 zusage.py 读的库/用户与远端 ZCode 实际写入的是否一致)",
"the remote database does not have it either (verify the database/user the remote zusage.py reads matches what the remote ZCode writes)"))
: L("若这是 SSH 远程 / 他机会话:在 config.json 配置 remote 段即可(README「SSH 远程会话支持」;ssh 也可写成 argv 数组,docker exec / wsl / kubectl exec 等通道都能借道)",
"If this is an SSH/remote session, add the remote section to config.json (see README; ssh may also be an argv array, so docker exec / wsl / kubectl exec all work)")));
}
if (last.tps) { // 最近一次请求的生成速度,置顶显示;随数值三档变色
var tpsCls = last.tps >= 70 ? "ok" : last.tps >= 40 ? "warm" : "hot";
it(ico('<path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/>') +
'<span class="' + tpsCls + '">' + last.tps + '</span><span class="dim">t/s</span>',
L("生成速度:最近完成请求的输出 tokens ÷ 生成耗时(首 token → 完成)\n≥70 t/s 绿色 · 40–70 黄色 · <40 红色",
"Generation speed: output tokens ÷ generation time of the latest completed request (first token → completion)\n≥70 t/s green · 40–70 yellow · <40 red"));
}
if (state.show.ctx) {
var cw = parseInt(state.ctxOv, 10) || d.native_ctx || d.context_window || 0;
var pct = cw ? sess.ctx / cw * 100 : 0;
var exc = excActive(sess);
/* 占比档位分两套:窗口 ≥100 万时同一百分比的绝对 token 量大,40/60 提前预警;其余维持 70/85。 */
var big = cw >= 1000000;
var cls = exc ? "exc" : pct >= (big ? 60 : 85) ? "hot" : pct >= (big ? 40 : 70) ? "warm" : "ok";
var bar = cw ? '<span class="cbar"><i class="' + cls + '" style="width:' +
Math.min(100, pct).toFixed(1) + '%"></i></span>' : "";
it(bar + (cw ? '<span class="pct ' + cls + '">' + pct.toFixed(1) + "%</span>"
: '<span class="v' + (exc ? " exc" : "") + '">' + fmt(sess.ctx) + "</span>"),
L("上下文:当前会话上下文大小(最近一次请求的总输入)÷ 窗口容量",
"Context: session context size (total input of the latest request) ÷ window capacity") + "\n" +
L("已用 ", "Used ") + fmt(sess.ctx) + L(" / 窗口 ", " / window ") + fmt(cw) +
"\n" + L("颜色随占比:", "Color by usage: ") + (big
? L("≤40% 绿 · 40–60% 黄 · ≥60% 红(窗口 ≥100 万)", "≤40% green · 40–60% yellow · ≥60% red (windows ≥1M)")
: L("<70% 绿 · 70–85% 黄 · ≥85% 红", "<70% green · 70–85% yellow · ≥85% red")) +
L(" · 超限被拒=亮红闪烁", " · rejected over-limit = flashing red") +
(exc ? "\n" + L("⚠ 上下文超限:最近一次请求超出窗口容量被拒绝(", "⚠ Context over limit: the latest request was rejected for exceeding the window capacity (") +
fmtTime(d.ctx_exc) + L("),需要压缩会话或新开会话", ") — compress the session or start a new one") : ""));
}
if (state.show.turn) {
it(ico('<path d="M23 4v6h-6"/><path d="M20.49 15A9 9 0 1 1 18.36 5.64L23 10"/>') +
'<span class="v">' + fmt(lt.total) + "</span>" +
cachePct(lt.cache_read, lt.input) +
'<span class="dim">' + (lt.requests || 0) + L("次", " req") + "</span>" +
ico('<path d="M6 3h12M6 21h12M8 3v3.5L12 11l4-4.5V3M8 21v-3.5L12 13l4 4.5V21"/>') +
'<span class="dim">' + sec(last.duration_ms) + "</span>" +
ico('<path d="M5 20v-5M12 20v-9M19 20V5"/>') +
'<span class="dim">' + sec(last.ttft_ms) + "</span>",
L("本轮:最近一轮的 token 消耗(该轮共 ", "Turn: token usage of the latest turn (") + (lt.requests || 0) +
L(" 次模型请求)", " model requests in total)") + "\n" +
ioc(lt.input, lt.output, lt.cache_read, lt.reasoning, lt.cache_write) +
"\n" + L("单次耗时 ", "Single request ") + sec(last.duration_ms) + L(" · 首字 ", " · First token ") + sec(last.ttft_ms) +
L(" · 轮总耗时 ", " · Turn total ") + sec(lt.duration_ms) +
(lt.tool_calls ? L(" · 工具调用 ", " · Tool calls ") + lt.tool_calls : "") +
((lt.retries || lt.tool_errors) ? L(" · 重试 ", " · Retries ") + (lt.retries || 0) + L(" · 工具错误 ", " · Tool errors ") + (lt.tool_errors || 0) : ""));
}
if (state.show.win) {
it(ico('<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>') +
'<span class="v">' + fmt(sess.total) + "</span>" +
cachePct(sess.cache_read, sess.input) +
'<span class="dim">' + (sess.turns || 0) + L("轮 ", " turns · ") + (sess.requests || 0) + L("次", " req") + "</span>",
L("会话累计:当前会话全部请求的 token 消耗", "Session total: tokens of all requests in the current session") + "\n" + ioc(sess.input, sess.output, sess.cache_read, sess.reasoning, sess.cache_write) +
"\n" + (sess.turns || 0) + L(" 轮 · ", " turns · ") + (sess.requests || 0) + L(" 次请求", " requests") +
(sess.tool_calls ? L(" · 工具调用 ", " · Tool calls ") + sess.tool_calls : "") +
(sess.retries ? L(" · 重试 ", " · Retries ") + sess.retries : "") +
(d.code && (d.code.add || d.code.del) ?
"\n" + L("代码变更 +", "Code changes +") + (d.code.add || 0) + L(" / -", " / −") + (d.code.del || 0) +
(d.code.files ? L("(", " (") + d.code.files + L(" 文件)", " files)") : "") : ""));
}
if (state.show.tools) {
var toolLines = [];
(tls.list || []).forEach(function (t1) {
toolLines.push(t1.name + " " + t1.count + L("次", " calls") + " · " + sec(t1.duration_ms) +
(t1.errors ? L(" · 错 ", " · ") + t1.errors + L(" 个错误", " errors") : ""));
});
it(ico('<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>') +
'<span class="v">' + (tls.total || 0) + "</span>" +
(tls.errors ? '<span class="eb">' +
ico('<circle cx="12" cy="12" r="9"/><path d="M12 8v4M12 16h.01"/>') +
tls.errors + "</span>" : ""),
L("工具调用:当前会话的工具使用统计(按调用次数排序)", "Tool calls: tool usage stats of the current session (sorted by calls)") + "\n" +
(toolLines.length ? toolLines.join("\n") : L("无工具调用记录", "No tool calls recorded")));
}
if (state.show.today) {
it(ico('<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>') +
'<span class="v">' + fmt(today.total) + "</span>",
L("今日合计:今天所有会话的 token 消耗", "Today's total: tokens of all sessions today") + "\n" + ioc(today.input, today.output, today.cache_read, today.reasoning, today.cache_write) +
"\n" + (today.requests || 0) + L(" 次请求", " requests") + (today.retries ? L(" · 重试 ", " · Retries ") + today.retries : ""));
}
if (state.show.sub && d.sub && (d.sub.total || d.sub.active)) {
/* v50:悬停 tooltip 只放汇总 + 打开面板的提示;各子代理明细在点击弹出的
* 固定面板里用页签切换(面板固定不随鼠标,页签点击没有几何问题) */
it(ico('<path d="M6 3v12"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>') +
'<span class="v">' + fmt(d.sub.total) + "</span>" +
(d.sub.active ? '<span class="ok dot">●</span>' : ""),
L("子代理:当前会话的后台子代理消耗(独立统计,不计入会话累计)", "Sub-agents: background sub-agent usage of the current session (tracked separately, not counted in the session total)") + "\n" +
ioc(d.sub.input, d.sub.output, d.sub.cache_read, d.sub.reasoning, d.sub.cache_write) +
L(",共 ", ", ") + (d.sub.requests || 0) + L(" 次", " requests in total") + (d.sub.active ? L(" · 运行中", " · running") : "") +
"\n" + L("点击条目打开面板,查看每个子代理的具体消耗", "Click the item to open the panel with per-sub-agent details"), "zu-sub");
}
return { s: items.join('<span class="sep"></span>') || '<span class="dim">' + L("全部显示项已关闭", "All display items are off") + '</span>', tips: tips };
}
/* 当前会话识别(v34 起 mine 优先,此处为兜底启发式):ZCode 在 localStorage 的
* "zcode-v4-last-session:v1:<工作区路径>" 键里保存每个工作区当前打开的会话 id。
* 键随会话切换由应用更新(新开空会话时应用还会删键);但 localStorage 全窗口共享、
* 键对"自动新建的会话"更新滞后,多窗口下根本分不清哪个键属于本窗口 ——
* 所以 v34 起正常路径是泵按窗口注入的 mine(渲染端经 IPC 上报的焦点会话),
* 只有泵无该窗口信息时(辅助窗口/旧泵)才落到这里:取所有键值与 payload.recent
* 的 sid 求交集;多候选取最近活跃(数值 last_at 比较,勿用 HH:MM:SS 字符串,跨午夜会排错)。
* v32:键值即使不在 recent 池里也作为 want 返回(挂 window.__zusageWantSid),
* 泵把它传给 zusage.py 强制纳入快照——刚开的新会话/超出 6+6 池的会话也能显示自己的数据。
* 注意:会话标题不在可扫描的 DOM 文本节点里(顶栏标题扫描方案实测永远失败,勿回退)。 */
var pickedSid = "", wsPrev = null, lastHtml = "";
function pickCurrent(d) {
var recent = d.recent || [];
if (!recent.length) return null;
var bySid = {}, kv = {};
for (var i = 0; i < recent.length; i++) bySid[recent[i].sid] = recent[i];
try {
Object.keys(localStorage).forEach(function (k) {
if (/^zcode-v4-last-session:/.test(k)) {
var v = localStorage.getItem(k);
if (v && /^[A-Za-z0-9_-]+$/.test(v)) kv[k] = v;
}
});
} catch (e) { return null; }
if (!Object.keys(kv).length) return null;
var hit = {}, firstKeyVal = "";
for (var k in kv) {
if (bySid[kv[k]]) hit[k] = kv[k];
if (!firstKeyVal) firstKeyVal = kv[k];
}
var switched = null;
if (wsPrev) {
for (var k2 in kv) {
if (wsPrev[k2] && wsPrev[k2] !== kv[k2]) switched = kv[k2]; // 该工作区刚切换了会话
}
}
wsPrev = kv;
/* want = 当前真正打开的会话(切换优先,否则任一键值),交由泵强制补拉 */
var want = switched || firstKeyVal;
if (switched && bySid[switched]) return { sess: bySid[switched], want: want };
var cands = [];
for (var k3 in hit) cands.push(bySid[hit[k3]]);
if (cands.length === 1) return { sess: cands[0], want: want };
if (!cands.length) return { sess: null, want: want };
var best = cands[0]; // 多候选:取最近活跃的
for (var n = 1; n < cands.length; n++) {
if ((cands[n].last_at || 0) > (best.last_at || 0)) best = cands[n];
}
return { sess: best, want: want };
}
/* 子代理查看视图识别:子代理会话的任务提示词会作为聊天气泡文本出现在消息流里,
* 用 recent 中 is_sub 候选的标题前缀(前 30 字)在 DOM 文本里匹配。
* (主会话的标题不在 DOM 文本里,但子代理视图的气泡是普通文本,可匹配。) */
/* 零值会话 stub:当前会话在共享池里还没有数据行(新会话没发过消息/还没拉到)时,
* 显示"当前会话的 0 值"。绝不回退到池里别的会话 —— 显示错会话的数字正是 v34 修的 bug。 */
function stubFor(sid) {
return { sid: String(sid || ""), title: "", active: false, turns: 0, requests: 0,
input: 0, output: 0, reasoning: 0, cache_read: 0, cache_write: 0, total: 0,
tool_calls: 0, retries: 0, ctx: 0, updated: "", last_at: 0, ctx_exc: 0,
last_turn: {requests: 0, retries: 0, tool_calls: 0, tool_errors: 0, input: 0, output: 0,
reasoning: 0, cache_read: 0, cache_write: 0, total: 0, duration_ms: 0, ttft_ms: 0},
last: {duration_ms: 0, ttft_ms: 0, model: "", tps: 0},
code: {add: null, del: null, files: null},
tools: {total: 0, errors: 0, list: []},
sub: {requests: 0, total: 0, input: 0, output: 0, cache_read: 0, reasoning: 0,
cache_write: 0, active: false, list: []},
context_window: 0, context_auto: false };
}
function render(d) {
state.data = d;
var pc = null, p = null;
if (instSid) {
/* v61.1 分屏多实例:本条显示哪个会话由 composer 祖先链的 data-session-id 决定
* (客户端每个 pane 自带该标记,比窗口级 mine 更准,绝不串 pane);
* 会话不在池里(还没拉取)就显示本会话 0 值 —— 绝不回退到别的会话(v34 原则)。 */
pickedSid = instSid;
var rec = d.recent || [];
for (var i = 0; i < rec.length; i++) if (rec[i].sid === instSid) { p = rec[i]; break; }
if (!p) p = stubFor(instSid);
} else {
var mine = d.mine; // 泵按窗口注入:string=本窗口焦点会话(空串=该窗口当前无会话),undefined=泵无该窗口信息
if (typeof mine === "string") {
pickedSid = mine;
window.__zusageWantSid = mine;
if (mine) {
var rec2 = d.recent || [];
for (var j = 0; j < rec2.length; j++) if (rec2[j].sid === mine) { p = rec2[j]; break; }
if (!p) p = stubFor(mine);
} else {
p = stubFor("");
}
} else {
pc = pickCurrent(d);
p = pc ? pc.sess : null;
pickedSid = p ? p.sid : "";
window.__zusageWantSid = (pc && pc.want) || ""; // 泵读取后让 zusage.py 强制纳入该会话
if (!p) p = stubFor((pc && pc.want) || "");
}
}
excOn = excActive(p); // 气泡的显示依据(track 每帧消费)
var view = {
session: p, last_turn: p.last_turn || {}, last: p.last || {},
/* v9:远端会话的「今日合计」用远端机数据,本地 today 只在显示本地会话时使用 */
today: (p.remote && d.remote_today) ? d.remote_today : d.today,
native_ctx: nativeCtxVal,
context_window: p.context_window, context_auto: p.context_auto,
code: p.code, ctx_exc: p.ctx_exc, tools: p.tools,
remote: !!p.remote, remote_host: p.remote_host || "", remote_error: d.remote_error || "",
/* v62(issue #14 / #10):该会话不在本地库且没命中远端 → 条面给一次配置指引,
* 把"各项全 0 = 插件坏了"的误判转成可执行的下一步;远端命中的会话由 ☁ 徽标那套说明。 */
not_local: (d.not_local || []).indexOf(p.sid) >= 0 && !p.remote,
remote_enabled: !!d.remote_enabled,
remote_empty: !!(p.remote && !p.requests), // 远端答了、但远端库里该会话没有已完成请求
sub: p.sub || {requests: 0, total: 0, input: 0, output: 0, cache_read: 0, active: false, list: []},
};
lastSub = view.sub; // 子代理明细面板的数据源(开关面板/数据推送刷新用)
refreshWants();
var h = html(view);
/* 内容没变就不重建 DOM:泵每 1.5s 推送一次,无条件 innerHTML 会重建条面+重启呼吸灯
* 动画+触发 tooltip 重画 = 悬停时周期性闪烁(v33 修复)。变化时才重建。 */
if (h.s !== lastHtml) {
lastHtml = h.s;
main.innerHTML = h.s;
}
var els = main.querySelectorAll(".it");
for (var i = 0; i < els.length; i++) els[i].__tip = h.tips[i] || null;
/* 数据刷新时正在悬停:接管同位置的新元素继续显示。只调位置不重画内容——
* 数字每 1.5s 都在变,重画就是闪烁;旧文本保持到鼠标下次移动为止。 */
if (tipFor) {
var nu = null;
if (tipFor === main) nu = main;
else if (tipFor.__n === els.length) nu = els[tipFor.__idx];
if (nu && nu.__tip) { tipFor = nu; positionTip(nu, true); } else hideTip(false, "render-orphan");
/* 接管失败(条目结构变化)也受总闸保护:鼠标仍在条上时保持旧 tooltip,
* 下一次 mousemove 会重新判定该显示哪一项 */
}
if (subPanel.classList.contains("open")) buildSubPanel(view.sub); // 面板开着:随推送实时刷新
}
/* ---------- 自绘 tooltip:向上弹出(原生 title 方向不可控) ---------- */
var tipFor = null, lastMoveAt = 0, tipSig = "";
var mouseInBar = false; // 鼠标是否悬停在条面/tooltip/面板上(最近一次 mousemove 判定)
var tipGraceTimer = 0; // 越顶宽限定时器(v46)
var tipStats = { mv: 0, shows: 0, hides: 0, last: "", lastHide: "", corridor: 0, grace: 0 }; // 诊断:随 mount diag 回写
/* 隐藏总闸(v33.3):鼠标还在条面上时,任何路径(数据刷新接管失败/输入框重建/
* 瞬时不可见判定)都不得隐藏 tooltip——只有鼠标真正离开条面(left-bar,force)
* 或条面整体隐藏(force)才允许。 */
function hideTip(force, cause) {
if (!force && mouseInBar) { tipStats.lastHide = "blocked:" + (cause || "?"); return; }
if (tipGraceTimer) { clearTimeout(tipGraceTimer); tipGraceTimer = 0; }
if (tip.style.display !== "none") tip.style.display = "none";
tipFor = null;
tipSig = "";
tipStats.hides++;
tipStats.lastHide = cause || "?";
}
function positionTip(el, keepTop) {
var tr = tip.getBoundingClientRect(), ar = el.getBoundingClientRect();
var left = ar.left + ar.width / 2 - tr.width / 2;
left = Math.max(8, Math.min(left, Math.max(8, innerWidth - tr.width - 8)));
/* keepTop=数据刷新等重定位时保持顶边——悬停中只调位置不重建,顶边不动鼠标就
* 不会因盒形变化被甩出去(外面是 iframe 静默区,甩出去就回不来了)。
* 只有首次弹出才按条目重新锚定(悬空 8px)。 */
var top = (keepTop && tip.style.top) ? parseFloat(tip.style.top) : 0;
if (!isFinite(top) || top < 8) top = Math.max(8, ar.top - tr.height - 8);
tip.style.left = Math.round(left) + "px";
tip.style.top = Math.round(top) + "px";
}
function drawTip(el, keepTop) {
var t = el && el.__tip;
if (!t) { tip.style.display = "none"; tipFor = null; return; }
/* 幂等:内容没变就只调位置,不重建 innerHTML(重建=悬停中每 1.5s 闪烁) */
if (t === tipSig && tip.style.display === "block") {
positionTip(el, true);
return;
}
tipSig = t;
tip.innerHTML = '<div class="tbody"></div>';
tip.firstChild.textContent = t;
tip.style.display = "block";
positionTip(el, keepTop);
}
function showTipFor(el) {
tipFor = el;
var its = main.querySelectorAll(".it");
el.__idx = el === main ? -1 : Array.prototype.indexOf.call(its, el);
el.__n = its.length; // render 接管时校验条目数未变,数量变了 __idx 就不可信
tipStats.shows++;
tipStats.last = el === main ? "main" : String(el.textContent || "").slice(0, 12);
drawTip(el, false);
}
/* 事件挂 document 捕获阶段(v32):React/应用层可能在冒泡路上 stopPropagation,
* 挂 main 的 mousemove 收不到(v31 实证:原生 title 有 hover、自绘 tip 无事件)。
* stale() 守卫:热更新后旧实例监听器必须罢工,否则新旧两个 tip 同时工作。 */
document.addEventListener("mousemove", function (e) {
if (stale()) return;
tipStats.mv++;
if (Date.now() - lastMoveAt < 150) return;
lastMoveAt = Date.now();
var t = e.target;
if (!t || !t.closest) return;
if (tip.contains(t)) { mouseInBar = true; cancelTipGrace(); return; } // 鼠标移入 tooltip:保持
if (t.closest(".panel")) {
/* 面板上不保留条面 tooltip(v42):开设置的路上触发过条目 tooltip 的话,
* 这分支若不清掉它,鼠标在面板里它就永不消失。 */
mouseInBar = true;
if (tipFor) hideTip(true, "panel-hover");
return;
}
if (!bar.contains(t)) {
/* 空中走廊(v45):悬空缝是"条外且 tip 外"的真空带,采样事件落在这里会被误判
* 成"移开"立即熄灭——鼠标在 tooltip 横向范围、底边下方 ~12px 内时视同仍在条上,
* 穿行去 tooltip 点 tab 的路上不熄灭。 */
if (tipFor && inTipCorridor(e.clientX, e.clientY)) {
tipStats.corridor++;
mouseInBar = true;
cancelTipGrace();
return;
}
/* 越顶宽限(v46):mousemove 有 150ms 节流采样,快速上移时两个采样点之间能跨过
* 整个 tooltip,采样点落在 tip 上方的死区——此前立即隐藏就把"正要进 tip"错杀成
* "离开"。改挂 300ms 宽限:下一个采样落回 tip/走廊就取消(tip 仍可点 tab),
* 确实继续远离才真正收起。横向/向下移出不享受宽限,依旧立即消失。 */
if (tipFor && inTipColumn(e.clientX, e.clientY)) {
tipStats.grace++;
mouseInBar = true; // 视同在途:总闸拦住 render-orphan 等路径趁机熄灯
if (!tipGraceTimer) tipGraceTimer = setTimeout(function () {
tipGraceTimer = 0;
hideTip(true, "grace-expire");
}, 300);
return;
}
mouseInBar = false;
if (tipFor) hideTip(true, "left-bar"); // v43:移开条面立即消失,不再留 300ms 缓冲
return;
}
mouseInBar = true;
cancelTipGrace();
if (panel.classList.contains("open")) return; // 面板开着:条面不弹 tooltip(弹出也会被面板盖住,只露边角)
var el = t.closest(".it");
if (el) {
if (el !== tipFor) showTipFor(el);
} else if (tipFor) {
/* 条面内非条目区(项间隔/分隔符/⚙):保持当前 tooltip 不动(此处刻意无操作)。
* 慢速移动时命中目标在条目↔空隙间反复跳,若此处切换内容/隐藏
* 就是"出现一下立刻消失"的闪烁(v33.2,概览 tooltip 因此废除) */
}
}, true);
/* 鼠标甩出窗口外/窗口失焦:tooltip 立即收起(v43)。
* v47 根因修正:ZCode 条面上方的聊天区是独立浏览上下文(iframe/webview,findComposer
* 穿透同源 iframe 即佐证),光标从条面跨入其上时宿主文档会发 documentElement
* mouseleave、且后续 mousemove 全部进子文档——diag 实证(mv 上万但 corridor/grace
* 计数全 0、lastHide=win-leave)这就是"一向上移 tooltip 就消失"的根因:v43 把这种
* 内部 leave 当成了甩出窗口。故 mouseleave 按退出点分流:
* · 退出点贴窗口边缘 = 真离开窗口 → 立即收;
* · 内部 leave 且退出点在 tip 横向列内(±30px)= 去 tooltip/回条面的穿行 → 400ms
* 宽限(子上下文区域事件停摆,靠定时器兜底收回;tip/条面/走廊事件会取消它);
* · 内部 leave 列外 = 横向走掉 → 立即收。 */
function tipBoundaryHide() {
if (stale()) return;
mouseInBar = false;
if (tipFor) hideTip(true, "win-leave");
}
window.addEventListener("blur", tipBoundaryHide, true);
function tipLeaveAt(x, y) {
if (stale() || !tipFor) return;
if (x <= 1 || x >= innerWidth - 2 || y <= 1 || y >= innerHeight - 2) {
tipBoundaryHide();
return;
}
var r = tip.getBoundingClientRect();
if (x >= r.left - 30 && x <= r.right + 30) {
tipStats.grace++;
mouseInBar = true; // 在途:总闸拦住 render-orphan 等路径趁机熄灯
cancelTipGrace();
tipGraceTimer = setTimeout(function () {
tipGraceTimer = 0;
hideTip(true, "grace-expire");
}, 400);
} else {
mouseInBar = false;
hideTip(true, "left-bar");
}
}
document.documentElement.addEventListener("mouseleave", function (e) {
tipLeaveAt(e.clientX, e.clientY);
}, true);
/* 光标离开 tooltip 本体:上行越顶/下行回条面 → 列内宽限;横向 → 立即收(分流同上) */
tip.addEventListener("mouseleave", function (e) {
tipLeaveAt(e.clientX, e.clientY);
}, true);
tip.addEventListener("mouseenter", function () {
if (stale()) return;
cancelTipGrace();
}, true);
/* 空中走廊判定(v45):tooltip 矩形横向 ±4px、纵向 tip 顶边到底边下方 12px
* (覆盖条面顶边到 tooltip 底边的 8px 悬空缝)。只在 tipFor 存在时被调用。 */
function inTipCorridor(x, y) {
var r = tip.getBoundingClientRect();
return x >= r.left - 4 && x <= r.right + 4 && y >= r.top && y <= r.bottom + 12;
}
/* 越顶宽限判定(v46):tip 正上方 ±30px、高 80px 的柱形区——快速上移的采样点
* 落进这里说明鼠标刚越过 tip 顶边,大概率下一拍就停在 tip 里。 */
function inTipColumn(x, y) {
var r = tip.getBoundingClientRect();
return x >= r.left - 30 && x <= r.right + 30 && y >= r.top - 80 && y < r.top;
}
function cancelTipGrace() {
if (tipGraceTimer) { clearTimeout(tipGraceTimer); tipGraceTimer = 0; }
}
/* ---------- 设置面板 ---------- */
function syncPanel() {
panel.querySelectorAll("input[type=checkbox]").forEach(function (cb) {
cb.checked = !!state.show[cb.dataset.k];
});
panel.querySelector(".zu-ctxov").value = state.ctxOv;
}
gear.addEventListener("click", function (e) {
e.stopPropagation();
syncPanel();
var opening = !panel.classList.contains("open");
panel.classList.toggle("open");
if (opening) {
subPanel.classList.remove("open"); // 两面板同位重叠,互斥打开(v49)
hideTip(true, "gear-open"); // 移向齿轮的路上可能触发过条目 tooltip,开面板时一并收掉
}
});
panel.addEventListener("change", function (e) {
var t2 = e.target;
if (t2.name === "zu-lang") {
state.lang = t2.value;
persist();
buildPanel(); // 面板自身文案随语言重建(含 radio 勾选态)
rebuildExcBubble();
renderAll(true); // 条面单位与 tooltip 文案随语言刷新(含各实例面板文案重建)
return;
}
if (t2.type === "checkbox") {
state.show[t2.dataset.k] = t2.checked ? 1 : 0;
persist();
} else if (t2.id === "zu-ctxov") {
state.ctxOv = t2.value.trim();
persist();
}
renderAll(false);
});
document.addEventListener("mousedown", function (e) {
if (panel.classList.contains("open") && !bar.contains(e.target)) panel.classList.remove("open");
if (subPanel.classList.contains("open") && !bar.contains(e.target)) subPanel.classList.remove("open");
});
/* ---------- 子代理明细面板(v49):点击条目弹出的固定面板,替代悬停 tooltip ---------- */
var subPanelTab = 0; // 面板当前页签(0=汇总,i=第 i 个子代理);数据刷新时保持
function buildSubPanel(sub) {
var list = (sub && sub.list) || [];
if (subPanelTab > list.length) subPanelTab = 0; // 列表变短则回汇总
subPanel.innerHTML = "";
var head = document.createElement("div");
head.className = "phead";
head.textContent = L("子代理明细", "Sub-agent details");
var ver = document.createElement("span");
ver.className = "pver";
ver.textContent = L("独立统计 · 不计入会话累计", "Tracked separately · not counted in session total");
head.appendChild(ver);
subPanel.appendChild(head);
if (!sub || (!sub.total && !sub.active)) {
var empty = document.createElement("div");
empty.className = "pnote";
empty.textContent = L("当前会话没有子代理记录", "No sub-agent records in the current session");
subPanel.appendChild(empty);
return;
}
/* 页签行:汇总 + 每个子代理(复用 .ttabs/.ttab 样式);动态名走 textContent 防注入 */
var tabsRow = document.createElement("div");
tabsRow.className = "ttabs";
var labels = [L("汇总", "Summary")];
list.forEach(function (s1) {
/* task=派发时的 description(右侧"子智能体目录"同源);无则退回 title/线路名 */
var nm = String(s1.task || "").trim() || String(s1.title || "").trim() ||
String(s1.agent || "sub").replace(/^zcode-/, "") + "…" + String(s1.sid).slice(-4);
labels.push(nm.length > 12 ? nm.slice(0, 11) + "…" : nm);
});
labels.forEach(function (lb, i) {
var tb = document.createElement("span");
tb.className = "ttab" + (i === subPanelTab ? " on" : "");
tb.setAttribute("data-i", String(i));
tb.textContent = lb;
tabsRow.appendChild(tb);
});
subPanel.appendChild(tabsRow);
var body = document.createElement("div");
body.className = "tbody"; // 复用 tooltip 内容容器的限高滚动
subPanel.appendChild(body);
function fillRow(row, s1) {
var nm = String(s1.task || "").trim() || String(s1.title || "").trim() ||
String(s1.agent || "sub").replace(/^zcode-/, "") + "…" + String(s1.sid).slice(-4);
var n = document.createElement("div");
n.className = "subname";