zcode-pro 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  // “ZCode Pro 增强设置”弹窗:功能开关 + 样式调整 + 运行状态。
2
2
  // 顶部标签页切换(视觉参考侧栏“分组/项目”切换);配置写入 helper(~/.zcode/zcodepro.json)。
3
- import { h, t, rpc, getConfig, clearConfigCache, errText, HELPER_URL, isZhLocale, isNewProjectSidebar, DEFAULT_SHORTCUTS } from '../core.js';
3
+ import { h, t, rpc, getConfig, clearConfigCache, errText, HELPER_URL, isZhLocale, isNewProjectSidebar, DEFAULT_SHORTCUTS, DEFAULT_STATUS_ITEMS, DEFAULT_TURN_ITEMS, STATUS_SESSION_IDS, STATUS_GLOBAL_IDS } from '../core.js';
4
4
  import { openDialog, dialogFooter, btnPrimary, btnSecondary, btnSmall, settingRow, ensureStyle, showToast, numberField, unitField } from '../ui.js';
5
5
  import { comboField, providerOptionsOf, modelOptionsOf, modelPlaceholderOf, isManualConn } from '../provider-fields.js';
6
6
  import { refreshAliases } from './alias.js';
@@ -355,6 +355,16 @@ export function openSettingsDialog() {
355
355
  if (await setFeature('imageCopy', next)) f.imageCopy = next;
356
356
  refreshRows();
357
357
  }),
358
+ settingRow(L.featureSandboxReclaim, L.featureSandboxReclaimDesc, f.sandboxReclaim !== false, async () => {
359
+ const next = !(f.sandboxReclaim !== false);
360
+ if (await setFeature('sandboxReclaim', next)) f.sandboxReclaim = next;
361
+ refreshRows();
362
+ }),
363
+ settingRow(L.featurePluginCardRetry, L.featurePluginCardRetryDesc, f.pluginCardRetry !== false, async () => {
364
+ const next = !(f.pluginCardRetry !== false);
365
+ if (await setFeature('pluginCardRetry', next)) f.pluginCardRetry = next;
366
+ refreshRows();
367
+ }),
358
368
  settingRow(L.featureWsRunning, L.featureWsRunningDesc, f.wsRunningSpin !== false, async () => {
359
369
  const next = !(f.wsRunningSpin !== false);
360
370
  if (await setFeature('wsRunningSpin', next)) f.wsRunningSpin = next;
@@ -365,6 +375,16 @@ export function openSettingsDialog() {
365
375
  if (await setFeature('newtabPanels', next)) f.newtabPanels = next;
366
376
  refreshRows();
367
377
  }),
378
+ settingRow(L.featureStatusBar, L.featureStatusBarDesc, f.statusBar !== false, async () => {
379
+ const next = !(f.statusBar !== false);
380
+ if (await setFeature('statusBar', next)) f.statusBar = next;
381
+ refreshRows();
382
+ }),
383
+ settingRow(L.featureTurnUsage, L.featureTurnUsageDesc, f.turnUsage !== false, async () => {
384
+ const next = !(f.turnUsage !== false);
385
+ if (await setFeature('turnUsage', next)) f.turnUsage = next;
386
+ refreshRows();
387
+ }),
368
388
  (() => {
369
389
  // 标题 = “自动更新 duanluan/zcode-plugins 插件”,仓库名可点击跳转仓库
370
390
  const repoLink = h('span', {
@@ -534,7 +554,7 @@ export function openSettingsDialog() {
534
554
  h('div', { class: 'shrink-0' }, btnSmall(L.hkReset, () => { void resetShortcuts(); }))),
535
555
  hkCard,
536
556
  );
537
- const paneStyles = h('div', { role: 'tabpanel', class: 'mt-4', style: 'display:none' });
557
+ const paneStyles = h('div', { role: 'tabpanel', class: 'mt-2', style: 'display:none' });
538
558
  const panePerf = h('div', { role: 'tabpanel', class: 'mt-4', style: 'display:none' });
539
559
  const paneAgents = h('div', { role: 'tabpanel', class: 'mt-2', style: 'display:none' });
540
560
  const paneProxy = h('div', { role: 'tabpanel', class: 'mt-4', style: 'display:none' });
@@ -728,7 +748,125 @@ export function openSettingsDialog() {
728
748
  for (const c of fontCells) c.setOptions(list);
729
749
  })();
730
750
  renderToolbarSwitch();
731
- paneStyles.append(
751
+ // “样式”标签页的二级结构:样式调整(原内容)/ 状态栏(统计项顺序与显隐)
752
+ const paneStylesSub1 = h('div', { class: 'mt-4' });
753
+ const paneStylesSub2 = h('div', { class: 'mt-4', style: 'display:none' });
754
+ const ITEM_NAMES = {
755
+ ctx: 'sbItemCtx', questions: 'sbItemQuestions',
756
+ todayTokens: 'sbItemTodayTokens', totalTokens: 'sbItemTotalTokens', tasks: 'sbItemTasks',
757
+ };
758
+ // 读取并归一化:会话组项在前、全局组项在后(组内保持保存的顺序),
759
+ // 保证状态栏两组之间只有一道竖线
760
+ const sbItems = () => {
761
+ const saved = config.statusBar && Array.isArray(config.statusBar.items) ? config.statusBar.items : null;
762
+ if (!saved) return DEFAULT_STATUS_ITEMS.map((x) => ({ ...x }));
763
+ const byId = new Map(saved.map((x) => [x.id, x]));
764
+ const list = saved.filter((x) => ITEM_NAMES[x.id]).map((x) => ({ on: x.on !== false, id: x.id }));
765
+ for (const d of DEFAULT_STATUS_ITEMS) if (!byId.has(d.id)) list.push({ ...d });
766
+ return [
767
+ ...list.filter((x) => STATUS_SESSION_IDS.has(x.id)),
768
+ ...list.filter((x) => STATUS_GLOBAL_IDS.has(x.id)),
769
+ ];
770
+ };
771
+ let sbSaving = false; // 保存进行中忽略后续点击:连点会基于旧配置覆盖掉上一次的改动
772
+ const saveSbItems = async (next) => {
773
+ if (sbSaving) return;
774
+ sbSaving = true;
775
+ try {
776
+ const res = await rpc('/config', { method: 'POST', body: { statusBar: { items: next } } });
777
+ clearConfigCache();
778
+ window.dispatchEvent(new CustomEvent('zcodepro:config-changed'));
779
+ if (!res.ok) { showToast(L.failed + ': ' + errText(res), 'error'); renderSbItems(); return; }
780
+ if (res.config && res.config.statusBar) config.statusBar = res.config.statusBar;
781
+ } finally { sbSaving = false; }
782
+ renderSbItems();
783
+ };
784
+ const sbList = h('div', { class: 'divide-y divide-border rounded-xl border border-border' });
785
+ const sbGlobalList = h('div', { class: 'divide-y divide-border rounded-xl border border-border' });
786
+ // 每轮统计节(输入/输出/缓存,只开关不排序——显示顺序固定)
787
+ const TURN_NAMES = { turnIn: 'sbLabelIn', turnOut: 'sbLabelOut', turnCache: 'sbLabelCache' };
788
+ const turnItems = () => {
789
+ const saved = config.statusBar && Array.isArray(config.statusBar.turnItems) ? config.statusBar.turnItems : null;
790
+ if (!saved) return DEFAULT_TURN_ITEMS.map((x) => ({ ...x }));
791
+ const byId = new Map(saved.map((x) => [x.id, x]));
792
+ const list = saved.filter((x) => TURN_NAMES[x.id]).map((x) => ({ on: x.on !== false, id: x.id }));
793
+ for (const d of DEFAULT_TURN_ITEMS) if (!byId.has(d.id)) list.push({ ...d });
794
+ return list;
795
+ };
796
+ const saveTurnItems = async (next) => {
797
+ if (sbSaving) return;
798
+ sbSaving = true;
799
+ try {
800
+ const res = await rpc('/config', { method: 'POST', body: { statusBar: { turnItems: next } } });
801
+ clearConfigCache();
802
+ window.dispatchEvent(new CustomEvent('zcodepro:config-changed'));
803
+ if (!res.ok) { showToast(L.failed + ': ' + errText(res), 'error'); renderSbItems(); return; }
804
+ if (res.config && res.config.statusBar) config.statusBar = res.config.statusBar;
805
+ } finally { sbSaving = false; }
806
+ renderSbItems();
807
+ };
808
+ const turnList = h('div', { class: 'divide-y divide-border rounded-xl border border-border' });
809
+ const renderSbItems = () => {
810
+ turnList.replaceChildren(...turnItems().map((it, i) => h('div', { class: 'flex items-center gap-2 px-3 py-2' },
811
+ h('span', { class: 'min-w-0 flex-1 truncate text-ui-sm text-foreground-subtle' }, L[TURN_NAMES[it.id]] || it.id),
812
+ btnSmall(it.on ? L.sbVisible : L.sbHidden, () => { void saveTurnItems(turnItems().map((x) => x.id === it.id ? { ...x, on: !x.on } : x)); }),
813
+ btnSmall('↑', () => { const a = turnItems(); if (i > 0) { [a[i - 1], a[i]] = [a[i], a[i - 1]]; void saveTurnItems(a); } }),
814
+ btnSmall('↓', () => { const a = turnItems(); if (i < a.length - 1) { [a[i], a[i + 1]] = [a[i + 1], a[i]]; void saveTurnItems(a); } }))));
815
+ // 两节各列各的组;↑/↓ 只在组内移动(跨组相邻的交换会被组过滤弹开)
816
+ const list = sbItems();
817
+ const renderGroup = (el, groupIds) => {
818
+ const idxs = list.map((x, i) => i).filter((i) => groupIds.has(list[i].id));
819
+ el.replaceChildren(...idxs.map((i) => {
820
+ const it = list[i];
821
+ const swap = (dir) => {
822
+ const a = sbItems();
823
+ // 找同组相邻项的索引再交换,天然不会越组
824
+ const same = a.map((x, j) => j).filter((j) => groupIds.has(a[j].id));
825
+ const pos = same.indexOf(i);
826
+ const other = same[pos + dir];
827
+ if (other == null) return;
828
+ [a[i], a[other]] = [a[other], a[i]];
829
+ void saveSbItems(a);
830
+ };
831
+ return h('div', { class: 'flex items-center gap-2 px-3 py-2' },
832
+ h('span', { class: 'min-w-0 flex-1 truncate text-ui-sm text-foreground-subtle' },
833
+ L[ITEM_NAMES[it.id]] || it.id),
834
+ btnSmall(it.on ? L.sbVisible : L.sbHidden, () => { void saveSbItems(sbItems().map((x, j) => j === i ? { ...x, on: !x.on } : x)); }),
835
+ btnSmall('↑', () => swap(-1)),
836
+ btnSmall('↓', () => swap(1)));
837
+ }));
838
+ };
839
+ renderGroup(sbList, STATUS_SESSION_IDS);
840
+ renderGroup(sbGlobalList, STATUS_GLOBAL_IDS);
841
+ };
842
+ renderSbItems();
843
+ paneStylesSub2.append(
844
+ h('p', { class: 'text-ui-sm/relaxed text-foreground-subtle' }, L.featureStatusBarDesc),
845
+ h('div', { class: 'mt-3 mb-1 text-ui-sm font-medium' }, L.sbGroupSession),
846
+ sbList,
847
+ h('div', { class: 'mt-3 mb-1 text-ui-sm font-medium' }, L.sbGroupGlobal),
848
+ sbGlobalList,
849
+ h('div', { class: 'mt-3 mb-1 text-ui-sm font-medium' }, L.sbGroupTurn),
850
+ turnList);
851
+ let stylesSubTab = 'styles';
852
+ const stylesSubTablist = h('div', { role: 'tablist', 'aria-orientation': 'horizontal', class: 'zcodepro-tablist' });
853
+ const renderStylesSubTabs = () => stylesSubTablist.replaceChildren(...[
854
+ ['styles', L.tabStylesPane],
855
+ ['status', L.tabStatusPane],
856
+ ].map(([id, label]) => h('button', {
857
+ type: 'button', role: 'tab', class: 'zcodepro-tab',
858
+ 'aria-selected': String(stylesSubTab === id),
859
+ 'data-state': stylesSubTab === id ? 'active' : 'inactive',
860
+ onClick: () => {
861
+ stylesSubTab = id;
862
+ paneStylesSub1.style.display = id === 'styles' ? '' : 'none';
863
+ paneStylesSub2.style.display = id === 'status' ? '' : 'none';
864
+ renderStylesSubTabs();
865
+ },
866
+ }, label)));
867
+ renderStylesSubTabs();
868
+ paneStyles.append(stylesSubTablist, paneStylesSub1, paneStylesSub2);
869
+ paneStylesSub1.append(
732
870
  toolbarSwitchWrap,
733
871
  h('div', { class: 'mt-2 flex flex-col gap-0.5 rounded-xl border border-border p-1' },
734
872
  ...fontCells.map((c) => h('div', { class: 'rounded-lg transition-colors hover:bg-surface-hover' }, c.el))),
@@ -1600,6 +1738,11 @@ export function openSettingsDialog() {
1600
1738
  modeBtn('fallback', L.visionModeFallback, L.visionModeFallbackDesc),
1601
1739
  modeBtn('pipeline', L.visionModePipeline, L.visionModePipelineDesc),
1602
1740
  h('div', { class: 'ml-auto flex items-center gap-4' },
1741
+ settingRow(L.visionCardUi, '', visionCfg.cardUi !== false, async () => {
1742
+ visionCfg.cardUi = !(visionCfg.cardUi !== false);
1743
+ await persistVision();
1744
+ renderVision();
1745
+ }),
1603
1746
  settingRow(L.visionForceIntercept, '', visionCfg.forceIntercept !== false, async () => {
1604
1747
  visionCfg.forceIntercept = !(visionCfg.forceIntercept !== false);
1605
1748
  await persistVision();
@@ -0,0 +1,274 @@
1
+ // 状态栏统计:主界面底部右下角一行小字,按用户配置的顺序显示统计项。
2
+ // 会话项(上下文占用 / 提问次数)跟随当前会话,无选中会话时自动隐藏;
3
+ // 全局项(今日 / 累计 token、任务总数)优先取官方使用统计(云端,5 分钟刷新),
4
+ // 回退本地聚合。数据 2 秒刷新一次;上下文占用读官方输入框工具栏的窗口显示
5
+ //(“已用 497,101 / 总量 1,000,000”),按官方同款格式渲染。
6
+ import { t, rpc, getConfig, isZhLocale, DEFAULT_STATUS_ITEMS } from '../core.js';
7
+ import { ensureStyle } from '../ui.js';
8
+
9
+ let enabled = true;
10
+ let items = DEFAULT_STATUS_ITEMS.map((x) => ({ ...x }));
11
+ let barEl = null;
12
+ let lastStats = null; // /stats/usage 响应缓存(失败保旧值)
13
+
14
+ // —— 工具 ——
15
+
16
+ // 官方输入框工具栏的上下文显示:title 形如“上下文已用 61,400 / 总量 1,000,000”,
17
+ // 解析出已用与总量;读不到返回 null(ctx 项回退按会话记录估算 token 数)。
18
+ // 直接在全文档找(该显示全局唯一),排除自己渲染的 title
19
+ function readCtx() {
20
+ for (const el of document.querySelectorAll('[title], [aria-label]')) {
21
+ if (el.closest('.zcodepro-statusbar')) continue;
22
+ const text = el.getAttribute('title') || el.getAttribute('aria-label') || '';
23
+ if (!/上下文|context/i.test(text)) continue;
24
+ // 格式为「已用 497,101 / 总量 1,000,000」:斜杠后可能带“总量”等文字再接数字
25
+ const m = text.match(/([\d][\d,]*)\s*\/[^0-9]*([\d][\d,]*)/);
26
+ if (!m) continue;
27
+ const used = Number(m[1].replace(/,/g, ''));
28
+ const size = Number(m[2].replace(/,/g, ''));
29
+ if (used > 0 && size > used) return { used, size };
30
+ }
31
+ return null;
32
+ }
33
+
34
+ // 官方使用统计(设置 → 数据与统计 → 使用统计 的同款服务):真累计/今日/任务数,
35
+ // 比本地 rollout 聚合完整。云端接口低频刷新(5 分钟一次),失败静默回退本地数据
36
+ let usageSvc = null;
37
+ let official = null; // { todayTokens, totalTokens, totalSessions }
38
+ let officialAt = 0;
39
+ // 从 React fiber 树遍历(newtab-panels.js 同款)
40
+ function eachFiber(visit) {
41
+ const rootEl = document.getElementById('root');
42
+ const key = rootEl && Object.keys(rootEl).find((k) => k.startsWith('__reactContainer$'));
43
+ if (!key) return;
44
+ let visited = 0;
45
+ const walk = (f) => {
46
+ if (!f || visited > 200000) return false;
47
+ visited++;
48
+ if (visit(f)) return true;
49
+ return walk(f.child) || walk(f.sibling);
50
+ };
51
+ walk(rootEl[key]);
52
+ }
53
+
54
+ function findUsageService() {
55
+ let found = null;
56
+ const seen = new Set();
57
+ const scan = (o) => {
58
+ if (!o || typeof o !== 'object' || seen.has(o) || found) return;
59
+ seen.add(o);
60
+ if (typeof o.usageStatsService === 'object' && o.usageStatsService) { found = o.usageStatsService; return; }
61
+ for (const v of Object.values(o)) if (v && typeof v === 'object') scan(v);
62
+ };
63
+ eachFiber((f) => {
64
+ for (const h of [f.memoizedProps, f.memoizedState]) {
65
+ if (!h || typeof h !== 'object') continue;
66
+ scan(h);
67
+ if (found) return true;
68
+ }
69
+ return false;
70
+ });
71
+ return found;
72
+ }
73
+ function refreshOfficial() {
74
+ // 成功后 5 分钟刷新一次;尚未成功(页面刚加载服务未就绪等)30 秒重试,
75
+ // 避免首次失败吃满冷却期
76
+ const period = official ? 5 * 60_000 : 30_000;
77
+ if (Date.now() - officialAt < period) return;
78
+ officialAt = Date.now();
79
+ void (async () => {
80
+ try {
81
+ if (!usageSvc) usageSvc = findUsageService();
82
+ if (!usageSvc || typeof usageSvc.getAppUsageSnapshot !== 'function') return;
83
+ const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
84
+ const [all, d7] = await Promise.all([
85
+ usageSvc.getAppUsageSnapshot({ range: 'all', timeZone }),
86
+ usageSvc.getAppUsageSnapshot({ range: '7d', timeZone }),
87
+ ]);
88
+ const now = new Date();
89
+ const key = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0') + '-' + String(now.getDate()).padStart(2, '0');
90
+ let todayTokens = 0;
91
+ const day = ((d7 && Array.isArray(d7.dailyModelUsage)) ? d7.dailyModelUsage : []).find((d) => d.date === key);
92
+ if (day) for (const m of day.models || []) todayTokens += Number(m.totalTokens) || 0;
93
+ official = {
94
+ todayTokens,
95
+ totalTokens: Number(all && all.summary && all.summary.totalTokens) || 0,
96
+ totalSessions: Number(all && all.summary && all.summary.totalSessions) || 0,
97
+ };
98
+ renderBar();
99
+ } catch {
100
+ usageSvc = null; // 服务失效(页面重载等),下个周期重挖
101
+ }
102
+ })();
103
+ }
104
+
105
+ // 全局组统计项(会话组之外的都算会话级):两组之间渲染竖线分隔
106
+ const GLOBAL_GROUP = new Set(['todayTokens', 'totalTokens', 'tasks']);
107
+
108
+ // 各统计项的 lucide 图标(12px 内联,描边风格与官方一致)
109
+ const SB_ICONS = {
110
+ ctx: '<path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/>',
111
+ asks: '<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/>',
112
+ today: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>',
113
+ total: '<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/>',
114
+ tasks: '<rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/>',
115
+ coins: '<circle cx="8" cy="8" r="6"/><path d="M18.09 10.37A6 6 0 1 1 10.34 18"/><path d="M7 6h1v4"/><path d="m16.71 13.88.7.71-2.82 2.82"/>',
116
+ };
117
+ const sbIcon = (name) => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="zcodepro-sb-icon">${SB_ICONS[name]}</svg>`;
118
+
119
+ function fmtTokens(n) {
120
+ if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B';
121
+ if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
122
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
123
+ return String(n);
124
+ }
125
+
126
+ // 数值格式与官方“上下文容量”显示一致:中文界面按数量级用“万/亿”
127
+ //(48.4万、900万、3.5亿),不足一万显示原数;其它语言沿用 k/M 缩写
128
+ function fmtNum(n) {
129
+ if (!isZhLocale()) return fmtTokens(n);
130
+ if (n < 10000) return String(n);
131
+ if (n >= 100000000) {
132
+ const yi = Math.round((n / 100000000) * 10) / 10;
133
+ return (yi % 1 === 0 ? String(yi) : yi.toFixed(1)) + '亿';
134
+ }
135
+ const wan = Math.round((n / 10000) * 10) / 10;
136
+ return (wan % 1 === 0 ? String(wan) : wan.toFixed(1)) + '万';
137
+ }
138
+
139
+ // —— 渲染 ——
140
+
141
+ // 生成一项的 { html, title, tone };数据不可得返回 null(该项隐藏)。
142
+ // 每项 = 图标 + 短标签 + 数值(标签跟随界面语言),悬停 title 给全名与明细
143
+ function itemContent(id, L) {
144
+ const g = lastStats && lastStats.global;
145
+ const label = (text) => `<em>${text}</em>`;
146
+ switch (id) {
147
+ case 'ctx': {
148
+ const dom = readCtx();
149
+ if (dom) {
150
+ const pct = Math.round((dom.used / dom.size) * 1000) / 10;
151
+ const tone = pct >= 95 ? 'danger' : pct >= 80 ? 'warn' : '';
152
+ return {
153
+ html: sbIcon('ctx') + label(L.sbLabelCtx) + `<b>${fmtNum(dom.used)}/${fmtNum(dom.size)}${isZhLocale() ? '(' : '('}${pct}%${isZhLocale() ? ')' : ')'}</b>`,
154
+ title: `${L.sbItemCtx}:${dom.used.toLocaleString()} / ${dom.size.toLocaleString()}`,
155
+ tone,
156
+ };
157
+ }
158
+ return null; // 官方窗口数据不在(视图切换间隙等)时该项隐藏
159
+ }
160
+ case 'questions': {
161
+ // 官方“对话问题导航”的真实计数(当前会话的问题总数)
162
+ const nav = document.querySelector('[data-testid="v4-turn-navigator"]');
163
+ const n = nav ? Number(nav.getAttribute('data-item-count')) : 0;
164
+ return n > 0 ? { html: sbIcon('asks') + label(L.sbLabelQuestions) + String(n), title: L.sbItemQuestions } : null;
165
+ }
166
+ case 'todayTokens': {
167
+ const n = official ? official.todayTokens : (g ? g.todayTokens : 0);
168
+ return n > 0 ? { html: sbIcon('today') + label(L.sbLabelToday) + fmtNum(n), title: L.sbItemTodayTokens } : null;
169
+ }
170
+ case 'totalTokens': {
171
+ const n = official ? official.totalTokens : (g ? g.totalTokens : 0);
172
+ return n > 0 ? { html: sbIcon('total') + label(L.sbLabelTotal) + fmtNum(n), title: L.sbItemTotalTokens } : null;
173
+ }
174
+ case 'tasks': {
175
+ const n = official ? official.totalSessions : (g ? g.tasks : 0);
176
+ return n > 0 ? { html: sbIcon('tasks') + label(L.sbLabelTasks) + String(n), title: L.sbItemTasks } : null;
177
+ }
178
+ default:
179
+ return null;
180
+ }
181
+ }
182
+
183
+ function renderBar() {
184
+ // 3.16.1 的输入框是 contenteditable(旧版是 textarea),两种都认
185
+ const ta = document.querySelector('textarea, [contenteditable=true]');
186
+ const host = ta && (ta.closest('form') || ta.parentElement);
187
+ if (!enabled || !host || !host.parentElement) {
188
+ if (barEl) barEl.remove();
189
+ barEl = null;
190
+ return;
191
+ }
192
+ if (!barEl || !barEl.isConnected) {
193
+ if (!barEl) {
194
+ barEl = document.createElement('div');
195
+ barEl.className = 'zcodepro-statusbar';
196
+ }
197
+ host.parentElement.insertBefore(barEl, host.nextSibling);
198
+ }
199
+ // 新建会话视图(无选中会话)下方是官方的功能按钮区,不能往下压:上下对称
200
+ // 留 7px;普通会话视图下方是窗口底的官方留白,压进去贴底(同为约 7px)。
201
+ // 按选中会话判定视图(按钮区与状态条不在同一父容器,兄弟判断不可用)
202
+ const hasSession = !![...document.querySelectorAll('[data-task-item-key]')]
203
+ .find((e) => (e.className + '').includes('bg-selected'));
204
+ barEl.style.marginBottom = hasSession ? '-14px' : '0';
205
+ barEl.style.paddingBottom = hasSession ? '0' : '7px';
206
+ const L = t();
207
+ const parts = [];
208
+ let lastGroup = '';
209
+ for (const it of items) {
210
+ if (!it.on) continue;
211
+ const c = itemContent(it.id, L);
212
+ if (!c) continue;
213
+ const group = GLOBAL_GROUP.has(it.id) ? 'g' : 's';
214
+ // 会话项与全局项之间用竖线分隔(比项间的 · 更强的分界)
215
+ if (lastGroup && group !== lastGroup) parts.push('<span class="zcodepro-statusbar-groupsep"></span>');
216
+ lastGroup = group;
217
+ parts.push(`<span title="${c.title.replace(/"/g, '&quot;')}"${c.tone ? ` data-tone="${c.tone}"` : ''}>${c.html}</span>`);
218
+ }
219
+ // 组内项之间用 ·,跨组竖线两侧不再叠加 ·
220
+ const SEP = '<span class="zcodepro-statusbar-sep">·</span>';
221
+ const GROUPSEP = '<span class="zcodepro-statusbar-groupsep"></span>';
222
+ barEl.innerHTML = parts.reduce((acc, p, i) => {
223
+ if (i === 0) return p;
224
+ if (p === GROUPSEP || parts[i - 1] === GROUPSEP) return acc + p;
225
+ return acc + SEP + p;
226
+ }, '');
227
+ barEl.style.display = parts.length ? '' : 'none';
228
+ }
229
+
230
+ async function tick() {
231
+ if (!enabled || document.hidden) return;
232
+ refreshOfficial();
233
+ const res = await rpc('/stats/usage').catch(() => null);
234
+ if (res && res.ok) lastStats = res;
235
+ renderBar();
236
+ }
237
+
238
+ export function startStatusBar() {
239
+ ensureStyle();
240
+ const refreshCfg = () => {
241
+ void getConfig().then((cfg) => {
242
+ enabled = cfg.features ? cfg.features.statusBar !== false : true;
243
+ const saved = cfg.statusBar && Array.isArray(cfg.statusBar.items) ? cfg.statusBar.items : null;
244
+ if (saved) {
245
+ // 保存的顺序为准(会话组在前已由设置保存时归一化;这里再归一化一次
246
+ // 防御旧版本保存的交错顺序);新版本新增的默认项按默认组位补齐
247
+ const byId = new Map(saved.map((x) => [x.id, x]));
248
+ const list = saved.filter((x) => DEFAULT_STATUS_ITEMS.some((d) => d.id === x.id)).map((x) => ({ ...x }));
249
+ const known = new Set(list.map((x) => x.id));
250
+ for (const d of DEFAULT_STATUS_ITEMS) {
251
+ if (!known.has(d.id)) list.push({ ...d });
252
+ }
253
+ items = [
254
+ ...list.filter((x) => !GLOBAL_GROUP.has(x.id)),
255
+ ...list.filter((x) => GLOBAL_GROUP.has(x.id)),
256
+ ];
257
+ } else {
258
+ items = DEFAULT_STATUS_ITEMS.map((x) => ({ ...x }));
259
+ }
260
+ renderBar();
261
+ }).catch(() => {});
262
+ };
263
+ refreshCfg();
264
+ window.addEventListener('zcodepro:config-changed', refreshCfg);
265
+ document.addEventListener('visibilitychange', () => { if (!document.hidden) void tick(); });
266
+ setInterval(() => { void tick(); }, 2000);
267
+ // 视图切换(进新建会话、换会话等)时 DOM 变动立即重渲染(去抖 300ms):
268
+ // 否则状态条要等下一个 2 秒周期,期间残留上一个视图的统计项与间距
269
+ let rerenderTimer = 0;
270
+ new MutationObserver(() => {
271
+ if (rerenderTimer) return;
272
+ rerenderTimer = setTimeout(() => { rerenderTimer = 0; renderBar(); }, 300);
273
+ }).observe(document.body, { childList: true, subtree: true });
274
+ }
@@ -0,0 +1,195 @@
1
+ // 每轮回答的 token 用量:在回答末尾操作按钮(复制/赞/踩/分叉/钩子)行的时间
2
+ // 后面追加“竖线 + 输入/输出/缓存”标注(段顺序与显隐可在设置里调)。
3
+ // 数据来自本地模型调用记录(helper 按本地分钟分桶),按“本条提问到下一条
4
+ // 提问”的区间归属到回答行;本地记录只保留最近一段,覆盖不到的旧回答不标注。
5
+ import { t, rpc, getConfig, isZhLocale } from '../core.js';
6
+
7
+ let enabled = true;
8
+ let recentTurns = []; // [{hm, in, out, cacheRead, ts}] 按时间倒序
9
+ let scanTimer = 0;
10
+ // 三段(输入/输出/缓存)的显隐与顺序:设置 → 样式 → 状态栏 → 每轮统计
11
+ let turnCfg = [
12
+ { id: 'turnIn', on: true },
13
+ { id: 'turnOut', on: true },
14
+ { id: 'turnCache', on: true },
15
+ ];
16
+ const refreshTurnItems = () => {
17
+ void getConfig().then((cfg) => {
18
+ const saved = cfg.statusBar && Array.isArray(cfg.statusBar.turnItems) ? cfg.statusBar.turnItems : null;
19
+ if (!saved) return;
20
+ const known = new Set(turnCfg.map((x) => x.id));
21
+ const list = saved.filter((x) => x && known.has(x.id)).map((x) => ({ id: x.id, on: x.on !== false }));
22
+ for (const d of turnCfg) if (!list.some((x) => x.id === d.id)) list.push({ ...d });
23
+ turnCfg = list;
24
+ }).catch(() => {});
25
+ };
26
+
27
+ // 与状态栏同款的小图标(上/下箭头、层叠缓存)
28
+ const ICONS = {
29
+ up: '<path d="m5 12 7-7 7 7"/><path d="M12 19V5"/>',
30
+ down: '<path d="M12 5v14"/><path d="m19 12-7 7-7-7"/>',
31
+ cache: '<path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>',
32
+ };
33
+ const icon = (name) => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:11px;height:11px;vertical-align:-1.5px;opacity:.6">${ICONS[name]}</svg>`;
34
+
35
+ // 与状态栏同款:中文用 万/亿,其它语言 k/M 缩写
36
+ function fmt(n) {
37
+ if (!isZhLocale()) {
38
+ if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B';
39
+ if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
40
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
41
+ return String(n);
42
+ }
43
+ if (n >= 1e8) return (n / 1e8).toFixed(1) + '亿';
44
+ if (n >= 1e4) {
45
+ const wan = Math.round((n / 1e4) * 10) / 10;
46
+ return (wan % 1 === 0 ? String(wan) : wan.toFixed(1)) + '万';
47
+ }
48
+ return String(n);
49
+ }
50
+
51
+ // 回答操作行:按结构特征识别——回答行有赞/踩按钮(v4-feedback-),用户消息行
52
+ // 没有(只有复制/编辑),比时间文本稳(官方改时间格式不影响识别)。
53
+ // 行内时间文本只用于解析该轮的提问时刻
54
+ function findRows() {
55
+ const rows = [];
56
+ for (const btn of document.querySelectorAll('[data-testid^="v4-copy-"]')) {
57
+ const row = btn.parentElement;
58
+ if (!row || row.dataset.zcodeproTurn) continue;
59
+ if (!row.querySelector('[data-testid^="v4-feedback-"]')) continue;
60
+ const timeEl = [...row.querySelectorAll('span')].find((s) => /\d{1,2}:\d{2}$/.test((s.textContent || '').trim()));
61
+ if (timeEl) { const ts = rowTimeTs((timeEl.textContent || '').trim()); if (ts != null) rows.push({ row, ts }); }
62
+ }
63
+ return rows;
64
+ }
65
+
66
+ // 当前会话 id:优先选中行,回退侧栏最新一行(与状态栏同口径)
67
+ function currentSessionId() {
68
+ const rows = [...document.querySelectorAll('[data-task-item-key]')];
69
+ const cur = rows.find((e) => (e.className + '').includes('bg-selected')) || rows[rows.length - 1];
70
+ const key = cur ? cur.getAttribute('data-task-item-key') : '';
71
+ const i = key.lastIndexOf(':sess');
72
+ return i > 0 ? key.slice(i + 1) : '';
73
+ }
74
+
75
+ function injectRow(row, bucket) {
76
+ row.dataset.zcodeproTurn = '1';
77
+ if (!bucket || bucket.in + bucket.out <= 0) return;
78
+ const L = t();
79
+ const el = document.createElement('span');
80
+ el.dataset.zcodeproTurnSpan = '1';
81
+ el.className = 'select-none text-ui-sm text-foreground-subtlest';
82
+ // 按钮行是 flex 容器:本元素作为子项会被压缩导致内容折行,锁成单行
83
+ el.style.whiteSpace = 'nowrap';
84
+ el.style.display = 'inline-flex';
85
+ el.style.alignItems = 'center';
86
+ el.style.gap = '4px'; // 组与 · 分隔之间的间距
87
+ el.style.marginLeft = '10px'; // 与左侧时间拉开
88
+ // 与状态栏同款:图标 + 文字 + 数据;缓存有量时给命中率
89
+ // 保留两位小数:长上下文会话命中率普遍 99%+,取整会全部显示 100% 失真
90
+ const cachePct = bucket.in > 0 && bucket.cacheRead > 0
91
+ ? (bucket.cacheRead / bucket.in * 100).toFixed(2) : '';
92
+ const group = (html) => `<span style="display:inline-flex;align-items:center;gap:2px">${html}</span>`;
93
+ const em = (text) => `<em style="font-style:normal;opacity:.75">${text}</em>`;
94
+ const DOT = '<span style="opacity:.45">·</span>'; // 与状态栏同款分隔
95
+ // 段按设置保存的顺序渲染,段间用 ·;首段左侧竖线与底部状态栏的组分隔同款;
96
+ // 全部隐藏时不注入(连竖线一起没有)
97
+ const sections = [];
98
+ for (const s of turnCfg) {
99
+ if (!s.on) continue;
100
+ if (s.id === 'turnIn') sections.push(group(icon('up') + em(L.sbLabelIn) + fmt(bucket.in)));
101
+ else if (s.id === 'turnOut') sections.push(group(icon('down') + em(L.sbLabelOut) + fmt(bucket.out)));
102
+ else if (s.id === 'turnCache' && cachePct) sections.push(group(icon('cache') + em(L.sbLabelCache) + cachePct + '%'));
103
+ }
104
+ if (!sections.length) { delete row.dataset.zcodeproTurn; el.remove(); return; }
105
+ const VBAR = '<span style="display:inline-block;width:1px;height:10px;background:currentColor;opacity:.3;margin:0 5px"></span>';
106
+ el.innerHTML = VBAR + sections.join(DOT);
107
+ el.title = `${L.turnUsageTitle}:${L.sbLabelIn} ${bucket.in.toLocaleString()} / ${L.sbLabelOut} ${bucket.out.toLocaleString()}`
108
+ + (bucket.cacheRead > 0 ? ` / ${L.sbLabelCache} ${bucket.cacheRead.toLocaleString()}` : '');
109
+ row.append(el); // 追加在按钮行末尾(时间之后)
110
+ }
111
+
112
+ // 回答行时间文本 → 当年本地时间的时间戳(毫秒)。
113
+ // 官方三种形态:今天“18:16”、昨天“昨天 18:16”、更早“10/21 17:41”。
114
+ // 行时间是提问时刻,调用桶是完成时刻——一轮的用量按“本条提问到下一条
115
+ // 提问之间”的区间归属(见 scan),因此这里只取可比时间戳
116
+ function rowTimeTs(text) {
117
+ const now = new Date();
118
+ let m = text.match(/^(\d{1,2}):(\d{2})$/);
119
+ if (m) return new Date(now.getFullYear(), now.getMonth(), now.getDate(), +m[1], +m[2]).getTime();
120
+ m = text.match(/^昨天\s*(\d{1,2}):(\d{2})$/);
121
+ if (m) { const y = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1); return new Date(y.getFullYear(), y.getMonth(), y.getDate(), +m[1], +m[2]).getTime(); }
122
+ m = text.match(/^(\d{1,2})\/(\d{1,2})\s*(\d{1,2}):(\d{2})$/);
123
+ if (m) return new Date(now.getFullYear(), +m[1] - 1, +m[2], +m[3], +m[4]).getTime();
124
+ return null;
125
+ }
126
+
127
+ let lastSid = '';
128
+ function scan() {
129
+ if (!enabled) return;
130
+ // 会话切换瞬间:旧会话的桶还留在 recentTurns 里,若继续归属会把别的会话
131
+ // 的调用安到新视图的旧回答上——先清掉并立即重拉(拉到前只清不注)
132
+ const sid = currentSessionId();
133
+ if (sid !== lastSid) {
134
+ lastSid = sid;
135
+ recentTurns = [];
136
+ void tick();
137
+ }
138
+ // 每轮重算:先移除上一次注入的标注并清行标记(进行中的轮数值会增长);
139
+ // 按标题前缀一并清掉旧版本格式的标注
140
+ for (const el of document.querySelectorAll('[data-zcodepro-turn-span], span[title^="本轮"], span[title^="Turn"]')) el.remove();
141
+ for (const row of document.querySelectorAll('[data-zcodepro-turn]')) delete row.dataset.zcodeproTurn;
142
+ if (document.hidden || !recentTurns.length) return;
143
+ const rows = findRows().sort((a, b) => a.ts - b.ts); // 可见行按时间升序
144
+ if (!rows.length) return;
145
+ // 区间归属:每条回答拥有“自己的提问时刻 ≤ 桶完成时刻 < 下一条提问时刻”
146
+ // 的全部调用(一轮可能跨多个分钟);最后一条收到其后所有桶(进行中的轮)。
147
+ // 行集是虚拟列表的可见窗口,同一条回答的邻居行在场时归属稳定
148
+ const agg = rows.map(() => ({ in: 0, out: 0, cacheRead: 0 }));
149
+ const dayOf = (t) => { const d = new Date(t); return d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate(); };
150
+ for (const b of recentTurns) {
151
+ let owner = -1;
152
+ for (let i = 0; i < rows.length; i++) {
153
+ if (rows[i].ts <= b.ts) owner = i;
154
+ else break;
155
+ }
156
+ if (owner < 0) continue; // 比窗口里最早的行还早:不属于可见任何一条
157
+ // 同日约束:跨天的桶不归属(昨天的旧行不吞今天的调用——今天的提问
158
+ // 行可能只是不在当前虚拟列表窗口里)
159
+ if (dayOf(rows[owner].ts) !== dayOf(b.ts)) continue;
160
+ agg[owner].in += b.in;
161
+ agg[owner].out += b.out;
162
+ agg[owner].cacheRead += b.cacheRead;
163
+ }
164
+ for (let i = 0; i < rows.length; i++) {
165
+ if (agg[i].in + agg[i].out > 0) injectRow(rows[i].row, agg[i]);
166
+ }
167
+ }
168
+
169
+ async function tick() {
170
+ if (!enabled || document.hidden) return;
171
+ const sid = currentSessionId();
172
+ const res = await rpc('/stats/usage' + (sid ? '?session=' + encodeURIComponent(sid) : '')).catch(() => null);
173
+ if (res && res.ok && Array.isArray(res.recentTurns)) {
174
+ // 同分钟桶随时间增长,全量替换
175
+ recentTurns = res.recentTurns;
176
+ scan();
177
+ }
178
+ }
179
+
180
+ export function startTurnUsage() {
181
+ void getConfig().then((cfg) => { enabled = cfg.features ? cfg.features.turnUsage !== false : true; }).catch(() => {});
182
+ refreshTurnItems();
183
+ window.addEventListener('zcodepro:config-changed', () => {
184
+ void getConfig().then((cfg) => { enabled = cfg.features ? cfg.features.turnUsage !== false : true; }).catch(() => {});
185
+ refreshTurnItems();
186
+ });
187
+ // 回答行出现即注入(去抖);数据 5 秒刷新一次
188
+ const observer = new MutationObserver(() => {
189
+ if (scanTimer) return;
190
+ scanTimer = setTimeout(() => { scanTimer = 0; scan(); }, 1200); // 滚动/复用渲染稳定后再注
191
+ });
192
+ observer.observe(document.body, { childList: true, subtree: true });
193
+ setInterval(() => { void tick(); }, 5000);
194
+ document.addEventListener('visibilitychange', () => { if (!document.hidden) void tick(); });
195
+ }