zcode-pro 1.1.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zcode-pro",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "ZCode Pro — 为 ZCode 桌面版增加界面增强(不修改客户端文件,通过 ZCode Pro 快捷方式启动即可),核心功能:项目自定义别名",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,13 +11,13 @@ import { basename, delimiter, dirname, isAbsolute, join, resolve, sep } from 'no
11
11
  import { homedir, tmpdir, userInfo } from 'node:os';
12
12
  import { fileURLToPath, pathToFileURL } from 'node:url';
13
13
  import { readSettings, writeSettingsAtomic, remapSettingsPaths, isProjectOpenInTabs } from './settings.mjs';
14
- import { taskIndexPath, probeTaskIndexWritable, remapTaskIndexPaths, taskIndexDriverAvailable, listOverriddenTitles, listTaskWorkspaceMap, setTaskTitleIfNotOverridden, setCustomSessionTitle, isTaskTitleOverridden, reconcileCustomTitles, getTaskProviderModel, listVirtualProjects } from './taskIndex.mjs';
14
+ import { taskIndexPath, probeTaskIndexWritable, remapTaskIndexPaths, taskIndexDriverAvailable, listOverriddenTitles, listTaskWorkspaceMap, setTaskTitleIfNotOverridden, setCustomSessionTitle, isTaskTitleOverridden, reconcileCustomTitles, getTaskProviderModel, listVirtualProjects, countTasks } from './taskIndex.mjs';
15
15
  import { pickFolderSystem } from './pickFolder.mjs';
16
16
  import { reorderWorkspaceTasks, reorderGroupMembers } from './taskOrder.mjs';
17
17
  import { webdavRequest, webdavPutWithMkcol, webdavHttpText, parseWebdavListing, findCurl } from './webdav.mjs';
18
18
  import { resolveZcodeExecutable } from './paths.mjs';
19
19
 
20
- const VERSION = '1.1.0';
20
+ const VERSION = '1.2.1';
21
21
 
22
22
  // 全局提示词固定在用户主目录:官方加载器按 HOME/USERPROFILE 拼 .zcode/AGENTS.md,
23
23
  // 不读 ZCODE_DATA_BASE_DIR(数据根迁走时全局指令仍在原位)。
@@ -348,6 +348,35 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
348
348
  }
349
349
  current.shortcuts = { ...(current.shortcuts || {}), ...patch };
350
350
  }
351
+ // 状态栏统计(设置 → 样式 → 状态栏):items(任务统计项)与
352
+ // turnItems(每轮统计项)两个清单各自整表覆盖,谁在请求里就更新谁;
353
+ // id 白名单 + 去重保序,未知项丢弃
354
+ if (body && typeof body === 'object' && body.statusBar && typeof body.statusBar === 'object') {
355
+ const next = { ...(current.statusBar || {}) };
356
+ if (Array.isArray(body.statusBar.items)) {
357
+ const valid = new Set(['ctx', 'questions', 'todayTokens', 'totalTokens', 'tasks']);
358
+ const seen = new Set();
359
+ const items = [];
360
+ for (const it of body.statusBar.items) {
361
+ if (!it || !valid.has(it.id) || seen.has(it.id)) continue;
362
+ seen.add(it.id);
363
+ items.push({ id: it.id, on: it.on !== false });
364
+ }
365
+ next.items = items;
366
+ }
367
+ if (Array.isArray(body.statusBar.turnItems)) {
368
+ const turnValid = new Set(['turnIn', 'turnOut', 'turnCache']);
369
+ const tseen = new Set();
370
+ const turnItems = [];
371
+ for (const it of body.statusBar.turnItems) {
372
+ if (!it || !turnValid.has(it.id) || tseen.has(it.id)) continue;
373
+ tseen.add(it.id);
374
+ turnItems.push({ id: it.id, on: it.on !== false });
375
+ }
376
+ next.turnItems = turnItems;
377
+ }
378
+ current.statusBar = next;
379
+ }
351
380
  if (body && typeof body === 'object' && body.titleGen && typeof body.titleGen === 'object') {
352
381
  current.titleGen = mergeTitleGenConfig({ ...current.titleGen, ...body.titleGen });
353
382
  }
@@ -611,7 +640,17 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
611
640
  // —— zcode-vision 插件(图片视觉代理):设置弹窗“视觉代理”标签页读写 ————
612
641
  if (req.method === 'GET' && url.pathname === '/vision') {
613
642
  const file = defaultVisionFile();
614
- const parsed = readJsonFile(file);
643
+ let parsed = readJsonFile(file);
644
+ // 装了 zcode-pro 默认启用识图卡片(插件纯安装默认关,纯文本展示):
645
+ // 只补从未写过的配置,用户显式设过 cardUi(含 false)不覆盖
646
+ if (parsed && typeof parsed === 'object' && !('cardUi' in parsed)) {
647
+ parsed = { ...parsed, cardUi: true };
648
+ try {
649
+ const tmp = file + '.tmp';
650
+ writeFileSync(tmp, JSON.stringify(parsed, null, 2) + '\n', 'utf-8');
651
+ renameSync(tmp, file);
652
+ } catch { /* 补写失败下次再试,不阻塞读取 */ }
653
+ }
615
654
  if (parsed === null && existsSync(file)) {
616
655
  json(res, 500, { ok: false, code: 'vision-read', error: 'zcode-vision.json 不是合法 JSON' });
617
656
  return;
@@ -628,8 +667,12 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
628
667
  }
629
668
  try {
630
669
  const file = defaultVisionFile();
670
+ // 与磁盘旧配置合并(本块曾两次被并发改动覆盖回全量覆盖):前端按自己
671
+ // 的字段重建整份配置,直接覆盖会丢插件新增、面板还不认识的键
672
+ //(cardSandboxBudget/cardUi/interceptMode 等)
673
+ const merged = { ...(readJsonFile(file) || {}), ...cfg };
631
674
  const tmp = file + '.tmp';
632
- writeFileSync(tmp, JSON.stringify(cfg, null, 2) + '\n', 'utf-8');
675
+ writeFileSync(tmp, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
633
676
  renameSync(tmp, file);
634
677
  } catch (err2) {
635
678
  json(res, 500, { ok: false, code: 'vision-write', error: '写入 zcode-vision.json 失败: ' + (err2?.message || err2) });
@@ -1135,6 +1178,21 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
1135
1178
  return;
1136
1179
  }
1137
1180
  // —— zcode-plugins 市场更新:读状态(带 30 分钟节流的市场同步)与执行更新 ————
1181
+ // —— 状态栏统计(status-bar.js):聚合 ~/.zcode/cli/rollout 的模型调用记录 ————
1182
+ // model-io-*.jsonl 每行一次模型调用,response.usage 有分项 token;成本字段
1183
+ // 官方未落本地,成本类统计项由注入侧按“无数据自动隐藏”处理
1184
+ if (req.method === 'GET' && url.pathname === '/stats/usage') {
1185
+ const sessionId = url.searchParams.get('session') || '';
1186
+ const { global, recentTurns } = collectUsageStats(sessionId);
1187
+ // 任务总数 60 秒缓存:状态栏每 2 秒请求一次,不必每次开关 SQLite
1188
+ if (Date.now() - tasksCountAt > 60_000) {
1189
+ tasksCountAt = Date.now();
1190
+ tasksCountCache = await countTasks(dataRoot);
1191
+ }
1192
+ global.tasks = tasksCountCache;
1193
+ json(res, 200, { ok: true, global, recentTurns });
1194
+ return;
1195
+ }
1138
1196
  if (req.method === 'GET' && url.pathname === '/plugins/status') {
1139
1197
  await ensureZcodePluginsMarketplace();
1140
1198
  // 有更新任务在执行或排队时跳过这里的节流同步(任务本身会同步市场并刷新
@@ -1178,6 +1236,18 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
1178
1236
  }
1179
1237
  rejectStarted(err);
1180
1238
  });
1239
+ // 启动即补视觉配置缺省键:装了 zcode-pro ⇒ cardUi=true(识图卡片开,缺省
1240
+ // 才补、显式设过不动,规则同 GET /vision),不必等用户打开视觉设置页
1241
+ try {
1242
+ const vFile = defaultVisionFile();
1243
+ const vParsed = readJsonFile(vFile);
1244
+ if (vParsed && typeof vParsed === 'object' && !('cardUi' in vParsed)) {
1245
+ vParsed.cardUi = true;
1246
+ const vTmp = vFile + '.tmp';
1247
+ writeFileSync(vTmp, JSON.stringify(vParsed, null, 2) + '\n', 'utf-8');
1248
+ renameSync(vTmp, vFile);
1249
+ }
1250
+ } catch { /* 无配置或不可写不影响启动 */ }
1181
1251
  server.listen(port, '127.0.0.1', () => resolveStarted(server));
1182
1252
  };
1183
1253
  tryListen();
@@ -1186,6 +1256,101 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
1186
1256
 
1187
1257
  // 市场快照同步节流:helper 进程内 30 分钟最多主动 git pull 一次
1188
1258
  let lastMarketplaceSync = 0;
1259
+ let tasksCountAt = 0;
1260
+ let tasksCountCache = 0; // 任务总数缓存(/stats/usage 高频轮询,60 秒一刷)
1261
+
1262
+ // —— 状态栏统计:rollout 模型调用记录聚合 ————
1263
+ // ~/.zcode/cli/rollout/model-io-*.jsonl 每行一次模型调用;usage 字段有两种键风格
1264
+ // (协议 camelCase 与原始 snake_case),两种都解析。文件小(个位数、每会话一个),
1265
+ // 按 mtime+size 缓存只重读变过的文件;聚合结果含按日分桶(本地时区)供“今日”用
1266
+ const usageFileCache = new Map(); // 文件名 → { mtime, size, recs: [{at, in, out, session, model}] }
1267
+ function rolloutRecords() {
1268
+ const dir = join(homedir(), '.zcode', 'cli', 'rollout');
1269
+ let names = [];
1270
+ try { names = readdirSync(dir).filter((n) => n.startsWith('model-io-') && n.endsWith('.jsonl')); } catch { return []; }
1271
+ const recs = [];
1272
+ for (const name of names) {
1273
+ const full = join(dir, name);
1274
+ let st;
1275
+ try { st = statSync(full); } catch { continue; }
1276
+ const cached = usageFileCache.get(name);
1277
+ if (cached && cached.mtime === st.mtimeMs && cached.size === st.size) {
1278
+ recs.push(...cached.recs);
1279
+ continue;
1280
+ }
1281
+ const parsed = [];
1282
+ try {
1283
+ for (const line of readFileSync(full, 'utf8').split('\n')) {
1284
+ if (!line.trim()) continue;
1285
+ let d;
1286
+ try { d = JSON.parse(line); } catch { continue; }
1287
+ const u = (d.response && d.response.usage) || d.usage;
1288
+ if (!u) continue;
1289
+ const num = (v) => Number(v) || 0;
1290
+ // 两种键风格语义相反,必须分开处理:
1291
+ // 协议格式(inputTokens)的总输入已含缓存读(官方 summary 证实);
1292
+ // 原始 API 格式(input_tokens,Anthropic 语义)三个字段分开、需相加
1293
+ let input, cacheRead, cacheWrite;
1294
+ if (u.inputTokens != null) {
1295
+ input = num(u.inputTokens);
1296
+ cacheRead = num(u.cacheReadTokens);
1297
+ cacheWrite = num(u.cacheWriteTokens);
1298
+ } else {
1299
+ cacheRead = num(u.cache_read_input_tokens);
1300
+ cacheWrite = num(u.cache_write_input_tokens);
1301
+ input = num(u.input_tokens) + cacheRead + cacheWrite;
1302
+ }
1303
+ const output = num(u.outputTokens ?? u.output_tokens);
1304
+ parsed.push({
1305
+ at: d.completedAt || d.startedAt || '',
1306
+ in: input, out: output,
1307
+ cacheRead,
1308
+ session: String(d.sessionId || ''),
1309
+ });
1310
+ }
1311
+ } catch { /* 文件读失败跳过 */ }
1312
+ usageFileCache.set(name, { mtime: st.mtimeMs, size: st.size, recs: parsed });
1313
+ recs.push(...parsed);
1314
+ }
1315
+ return recs;
1316
+ }
1317
+
1318
+ function collectUsageStats(sessionId) {
1319
+ const recs = rolloutRecords();
1320
+ const today = new Date();
1321
+ const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
1322
+ const global = { todayTokens: 0, todayOut: 0, totalTokens: 0, totalOut: 0, tasks: 0, records: recs.length };
1323
+ // 每轮用量按本地 HH:mm 分桶(回答末尾的显示时间与之匹配);
1324
+ // 本地记录只留最近几轮,注入侧只给能对上时间的回答显示
1325
+ const byMinute = new Map();
1326
+ for (const r of recs) {
1327
+ const total = r.in + r.out;
1328
+ global.totalTokens += total;
1329
+ global.totalOut += r.out;
1330
+ const day = String(r.at || '').slice(0, 10);
1331
+ if (day === todayKey) { global.todayTokens += total; global.todayOut += r.out; }
1332
+ // 每轮回答只统计主对话流的调用:起名等辅助请求是独立小上下文、
1333
+ // 缓存从零开始,混进分钟桶会把命中率拉到个位数(全局统计仍全量计入)
1334
+ if (total < 5000) continue;
1335
+ // 指定会话时每轮桶只算该会话的调用——否则切到别的会话聊天后,
1336
+ // 那些调用会被区间归属算进当前会话最后一行的头上
1337
+ if (sessionId && r.session !== sessionId) continue;
1338
+ const t = Date.parse(r.at);
1339
+ if (!Number.isFinite(t)) continue;
1340
+ const d = new Date(t);
1341
+ // 键带日期(月-日 时:分):注入侧按回答行的时间文本(今天/昨天/10-21 三种
1342
+ // 形态)解析出同款键精确绑定,数值不随记录滚动移位
1343
+ const hm = (d.getMonth() + 1) + '-' + d.getDate() + ' ' + String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
1344
+ const bucket = byMinute.get(hm) || { hm, in: 0, out: 0, cacheRead: 0, ts: t };
1345
+ bucket.in += r.in;
1346
+ bucket.out += r.out;
1347
+ bucket.cacheRead += r.cacheRead;
1348
+ bucket.ts = Math.max(bucket.ts, t);
1349
+ byMinute.set(hm, bucket);
1350
+ }
1351
+ const recentTurns = [...byMinute.values()].sort((a, b) => b.ts - a.ts).slice(0, 60);
1352
+ return { global, recentTurns };
1353
+ }
1189
1354
 
1190
1355
  function readJsonFile(p) {
1191
1356
  try {
@@ -2402,6 +2567,11 @@ export function validateVisionConfig(raw) {
2402
2567
  skipMinutes: clampInt(raw.skipMinutes, 1, 10080, 30),
2403
2568
  // 主模型能看图时是否也拦截识别(false = 跳过;flash 主模型始终不触发,见插件)
2404
2569
  forceIntercept: raw.forceIntercept !== false,
2570
+ // 识图卡片展示:只在调用方显式给值时透传(面板开关用);缺省不写键,
2571
+ // 让 POST 的磁盘合并保留原值(zcode-pro 装机缺省 true 的补键逻辑见 GET /vision)
2572
+ ...(raw.cardUi !== undefined ? { cardUi: raw.cardUi === true } : {}),
2573
+ // interceptMode(tool/inject)同 cardUi:显式给值才透传,缺省保持磁盘原值
2574
+ ...(raw.interceptMode !== undefined && ['tool', 'inject'].includes(raw.interceptMode) ? { interceptMode: raw.interceptMode } : {}),
2405
2575
  };
2406
2576
  const names = new Set();
2407
2577
  for (const p of raw.proxies) {
@@ -270,6 +270,24 @@ export async function listTaskStatuses(dataRoot, ids) {
270
270
  }
271
271
 
272
272
  // 已重命名会话的标题表:task_id → title(title_overridden = 1 且未删除)。
273
+ // 状态栏统计用:任务总数(未删除的会话行)
274
+ export async function countTasks(dataRoot) {
275
+ const dbFile = taskIndexPath(dataRoot);
276
+ if (!existsSync(dbFile)) return 0;
277
+ const driver = await loadDriver();
278
+ if (!driver) return 0;
279
+ const sql = 'SELECT COUNT(*) AS n FROM tasks WHERE deleted = 0';
280
+ try {
281
+ if (driver.kind === 'node') {
282
+ const db = new driver.DatabaseSync(dbFile, { readOnly: true });
283
+ try { return Number(db.prepare(sql).get()?.n) || 0; } finally { db.close(); }
284
+ }
285
+ return Number(runCli(dbFile, sql + ';').stdout.trim()) || 0;
286
+ } catch {
287
+ return 0;
288
+ }
289
+ }
290
+
273
291
  // 只读单条 SELECT,不开事务;库不存在或没有 SQLite 支持时返回空表,
274
292
  // 调用方(会话名锁定)自然降级为不干预。
275
293
  export async function listOverriddenTitles(dataRoot) {
@@ -48,6 +48,28 @@ export const DEFAULT_SHORTCUTS = {
48
48
  switchAttention: 'r', // 打开运行中 / 未查看
49
49
  };
50
50
 
51
+ // 状态栏任务统计项的两个组:会话级跟随当前会话,全局级始终显示;
52
+ // 设置里两节各自排序,保存时归一化为会话组在前(状态栏两组间一道竖线)
53
+ export const STATUS_SESSION_IDS = new Set(['ctx', 'sessionCost', 'questions']);
54
+ export const STATUS_GLOBAL_IDS = new Set(['todayTokens', 'todayCost', 'totalTokens', 'totalCost', 'tasks']);
55
+
56
+ // 状态栏统计项的默认清单(顺序即默认显示顺序);设置 → 样式 → 状态栏里
57
+ // 可调顺序与显隐,配置存在 config.statusBar.items(整表覆盖)
58
+ // 每轮回答统计项(turn-usage.js):回答末尾标注的三段,各自可显隐(顺序固定)
59
+ export const DEFAULT_TURN_ITEMS = [
60
+ { id: 'turnIn', on: true }, // 输入
61
+ { id: 'turnOut', on: true }, // 输出
62
+ { id: 'turnCache', on: true }, // 缓存命中率
63
+ ];
64
+
65
+ export const DEFAULT_STATUS_ITEMS = [
66
+ { id: 'ctx', on: true }, // 上下文占用
67
+ { id: 'questions', on: true }, // 提问次数
68
+ { id: 'todayTokens', on: true }, // 今日 token(今天的会话记录完整,数据准确)
69
+ { id: 'totalTokens', on: true }, // 累计 token(官方使用统计,全历史)
70
+ { id: 'tasks', on: true }, // 任务总数
71
+ ];
72
+
51
73
  // —— ZCode 版本判断(按官方版本启用/停用功能)——
52
74
  // 版本来源:启动器从 app.asar 读出后写进 bootstrap(window.__ZCODEPRO__.zcodeVersion),
53
75
  // helper 的 /config 响应也附加同名字段。读不到时为空串,各判断保持旧行为。
@@ -245,6 +267,11 @@ const zh = {
245
267
  tbPluginStore: '插件市场',
246
268
  featurePinnedCollapse: '已置顶分区可折叠',
247
269
  featurePinnedCollapseDesc: '侧边栏“已置顶”标题可点击折叠/展开其任务列表(与“项目”“分组”分区一致),状态记住。',
270
+ featureSandboxReclaim: '插件界面沙箱回收',
271
+ featureSandboxReclaimDesc: '插件卡片沙箱数量达到上限时自动释放最早创建的(正在查看的排最后),避免 64 个名额耗尽后新卡片打不开;被释放的旧卡片重新打开所在会话即恢复(预算可在配置文件 sandboxReclaimBudget 调整)。',
272
+ featurePluginCardRetry: '插件卡片自动重试',
273
+ featurePluginCardRetryDesc: '因名额上限加载失败的插件卡片自动点击“重新加载”,名额空出后自动恢复,无需手动蹲守。',
274
+ visionCardUi: '思考过程显示识图卡片',
248
275
  pinnedToggleAria: '折叠/展开已置顶',
249
276
  sidebarProjectSpacingName: '项目间距',
250
277
  sidebarProjectSpacingDesc: '侧边栏中相邻项目行之间的视觉间距(含行内留白);设置后各分区(已置顶/项目/任务)之间的间距也统一为该值。',
@@ -265,6 +292,38 @@ const zh = {
265
292
  switcherUnseen: '未查看',
266
293
  switcherAttentionHint: '运行中 / 运行完未查看的任务 · {prev} / {next} 或 ↑ / ↓ 选择,Enter 或松开 alt 切换',
267
294
  switcherAttentionEmpty: '暂无运行中或未查看的任务',
295
+ featureNewtabPanels: '新标签页插件面板入口',
296
+ featureNewtabPanelsDesc: '官方“新标签页”只列官方面板与推荐的官方插件,此功能把第三方市场插件(headroom、rtk 等)的面板卡片加进去,点击在侧边面板打开。',
297
+ ntpDescSuffix: '插件面板',
298
+ ntpNeedSession: '先打开一个任务会话,再从新标签页打开插件面板',
299
+ featureStatusBar: '状态栏统计',
300
+ featureStatusBarDesc: '主界面底部右下角显示统计项:上下文占用、会话/今日/累计 token、任务数等;各项的顺序与显隐在“样式 → 状态栏”里调整。',
301
+ tabStylesPane: '样式调整',
302
+ tabStatusPane: '状态栏',
303
+ sbItemCtx: '上下文占用',
304
+ sbItemQuestions: '提问次数',
305
+ sbGroupSession: '会话统计(跟随当前会话)',
306
+ sbGroupGlobal: '总统计',
307
+ sbGroupTurn: '每轮统计(回答末尾)',
308
+ sbLabelIn: '输入',
309
+ sbLabelOut: '输出',
310
+ sbLabelCache: '缓存',
311
+ featureTurnUsage: '每轮回答 token 用量',
312
+ featureTurnUsageDesc: '在每条回答末尾操作按钮的时间后面显示该轮的输入/输出 token(本地记录只保留最近若干轮,对不上时间的旧回答不显示)。',
313
+ turnUsageTitle: '本轮 token',
314
+ sbLabelQuestions: '提问',
315
+ sbItemTodayTokens: '今日 token',
316
+ sbItemTasks: '任务总数',
317
+ sbLabelCtx: '上下文',
318
+ sbLabelTurns: '调用',
319
+ sbLabelToday: '今日',
320
+ sbLabelTasks: '任务',
321
+ sbItemTotalTokens: '累计 token',
322
+ sbLabelTotal: '累计',
323
+ sbMoveUp: '上移',
324
+ sbMoveDown: '下移',
325
+ sbVisible: '显示',
326
+ sbHidden: '隐藏',
268
327
  tabShortcuts: '快捷键',
269
328
  hkTitle: '会话快捷切换',
270
329
  hkAltZ: '在当前与上次使用的会话间来回切换',
@@ -666,6 +725,11 @@ const en = {
666
725
  tbPluginStore: 'Plugin store',
667
726
  featurePinnedCollapse: 'Collapsible Pinned section',
668
727
  featurePinnedCollapseDesc: 'The sidebar "Pinned" header collapses/expands its task list (like Projects and Groups); the state is remembered.',
728
+ featureSandboxReclaim: 'Plugin card sandbox reclaim',
729
+ featureSandboxReclaimDesc: 'Releases the oldest plugin-card sandboxes once the cap is hit (the one being viewed goes last), so new cards keep opening after the 64-slot pool fills up; released cards come back when their session is reopened (budget adjustable via sandboxReclaimBudget in the config file).',
730
+ featurePluginCardRetry: 'Plugin card auto-retry',
731
+ featurePluginCardRetryDesc: 'Auto-clicks "Reload" on cards that failed the slot cap; they recover as soon as a slot frees up.',
732
+ visionCardUi: 'Vision cards',
669
733
  pinnedToggleAria: 'Collapse/expand Pinned',
670
734
  sidebarProjectSpacingName: 'Project spacing',
671
735
  sidebarProjectSpacingDesc: 'Visual spacing between adjacent project rows in the sidebar (row padding included); when set, section gaps (Pinned/Projects/Tasks) follow the same value.',
@@ -686,6 +750,38 @@ const en = {
686
750
  switcherUnseen: 'unseen',
687
751
  switcherAttentionHint: 'Running / finished but unseen tasks · {prev} / {next} or ↑ / ↓ to choose, Enter or release alt to switch',
688
752
  switcherAttentionEmpty: 'No running or unseen tasks',
753
+ featureNewtabPanels: 'Plugin panels on the new tab page',
754
+ featureNewtabPanelsDesc: "The built-in \"new tab page\" only lists built-in panels and recommended official plugins; this adds cards for third-party marketplace plugin panels (headroom, rtk, …) that open in the side pane.",
755
+ ntpDescSuffix: 'plugin panel',
756
+ ntpNeedSession: 'Open a task session first, then open plugin panels from the new tab page',
757
+ featureStatusBar: 'Status bar statistics',
758
+ featureStatusBarDesc: 'Shows statistics at the bottom-right of the main view: context usage, session/today/total tokens, task count, etc.; reorder or hide each item under "Styles → Status bar".',
759
+ tabStylesPane: 'Styles',
760
+ tabStatusPane: 'Status bar',
761
+ sbItemCtx: 'Context usage',
762
+ sbItemQuestions: 'Questions asked',
763
+ sbGroupSession: 'Session statistics (follows current session)',
764
+ sbGroupGlobal: 'Global statistics',
765
+ sbGroupTurn: 'Per-turn statistics (answer rows)',
766
+ sbLabelIn: 'in',
767
+ sbLabelOut: 'out',
768
+ sbLabelCache: 'cache',
769
+ featureTurnUsage: 'Per-turn token usage',
770
+ featureTurnUsageDesc: "Shows that turn's in/out tokens after the time in each answer's action row (local records keep only the most recent turns; older answers without a match stay unlabelled).",
771
+ turnUsageTitle: 'Turn tokens',
772
+ sbLabelQuestions: 'asks',
773
+ sbItemTodayTokens: 'Today tokens',
774
+ sbItemTasks: 'Tasks',
775
+ sbLabelCtx: 'ctx',
776
+ sbLabelTurns: 'calls',
777
+ sbLabelToday: 'today',
778
+ sbLabelTasks: 'tasks',
779
+ sbItemTotalTokens: 'Total tokens',
780
+ sbLabelTotal: 'total',
781
+ sbMoveUp: 'Up',
782
+ sbMoveDown: 'Down',
783
+ sbVisible: 'shown',
784
+ sbHidden: 'hidden',
689
785
  tabShortcuts: 'Shortcuts',
690
786
  hkTitle: 'Session quick switch',
691
787
  hkAltZ: 'Toggle between the current and the last-used session',
@@ -0,0 +1,224 @@
1
+ // 新标签页插件面板入口:ZCode 3.16.1 的“新标签页”只列官方面板(文件/终端/
2
+ // 辅助对话/浏览器/审查)与云端推荐的官方插件,第三方市场(duanluan/zcode-plugins
3
+ // 等)的面板(headroom、rtk……)注册正常(listSurfaces 有返回)却没有任何入口。
4
+ // 本功能检测新标签页的模块网格,把非官方市场的面板注入成同款卡片;点击走官方
5
+ // openSidePane(App 层注入 Context Provider 的 handleOpenPluginUi,从 React
6
+ // fiber 里按函数源码特征串挖出)在侧边面板打开 tab——与插件市场页“打开”同一条
7
+ // 调用路径,不绕开官方逻辑。
8
+ import { t, getConfig, isZhLocale } from '../core.js';
9
+ import { ensureStyle, showToast } from '../ui.js';
10
+
11
+ // 官方“新标签页”终端模块的描述片段(界面中英文),作为网格识别特征
12
+ const GRID_HINTS = ['运行命令和查看输出', 'Run commands and view output'];
13
+ // 官方市场的 pluginId 后缀:这些面板已有云端推荐入口,不注入
14
+ const OFFICIAL_SUFFIX = '@zcode-plugins-official';
15
+ // handleOpenPluginUi(useCallback 产物)源码里的 logger 字符串,压缩不改动
16
+ // 字面量,以此从多个 Context Provider 中精确挑出打开面板的函数
17
+ const OPEN_FN_MARK = '打开插件 UI';
18
+
19
+ let enabled = true;
20
+ let injectTimer = 0;
21
+
22
+ // —— 从 React fiber 取应用内部对象(ws-running.js findServices 同款遍历) ——
23
+
24
+ function eachFiber(visit) {
25
+ const rootEl = document.getElementById('root');
26
+ const key = rootEl && Object.keys(rootEl).find((k) => k.startsWith('__reactContainer$'));
27
+ if (!key) return;
28
+ let visited = 0;
29
+ const walk = (f) => {
30
+ if (!f || visited > 200000) return;
31
+ visited++;
32
+ if (visit(f)) return true;
33
+ return walk(f.child) || walk(f.sibling);
34
+ };
35
+ walk(rootEl[key]);
36
+ }
37
+
38
+ // Context Provider 的 value 里找打开插件面板的函数(特征:源码含 logger 字符串)
39
+ function findOpenSidePane() {
40
+ let found = null;
41
+ eachFiber((f) => {
42
+ const et = f.elementType;
43
+ if (et && typeof et === 'object' && typeof et.$$typeof === 'symbol'
44
+ && String(et.$$typeof).includes('react.context')) {
45
+ const val = f.memoizedProps && f.memoizedProps.value;
46
+ if (typeof val === 'function' && val.toString().includes(OPEN_FN_MARK)) {
47
+ found = val;
48
+ return true;
49
+ }
50
+ }
51
+ return false;
52
+ });
53
+ return found;
54
+ }
55
+
56
+ // pluginUiBridgeService(listSurfaces 数据源)
57
+ function findBridge() {
58
+ let holder = null;
59
+ const seen = new Set();
60
+ const scan = (o) => {
61
+ if (!o || typeof o !== 'object' || seen.has(o) || holder) return;
62
+ seen.add(o);
63
+ if (typeof o.pluginUiBridgeService === 'object' && o.pluginUiBridgeService) {
64
+ holder = o.pluginUiBridgeService;
65
+ return;
66
+ }
67
+ for (const v of Object.values(o)) if (v && typeof v === 'object') scan(v);
68
+ };
69
+ eachFiber((f) => {
70
+ for (const h of [f.memoizedProps, f.memoizedState]) {
71
+ if (!h || typeof h !== 'object') continue;
72
+ scan(h);
73
+ if (holder) return true;
74
+ }
75
+ return false;
76
+ });
77
+ return holder;
78
+ }
79
+
80
+ // —— 会话与面板清单 ——
81
+
82
+ // 打开面板必须挂在某个会话上(availability: session):优先当前选中会话,
83
+ // 其次侧栏最新一行(新会话更可能已随启动拉起插件的 UI server,老会话的
84
+ // server 目录里没有该插件时会卡在“正在加载插件界面”);一个都没有返回 null
85
+ function sessionScope() {
86
+ const rows = [...document.querySelectorAll('[data-task-item-key]')];
87
+ const cur = rows.find((e) => (e.className + '').includes('bg-selected')) || rows[rows.length - 1];
88
+ const key = cur ? cur.getAttribute('data-task-item-key') : '';
89
+ if (!key) return null;
90
+ const i = key.indexOf(':sess_');
91
+ return i > 0 ? { workspacePath: key.slice(0, i), sessionId: key.slice(i + 1) } : null;
92
+ }
93
+
94
+ async function thirdPartySurfaces(bridge, workspacePath) {
95
+ try {
96
+ const list = await bridge.listSurfaces({ workspacePath });
97
+ return (list || []).filter((s) => !String(s.pluginId || '').endsWith(OFFICIAL_SUFFIX));
98
+ } catch {
99
+ return [];
100
+ }
101
+ }
102
+
103
+ function surfaceTitle(s, locale) {
104
+ const title = s.title;
105
+ const pick = (obj) => {
106
+ const lang = (locale || '').split('-')[0];
107
+ return obj[locale] || (lang && obj[lang])
108
+ || Object.entries(obj).find(([k]) => k.toLowerCase().startsWith(lang || '\0'))?.[1]
109
+ || obj.en || Object.values(obj)[0] || '';
110
+ };
111
+ if (typeof title === 'string') return title;
112
+ if (title && typeof title === 'object') {
113
+ return pick(title) || s.pluginName || s.id;
114
+ }
115
+ return s.pluginName || s.id;
116
+ }
117
+
118
+ // —— 卡片注入 ——
119
+
120
+ // 通用面板图标(lucide layout-dashboard 同款路径),替换模板卡片原图标
121
+ const PANEL_ICON_PATHS = ['<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>'];
122
+
123
+ // 找“新标签页”的模块网格:官方卡片按钮带 data-side-pane-open-tab-item
124
+ // (3.16.1 实测标识),其父容器即网格;文本特征兜底校验(终端模块描述)
125
+ function findNewTabGrid() {
126
+ const item = document.querySelector('[data-side-pane-open-tab-item]');
127
+ if (!item || item.offsetParent === null) return null;
128
+ const grid = item.parentElement;
129
+ if (!grid || grid === document.body) return null;
130
+ const text = grid.textContent || '';
131
+ return GRID_HINTS.some((h) => text.includes(h)) ? grid : null;
132
+ }
133
+
134
+ async function injectPanels(grid) {
135
+ if (grid.dataset.zcodeproPanels) return;
136
+ const template = grid.querySelector('[data-side-pane-open-tab-item]');
137
+ if (!template) return;
138
+ const scope = sessionScope();
139
+ if (!scope) return; // 没有任何会话时暂不注入,下次 DOM 变动再试
140
+ const bridge = findBridge();
141
+ if (!bridge) return;
142
+ const surfaces = await thirdPartySurfaces(bridge, scope.workspacePath);
143
+ if (!surfaces.length) { grid.dataset.zcodeproPanels = '1'; return; }
144
+ grid.dataset.zcodeproPanels = '1';
145
+ const L = t();
146
+ const locale = isZhLocale() ? 'zh-CN' : 'en-US';
147
+ for (const s of surfaces) {
148
+ // 官方卡片整卡即 button:克隆同款,去掉官方标识,挂我们的标识与点击
149
+ const card = template.cloneNode(true);
150
+ card.removeAttribute('data-side-pane-open-tab-item');
151
+ card.setAttribute('data-zcodepro-panel', s.id);
152
+ card.setAttribute('data-zcodepro-panel-name', s.pluginName || '');
153
+ const title = surfaceTitle(s, locale);
154
+ const desc = (s.pluginName || '') + ' · ' + L.ntpDescSuffix;
155
+ const titleEl = card.querySelector('span.font-semibold');
156
+ if (titleEl) titleEl.textContent = title;
157
+ const descEl = card.querySelector('span[title]');
158
+ if (descEl) { descEl.textContent = desc; descEl.setAttribute('title', desc); }
159
+ // 图标换通用面板图形(模板 svg 的描边样式保留)
160
+ const svg = card.querySelector('svg');
161
+ if (svg) {
162
+ svg.innerHTML = PANEL_ICON_PATHS[0];
163
+ svg.setAttribute('viewBox', '0 0 24 24');
164
+ }
165
+ card.addEventListener('click', (ev) => {
166
+ // 克隆卡片会冒泡到官方“新标签页”容器的点击处理(被当成再开一个新标签页),
167
+ // 这里拦下冒泡只走面板打开
168
+ ev.stopPropagation();
169
+ const sc = sessionScope();
170
+ const open = findOpenSidePane();
171
+ if (!sc || !open) {
172
+ showToast(L.ntpNeedSession, 'error');
173
+ return;
174
+ }
175
+ try {
176
+ // 字段与官方市场页 selectSurface 的请求一致;serverName(资源的 MCP
177
+ // server 归属)缺失会让沙箱准备一直停在“正在加载插件界面…”
178
+ open({
179
+ parentSessionId: sc.sessionId,
180
+ pluginId: s.pluginId,
181
+ surfaceId: s.id,
182
+ serverName: s.server,
183
+ resourceUri: s.resourceUri,
184
+ remoteSessionId: null,
185
+ title,
186
+ workspacePath: sc.workspacePath,
187
+ });
188
+ } catch (e) {
189
+ console.warn('[zcodepro] 打开插件面板失败', e);
190
+ showToast(L.ntpNeedSession, 'error');
191
+ }
192
+ });
193
+ grid.append(card);
194
+ }
195
+ }
196
+
197
+ function tryInject() {
198
+ if (!enabled || document.hidden) return;
199
+ const grid = findNewTabGrid();
200
+ if (!grid) return;
201
+ // React 重渲染可能冲掉注入的卡片:标记还在但卡片没了时清标记重注
202
+ if (grid.dataset.zcodeproPanels && !grid.querySelector('[data-zcodepro-panel]')) {
203
+ delete grid.dataset.zcodeproPanels;
204
+ }
205
+ void injectPanels(grid);
206
+ }
207
+
208
+ export function startNewtabPanels() {
209
+ ensureStyle();
210
+ const refreshEnabled = () => {
211
+ void getConfig().then((cfg) => {
212
+ enabled = cfg.features ? cfg.features.newtabPanels !== false : true;
213
+ }).catch(() => {});
214
+ };
215
+ refreshEnabled();
216
+ // 设置里开关变化即时生效(保存时会派发 config-changed)
217
+ window.addEventListener('zcodepro:config-changed', refreshEnabled);
218
+ const observer = new MutationObserver(() => {
219
+ if (injectTimer) return;
220
+ injectTimer = setTimeout(() => { injectTimer = 0; tryInject(); }, 300);
221
+ });
222
+ observer.observe(document.body, { childList: true, subtree: true });
223
+ setInterval(tryInject, 5000);
224
+ }