zcode-pro 1.0.0 → 1.1.0

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.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "ZCode Pro — 为 ZCode 桌面版增加界面增强(不修改客户端文件,通过 ZCode Pro 快捷方式启动即可),核心功能:项目自定义别名",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,7 +17,7 @@ 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.0.0';
20
+ const VERSION = '1.1.0';
21
21
 
22
22
  // 全局提示词固定在用户主目录:官方加载器按 HOME/USERPROFILE 拼 .zcode/AGENTS.md,
23
23
  // 不读 ZCODE_DATA_BASE_DIR(数据根迁走时全局指令仍在原位)。
@@ -339,6 +339,15 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
339
339
  if (body && typeof body === 'object' && body.styles && typeof body.styles === 'object') {
340
340
  current.styles = applyStylesPartial(current.styles, body.styles);
341
341
  }
342
+ // 会话快捷切换的键位(设置 → 快捷键里修改):键名固定,值限单个字母或数字
343
+ if (body && typeof body === 'object' && body.shortcuts && typeof body.shortcuts === 'object') {
344
+ const patch = {};
345
+ for (const k of ['switchLast', 'switchPrev', 'switchNext', 'switchRecent', 'switchAttention']) {
346
+ const v = body.shortcuts[k];
347
+ if (typeof v === 'string' && /^[a-z0-9]$/.test(v)) patch[k] = v;
348
+ }
349
+ current.shortcuts = { ...(current.shortcuts || {}), ...patch };
350
+ }
342
351
  if (body && typeof body === 'object' && body.titleGen && typeof body.titleGen === 'object') {
343
352
  current.titleGen = mergeTitleGenConfig({ ...current.titleGen, ...body.titleGen });
344
353
  }
@@ -38,6 +38,16 @@ export async function getConfig(force = false) {
38
38
  }
39
39
  export function clearConfigCache() { configCache = { value: null, at: 0 }; }
40
40
 
41
+ // 会话快捷切换的默认键位(按住 alt 再按);settings 的“快捷键”标签页可改,
42
+ // 配置存在 config.shortcuts,未写的项回退到这里
43
+ export const DEFAULT_SHORTCUTS = {
44
+ switchLast: 'z', // 立即切到上次会话
45
+ switchPrev: 'x', // 弹窗中上移
46
+ switchNext: 'c', // 弹窗中下移
47
+ switchRecent: 'e', // 打开最近 10 条
48
+ switchAttention: 'r', // 打开运行中 / 未查看
49
+ };
50
+
41
51
  // —— ZCode 版本判断(按官方版本启用/停用功能)——
42
52
  // 版本来源:启动器从 app.asar 读出后写进 bootstrap(window.__ZCODEPRO__.zcodeVersion),
43
53
  // helper 的 /config 响应也附加同名字段。读不到时为空串,各判断保持旧行为。
@@ -190,7 +200,7 @@ const zh = {
190
200
  featureTaskOrder: '侧边栏会话拖动排序',
191
201
  featureTaskOrderDesc: '让置顶、项目与分组中的会话拖动后记住顺序,刷新后保持。',
192
202
  featureSessionSwitch: '会话快捷切换',
193
- featureSessionSwitchDesc: 'alt+z 在当前与上次会话间来回切换;按住 alt 再按 x/c 弹出最近会话列表前后选择,松开 alt 切换(类 alt+tab)。',
203
+ featureSessionSwitchDesc: '{last} 在当前与上次会话间来回切换;按住 alt 再按 {prev}/{next} 弹出最近会话列表前后选择(初始 10 条,{next} 到底再加载 5 条),{recent} 直接打开最近 10 条,{attention} 打开运行中与运行完未查看的任务;方向键同样可选,Enter 或松开 alt 切换(类 alt+tab)',
194
204
  featureTitleLock: '重命名会话名锁定',
195
205
  featureTitleLockDesc: '手动重命名过的会话不再被自动标题覆盖:会话有新回答后界面可能改显自动标题,重启后又变回,本功能让侧栏始终显示重命名后的名字。',
196
206
  tabTitleGen: '任务名提示词',
@@ -218,7 +228,7 @@ const zh = {
218
228
  titleGenTestOk: '生成结果:',
219
229
  titleGenTestFailed: '生成失败',
220
230
  featureWsRunning: '折叠时任务运行中提示',
221
- featureWsRunningDesc: '有会话在运行时,折叠的项目与“任务”“已置顶”等分区头显示与任务运行中同款的加载图标,全部结束后消失;3.16.1 之前每 60 秒会在后台短暂展开项目复核再收起。',
231
+ featureWsRunningDesc: '有会话在运行时,折叠的项目与“任务”“已置顶”等分区头显示与任务运行中同款的加载图标,全部结束后消失。',
222
232
  tabPerformance: '性能',
223
233
  perfDesc: 'ZCode 为每个打开过的项目常驻一个会话进程,应用自身不会回收,项目多了内存占用就高。开启后,长时间未使用且没有任务在跑的项目会被自动释放:会话记录不受影响,再次打开时自动恢复,仅首次回复有几秒等待。正在使用的项目、运行中的会话与后台命令、定时任务都不会被回收。后台命令检测依赖系统进程信息(Linux / macOS / Windows 各走系统自带工具),检测不可用时功能自动停用。',
224
234
  featureIdleReclaim: '空闲项目内存回收',
@@ -246,10 +256,40 @@ const zh = {
246
256
  userFontDesc: '会话中提问内容的字体;选“默认”时跟随界面字体。',
247
257
  assistantFontName: '回答字体',
248
258
  assistantFontDesc: '会话中回答正文的字体(代码块仍用等宽字体);选“默认”时跟随界面字体。',
249
- switcherHint: 'x / c 选择,松开 alt 切换,Esc 取消',
259
+ switcherHint: '{prev} / {next} 或 ↑ / ↓ 选择,Enter 或松开 alt 切换,Esc 取消',
250
260
  switcherCurrent: '当前',
251
261
  switcherEmpty: '暂无上次会话(切换过会话后可用)',
252
262
  switcherFailed: '未能切换到该会话(可能已删除)',
263
+ switcherMore: '还有 {n} 条 · 按 c 加载',
264
+ switcherRunning: '运行中',
265
+ switcherUnseen: '未查看',
266
+ switcherAttentionHint: '运行中 / 运行完未查看的任务 · {prev} / {next} 或 ↑ / ↓ 选择,Enter 或松开 alt 切换',
267
+ switcherAttentionEmpty: '暂无运行中或未查看的任务',
268
+ tabShortcuts: '快捷键',
269
+ hkTitle: '会话快捷切换',
270
+ hkAltZ: '在当前与上次使用的会话间来回切换',
271
+ hkAltX: '弹窗中上移高亮(更早的会话)',
272
+ hkAltC: '弹窗中下移高亮(更新的会话);到底且还有更多时加载 5 条并选中新加载的第一条',
273
+ hkAltE: '打开最近 10 条任务列表',
274
+ hkAltR: '打开正在运行与运行完未查看的任务列表',
275
+ hkArrow: '弹窗打开时移动高亮(与 alt + {prev} / alt + {next} 同规则)',
276
+ hkEnter: '确认切换到高亮的会话',
277
+ hkEsc: '取消并关闭弹窗',
278
+ hkRelease: '移动过高亮时松开即切换;从未移动则弹窗保持打开,继续选择',
279
+ hkReleaseKey: '松开 alt',
280
+ hkModify: '修改',
281
+ hkRecording: '按下新键…',
282
+ hkInvalid: '快捷键只支持单个字母或数字',
283
+ hkConflict: 'alt+{k} 已用于“{name}”',
284
+ hkSaved: '快捷键已更新',
285
+ hkNameLast: '切换上次会话',
286
+ hkNamePrev: '上移高亮',
287
+ hkNameNext: '下移高亮',
288
+ hkNameRecent: '最近 10 条',
289
+ hkNameAttention: '运行中 / 未查看',
290
+ hkEditHint: '点击“修改”后按下新按键(字母或数字),Esc 取消',
291
+ hkReset: '重置为默认',
292
+ hkResetDone: '已恢复默认键位',
253
293
  featureFileActions: '文件菜单增强',
254
294
  featureFileActionsDesc: '在会话中文件链接的右键菜单里新增“默认应用打开”与“打开所在目录”。',
255
295
  featureImageCopy: '图片右键复制',
@@ -374,7 +414,7 @@ const zh = {
374
414
  headroomStoppedWarn: '代理已停止;指向 127.0.0.1 的供应商将无法连接',
375
415
  headroomBackend: '压缩设备',
376
416
  headroomBackendAuto: '自动 · 显卡优先',
377
- headroomBackendCpu: 'CPU · 省电',
417
+ headroomBackendCpu: '省电 · CPU 优先',
378
418
  headroomBackendNative: '(原生值)',
379
419
  headroomBackendDesc: '自动:headroom 选最快设备(CUDA/MPS 显卡优先);CPU:强制 CPU 压缩,省电且不占显存。也支持 headroom 原生值(onnx、pytorch_mps 等)。切换会重启代理立即生效。',
380
420
  headroomPower: '省电自动切换',
@@ -412,7 +452,7 @@ const zh = {
412
452
  aliasSaved: '别名已更新',
413
453
  aliasCleared: '已恢复真实名称',
414
454
  aliasHint: '磁盘目录名不变:终端、文件管理器与其他引用真实路径的界面仍显示原名。',
415
- featureProjectMenuDescNew: '项目“更多”菜单中的切换文件夹、打开文件夹与复制路径。官方已支持修改项目名称(编辑项目),自定义别名已停用。',
455
+ featureProjectMenuDescNew: '项目“更多”菜单中的打开文件夹与复制路径。官方已支持修改项目名称(编辑项目),自定义别名已停用。',
416
456
  aliasMigrateTitle: '迁移项目别名',
417
457
  aliasMigrateDesc: '当前 ZCode 已支持修改项目名称(项目“更多”菜单 → 编辑项目),自定义别名功能已停用。可把已保存的别名写入官方项目名称,勾选要迁移的项目;迁移只改项目名称,磁盘目录不受影响。',
418
458
  aliasMigrateItemHint: '迁移后将作为项目名称保存(可随时在“编辑项目”中再改)。仅无标记的条目可勾选;已迁移、已并入、项目已删除、本机不存在(旧机器路径)以及项目已重命名的都不可勾选——要改这些项目的名字,直接用项目“更多”菜单里的“编辑项目”。',
@@ -581,7 +621,7 @@ const en = {
581
621
  featureTaskOrder: 'Sidebar session drag ordering',
582
622
  featureTaskOrderDesc: 'Makes session drags in Pinned, Projects and Groups persist across refreshes.',
583
623
  featureSessionSwitch: 'Session quick switch',
584
- featureSessionSwitchDesc: 'alt+z toggles between the current and the last session; hold alt and press x/c to move the highlight across recently used sessions, release alt to switch (like alt+tab).',
624
+ featureSessionSwitchDesc: '{last} toggles between the current and the last session; hold alt and press {prev}/{next} to move the highlight across recently used sessions (10 shown initially, pressing {next} at the bottom loads 5 more), {recent} opens the last 10 directly, {attention} opens running / finished-but-unseen tasks; arrow keys also move the highlight, Enter or release alt to switch (like alt+tab)',
585
625
  featureTitleLock: 'Keep renamed session titles',
586
626
  featureTitleLockDesc: 'Sessions you renamed keep their custom title: after new replies the app may show an auto-generated title instead, which reverts after a restart; this keeps the renamed title in the sidebar.',
587
627
  tabTitleGen: 'Task name prompt',
@@ -609,7 +649,7 @@ const en = {
609
649
  titleGenTestOk: 'Result: ',
610
650
  titleGenTestFailed: 'Generation failed',
611
651
  featureWsRunning: 'Running indicator when collapsed',
612
- featureWsRunningDesc: 'Shows the same spinning loader as running tasks on collapsed projects and on section headers (“Tasks”, “Pinned”) while any session is running; clears when they all finish. Before ZCode 3.16.1 the project is briefly re-expanded in the background every 60 s to re-check.',
652
+ featureWsRunningDesc: 'Shows the same spinning loader as running tasks on collapsed projects and on section headers (“Tasks”, “Pinned”) while any session is running; clears when they all finish.',
613
653
  tabPerformance: 'Performance',
614
654
  perfDesc: 'ZCode keeps a session process resident for every project you have opened and never reclaims it, so memory grows with projects. When enabled, projects unused for a while with nothing running release their memory automatically: sessions are untouched and reload when reopened, with a few seconds wait on the first reply. The project in use, running sessions, background commands and scheduled tasks are never reclaimed. Background-command detection reads system process info via built-in tools (Linux / macOS / Windows); when unavailable, the feature disables itself.',
615
655
  featureIdleReclaim: 'Idle project memory reclaim',
@@ -637,10 +677,40 @@ const en = {
637
677
  userFontDesc: 'Font of question text in conversations; "default" follows the UI font.',
638
678
  assistantFontName: 'Answer font',
639
679
  assistantFontDesc: 'Font of answer text in conversations (code blocks stay monospaced); "default" follows the UI font.',
640
- switcherHint: 'x / c to choose, release alt to switch, Esc to cancel',
680
+ switcherHint: '{prev} / {next} or ↑ / ↓ to choose, Enter or release alt to switch, Esc to cancel',
641
681
  switcherCurrent: 'current',
642
682
  switcherEmpty: 'No previous session yet (available after you switch sessions)',
643
683
  switcherFailed: 'Failed to switch to that session (it may have been deleted)',
684
+ switcherMore: '{n} more · press c to load',
685
+ switcherRunning: 'running',
686
+ switcherUnseen: 'unseen',
687
+ switcherAttentionHint: 'Running / finished but unseen tasks · {prev} / {next} or ↑ / ↓ to choose, Enter or release alt to switch',
688
+ switcherAttentionEmpty: 'No running or unseen tasks',
689
+ tabShortcuts: 'Shortcuts',
690
+ hkTitle: 'Session quick switch',
691
+ hkAltZ: 'Toggle between the current and the last-used session',
692
+ hkAltX: 'Move the highlight up (earlier sessions) in the popup',
693
+ hkAltC: 'Move the highlight down (recent sessions); at the bottom it loads 5 more and highlights the first newly loaded entry',
694
+ hkAltE: 'Open the last 10 tasks',
695
+ hkAltR: 'Open running / finished-but-unseen tasks',
696
+ hkArrow: 'Move the highlight while the popup is open (same rules as alt + {prev} / alt + {next})',
697
+ hkEnter: 'Switch to the highlighted session',
698
+ hkEsc: 'Cancel and close the popup',
699
+ hkRelease: 'Releasing alt switches once the highlight has moved; otherwise the popup stays open for further selection',
700
+ hkReleaseKey: 'release alt',
701
+ hkModify: 'Change',
702
+ hkRecording: 'press a key…',
703
+ hkInvalid: 'Shortcuts accept a single letter or digit only',
704
+ hkConflict: 'alt+{k} is already used by "{name}"',
705
+ hkSaved: 'Shortcut updated',
706
+ hkNameLast: 'last session',
707
+ hkNamePrev: 'move up',
708
+ hkNameNext: 'move down',
709
+ hkNameRecent: 'last 10 tasks',
710
+ hkNameAttention: 'running / unseen',
711
+ hkEditHint: 'Click "Change", press a new key (letter or digit); Esc cancels',
712
+ hkReset: 'Reset to defaults',
713
+ hkResetDone: 'Shortcuts reset to defaults',
644
714
  featureFileActions: 'File menu actions',
645
715
  featureFileActionsDesc: 'Adds "Open with default app" and "Reveal in file manager" to the right-click menu of file links in chat.',
646
716
  featureImageCopy: 'Image right-click copy',
@@ -765,7 +835,7 @@ const en = {
765
835
  headroomStoppedWarn: 'Proxy stopped; providers pointing at 127.0.0.1 cannot connect.',
766
836
  headroomBackend: 'Compression device',
767
837
  headroomBackendAuto: 'Auto · GPU first',
768
- headroomBackendCpu: 'CPU · power saving',
838
+ headroomBackendCpu: 'Power saving · CPU first',
769
839
  headroomBackendNative: ' (native)',
770
840
  headroomBackendDesc: 'Auto lets headroom pick the fastest device (CUDA/MPS); CPU forces CPU compression (power saving, no VRAM). Native headroom values (onnx, pytorch_mps, …) also work. Switching restarts the proxy to apply.',
771
841
  headroomPower: 'Power-save auto switch',
@@ -803,7 +873,7 @@ const en = {
803
873
  aliasSaved: 'Alias updated.',
804
874
  aliasCleared: 'Real name restored.',
805
875
  aliasHint: 'The directory name on disk is unchanged: terminals, file managers and other path-based UI still show the real name.',
806
- featureProjectMenuDescNew: 'Switch folder, open folder and copy path in the project "More" menu. Renaming is built in since ZCode 3.16.1 ("Edit project"), so the custom alias is retired.',
876
+ featureProjectMenuDescNew: 'Open folder and copy path in the project "More" menu. Renaming is built in since ZCode 3.16.1 ("Edit project"), so the custom alias is retired.',
807
877
  aliasMigrateTitle: 'Migrate project aliases',
808
878
  aliasMigrateDesc: 'This ZCode supports renaming projects (project "More" menu → Edit project), so the custom alias feature is retired. Write your saved aliases into the official project names by picking the projects below; only the project name changes, folders on disk are untouched.',
809
879
  aliasMigrateItemHint: 'The alias becomes the project name (re-editable anytime via "Edit project"). Only unmarked entries can be checked; migrated, merged, deleted, not-on-this-machine and manually-renamed ones cannot — to rename those projects, use "Edit project" in the project "More" menu directly.',
@@ -0,0 +1,121 @@
1
+ // 插件界面沙箱回收:宿主为每个插件交互卡片(识图卡片、压缩记录、评审面板等)
2
+ // 保留一个沙箱,全局上限 64 个(主进程注册表与窗口页面管理器各一道闸),已卸下
3
+ // 的名额默认要等 6 小时 TTL 才自动回收——多卡片会话很快把名额耗尽,之后所有新
4
+ // 卡片报“插件界面数量已达上限”。本功能平时不动作,只在沙箱数量达到上限
5
+ //(sandboxReclaimBudget,默认 60,8–64;留少量余量,避免新卡片抢名额时恰好撞上
6
+ // 64 硬上限)时,调用官方桥 window.zcodePluginSandbox.disposeSandbox 释放最早
7
+ // 创建的沙箱,把数量压回上限以下:被释放的卡片位置退回占位(可能显示已停止
8
+ // 提示),重新打开所在会话即恢复,会话文本内容不受影响。
9
+ //
10
+ // 驱逐顺序:最早创建的优先,但可见的(正在查看的卡片)排最后——先在不可见
11
+ // 沙箱里挑最早的;全部可见时才动最早创建的可见沙箱。
12
+ //
13
+ // 观察手段均为宿主稳定标识,不依赖压缩后的内部变量名:
14
+ // 1) window 消息 zcode:plugin-sandbox-ports:沙箱端口建立时广播 {sandboxId, initId};
15
+ // 2) webview[data-zcode-retained-sandbox]:src 含 /instance/<sandboxId>/;
16
+ // 3) 可见性:webview 所在页根 div[data-sandbox-page-root] 的 opacity——展示中的
17
+ // 页根 opacity:1 且按卡片占位几何摆放;卡片切走/滚下后宿主把页根收进停靠区
18
+ // ([data-sandbox-page-parking] 下,opacity:0.001 + inert 或 display:none)。
19
+ // 页根是固定定位、DOM 挂载点与视觉位置解耦,不能按父容器判断可见性。
20
+ //
21
+ // 窗口最小化/后台不改变布局尺寸,可见性判定不受影响(最小化期间不驱逐)。
22
+ // 开关:features.sandboxReclaim(默认开)。
23
+ import { getConfig } from '../core.js';
24
+
25
+ const CHECK_MS = 30_000; // 巡检周期
26
+ const MIN_BUDGET = 8;
27
+ const MAX_BUDGET = 64;
28
+ const RETRY_COOLDOWN_MS = 10 * 60_000; // 同一沙箱释放失败后的重试冷却
29
+
30
+ const seen = new Map(); // sandboxId → { initId, firstSeen }
31
+ const cooldown = new Map(); // sandboxId → 释放失败冷却截止时刻
32
+ let disabledLogged = false;
33
+
34
+ function onPortsMessage(e) {
35
+ const d = e.data;
36
+ if (!d || d.type !== 'zcode:plugin-sandbox-ports' || typeof d.sandboxId !== 'string') return;
37
+ // 端口广播≈沙箱创建时刻;页面刷新后宿主按幂等键复用同一沙箱并再次广播,
38
+ // 创建时刻随之刷新(视作新名额重新排队)
39
+ seen.set(d.sandboxId, { initId: Number(d.initId) || 0, firstSeen: Date.now() });
40
+ }
41
+
42
+ function extractSandboxId(wv) {
43
+ const m = /instance\/(sb-[0-9a-f]+)\//.exec(wv.getAttribute('src') || '');
44
+ return m ? m[1] : null;
45
+ }
46
+
47
+ // 展示中的页根 opacity:1 且按卡片占位几何摆放;停靠保活的页根 opacity:0.001 +
48
+ // inert(或 display:none)。宿主用固定定位按占位几何摆放页根,DOM 挂载点不代表
49
+ // 视觉位置(可见卡片的页根也可能挂在停靠容器下),因此按 opacity 与布局判定
50
+ function isVisible(wv) {
51
+ const root = wv.closest('[data-sandbox-page-root]');
52
+ if (!root) return wv.getClientRects().length > 0;
53
+ if (!root.getClientRects().length) return false;
54
+ return Number(getComputedStyle(root).opacity) > 0.5;
55
+ }
56
+
57
+ async function tick() {
58
+ const cfg = await getConfig();
59
+ if ((cfg.features || {}).sandboxReclaim === false) {
60
+ seen.clear();
61
+ return;
62
+ }
63
+ // 沙箱桥只在宿主主窗口存在(guest 沙箱页与旧版宿主没有):不在时静默跳过
64
+ const bridge = window.zcodePluginSandbox;
65
+ if (!bridge || typeof bridge.disposeSandbox !== 'function') {
66
+ if (!disabledLogged) {
67
+ disabledLogged = true;
68
+ console.info('[zcodepro] sandbox-reclaim: 当前窗口无 zcodePluginSandbox 桥,沙箱回收跳过');
69
+ }
70
+ return;
71
+ }
72
+ let budget = Number(cfg.sandboxReclaimBudget);
73
+ if (!Number.isFinite(budget)) budget = 60;
74
+ budget = Math.min(MAX_BUDGET, Math.max(MIN_BUDGET, budget));
75
+
76
+ const now = Date.now();
77
+ const live = new Map(); // sandboxId → 是否可见
78
+ for (const wv of document.querySelectorAll('webview[data-zcode-retained-sandbox]')) {
79
+ const id = extractSandboxId(wv);
80
+ if (id) live.set(id, isVisible(wv));
81
+ }
82
+ // 端口广播登记过但 DOM 已无对应 webview 的条目(guest 已销毁):清掉,防 Map 无限增长
83
+ for (const id of [...seen.keys()]) {
84
+ if (!live.has(id)) seen.delete(id);
85
+ }
86
+ // 重注入后端口广播没赶上(Map 为空)的兜底:首次观察到时按现在起算创建时刻
87
+ for (const id of live.keys()) {
88
+ if (!seen.has(id)) seen.set(id, { initId: undefined, firstSeen: now });
89
+ }
90
+
91
+ while (live.size > budget) {
92
+ // 驱逐顺序:不可见的按创建时间最早的 → 全可见时最早的可见沙箱
93
+ const candidates = [...live.entries()]
94
+ .filter(([id]) => (cooldown.get(id) || 0) <= now)
95
+ .sort((a, b) => (a[1] - b[1]) || ((seen.get(a[0]) || {}).firstSeen || 0) - ((seen.get(b[0]) || {}).firstSeen || 0));
96
+ if (!candidates.length) break;
97
+ const [id] = candidates[0];
98
+ const entry = seen.get(id);
99
+ try {
100
+ await bridge.disposeSandbox(id, entry && entry.initId);
101
+ console.info('[zcodepro] sandbox-reclaim: 沙箱满 ' + live.size + ' 个,已驱逐最早的 ' + id);
102
+ } catch (err) {
103
+ console.warn('[zcodepro] sandbox-reclaim: 释放失败 ' + id, err);
104
+ }
105
+ seen.delete(id);
106
+ live.delete(id);
107
+ cooldown.set(id, now + RETRY_COOLDOWN_MS);
108
+ }
109
+ if (cooldown.size > 256) {
110
+ for (const [k, v] of cooldown) {
111
+ if (v <= now) cooldown.delete(k);
112
+ }
113
+ }
114
+ }
115
+
116
+ export function startSandboxReclaim() {
117
+ window.addEventListener('message', onPortsMessage);
118
+ const loop = () => { tick().catch(() => { /* 单次巡检失败不影响后续 */ }); };
119
+ loop();
120
+ setInterval(loop, CHECK_MS);
121
+ }
@@ -1,17 +1,23 @@
1
1
  // 会话快捷切换:alt+z 立即切到上次使用的会话(再按切回);按住 alt 按 x/c 打开
2
- // MRU 弹窗前后移动高亮(x 更早 / c 更新,两端循环),松开 alt 切换,Esc / 点弹窗外
3
- // 取消,点行即切。MRU(最近使用序)从注入起记录(不回填历史库,上限 50 条),
2
+ // MRU 弹窗前后移动高亮(x 更早 / c 更新,c 到底且还有更多时加载 5 条并选中新
3
+ // 加载的第一条),alt+e 直接打开同一弹窗(初始显示最近 10 条),alt+r 打开
4
+ // “运行中 / 运行完未查看”任务弹窗,松开 alt 切换,Esc / 点弹窗外取消,点行即切。
5
+ // MRU(最近使用序)从注入起记录(不回填历史库,上限 50 条),
4
6
  // 与侧栏展示顺序无关;当前会话靠侧栏条目的 bg-selected 类识别(实测标记),
5
7
  // 事件驱动扫描(会话行增删/选中标记移动才触发)+ 低频兜底轮询——
6
8
  // 点击、新建任务自动跳转等任何方式的切换都能捕捉,聊天区流式输出不产生扫描。
7
9
  // 目标条目在折叠项目下时先展开项目行再点击(展开先例见 pinned-expand.js)。
8
- import { h, t, getConfig } from '../core.js';
10
+ import { h, t, getConfig, DEFAULT_SHORTCUTS } from '../core.js';
9
11
  import { ensureStyle, showToast } from '../ui.js';
10
12
  import { findProjectRow } from './ws-rows.js';
13
+ import { wsRunningKeys } from './ws-running.js';
11
14
 
12
15
  const MRU_LIMIT = 50;
13
16
  const SCAN_DEBOUNCE_MS = 150; // 相关 DOM 变动后的去抖,合并连续变动
14
17
  const SCAN_FALLBACK_MS = 5000; // 兜底轮询周期:仅补观察器覆盖不到的路径
18
+ const INITIAL_COUNT = 10; // 弹窗初始渲染条数(alt+e“最近 10 条”同此)
19
+ const PAGE_SIZE = 5; // c 到底再按时加载的条数
20
+ const UNSEEN_LIMIT = 50;
15
21
 
16
22
  let mru = []; // 会话键(data-task-item-key),最近使用的在最前
17
23
  let current = null; // 当前会话键
@@ -20,6 +26,24 @@ let popupEl = null; // 打开的切换弹窗(null = 未打开)
20
26
  let aliasesCache = {}; // 项目路径 → 别名(打开弹窗时预取,renderPopup 同步使用)
21
27
  let highlight = 0;
22
28
  let scanDebounce = 0;
29
+ let popupMode = 'mru'; // 'mru':最近使用(x/c/e);'attention':运行中 + 未查看(r)
30
+ let visibleCount = INITIAL_COUNT;
31
+ let unseen = new Set(); // 运行结束以来未再切入查看的会话键
32
+ let lastRunKeys = null; // 上次采样的运行键集合(用于识别“运行→结束”的转变)
33
+ let movedSinceOpen = false; // 打开弹窗后是否移动过高亮(决定松开 alt 是提交还是保持)
34
+ // 键位(alt+字母/数字):默认见 core.js,可在设置 → 快捷键修改;
35
+ // 设置保存时会派发 zcodepro:config-changed,这里刷新缓存
36
+ let hotkeys = { ...DEFAULT_SHORTCUTS };
37
+ function loadHotkeys() {
38
+ return getConfig(true).then((cfg) => {
39
+ const sc = cfg && cfg.shortcuts;
40
+ if (sc && typeof sc === 'object') {
41
+ for (const k of Object.keys(DEFAULT_SHORTCUTS)) {
42
+ if (typeof sc[k] === 'string' && /^[a-z0-9]$/.test(sc[k])) hotkeys[k] = sc[k];
43
+ }
44
+ }
45
+ }).catch(() => {});
46
+ }
23
47
 
24
48
  function firstLineOf(item) {
25
49
  // 条目文本含相对时间等杂项,取首个截断类标题元素,兜底 innerText 首行
@@ -41,8 +65,9 @@ function findCurrent() {
41
65
  return null;
42
66
  }
43
67
 
44
- // 记录一次“切到 key”;顺带刷新在场条目的标题缓存
68
+ // 记录一次“切到 key”;顺带刷新在场条目的标题缓存。切入即视为已查看
45
69
  function record(key) {
70
+ if (key) unseen.delete(key);
46
71
  if (key && key !== current) {
47
72
  current = key;
48
73
  mru = mru.filter((k) => k !== key);
@@ -111,13 +136,35 @@ async function switchTo(key) {
111
136
 
112
137
  // —— 切换弹窗(类 alt+tab):居中列表卡,x/c 移动高亮,松开 alt / 点击提交 ——
113
138
 
139
+ // attention 弹窗的列表:正在运行或运行完未查看的任务。MRU 只记录切入过的
140
+ // 会话,而“未查看”的任务可能从未切入,不在 MRU 里——这部分(先运行中、
141
+ // 再未查看,各按发现序)追加在 MRU 命中项之后
142
+ function attentionList() {
143
+ const run = wsRunningKeys();
144
+ const listed = new Set();
145
+ const head = [];
146
+ for (const k of mru) {
147
+ if (run.has(k) || unseen.has(k)) { head.push(k); listed.add(k); }
148
+ }
149
+ const tail = [];
150
+ for (const k of run) if (!listed.has(k)) { tail.push(k); listed.add(k); }
151
+ for (const k of unseen) if (!listed.has(k)) { tail.push(k); listed.add(k); }
152
+ return [...head, ...tail];
153
+ }
154
+
155
+ // 弹窗当前列表:普通模式为 MRU 全量;attention 模式为运行中 + 未查看
156
+ function activeList() {
157
+ return popupMode === 'attention' ? attentionList() : mru;
158
+ }
159
+
114
160
  function closePopup(commit) {
115
161
  if (!popupEl) return;
116
162
  const idx = highlight;
163
+ const list = activeList();
117
164
  popupEl.remove();
118
165
  popupEl = null;
119
- if (commit && idx > 0 && mru[idx] && mru[idx] !== current) {
120
- void switchTo(mru[idx]).then((ok) => { if (!ok) showToast(t().switcherFailed, 'error'); });
166
+ if (commit && list[idx] && list[idx] !== current) {
167
+ void switchTo(list[idx]).then((ok) => { if (!ok) showToast(t().switcherFailed, 'error'); });
121
168
  }
122
169
  }
123
170
 
@@ -139,51 +186,94 @@ function renderPopup() {
139
186
  if (!popupEl) return;
140
187
  const L = t();
141
188
  const list = popupEl.querySelector('[data-zcodepro-switcher-list]');
142
- list.replaceChildren(...mru.map((key, i) => {
189
+ const full = activeList();
190
+ const shown = full.slice(0, visibleCount);
191
+ // attention 模式每行标状态:运行中(绿)/ 未查看(琥珀);普通模式不加
192
+ const run = popupMode === 'attention' ? wsRunningKeys() : null;
193
+ list.replaceChildren(...shown.map((key) => {
143
194
  const proj = projLabelOf(key, aliasesCache);
144
195
  return h('div', {
145
196
  class: 'zcodepro-switcher-row',
146
- 'data-active': i === highlight ? '' : undefined,
147
- onClick: () => { highlight = i; closePopup(true); },
197
+ 'data-active': full[highlight] === key ? '' : undefined,
198
+ onClick: () => { highlight = full.indexOf(key); closePopup(true); },
148
199
  },
149
200
  // 标题文本包 span:截断省略作用在文本节点上(flex 容器自身 ellipsis 无效)
150
201
  h('div', { class: 'zcodepro-switcher-title' },
151
- i === 0 ? h('span', { class: 'zcodepro-switcher-tag' }, L.switcherCurrent) : null,
202
+ key === current ? h('span', { class: 'zcodepro-switcher-tag' }, L.switcherCurrent) : null,
203
+ run && run.has(key) ? h('span', { class: 'zcodepro-switcher-tag zcodepro-switcher-tag-run' }, L.switcherRunning) : null,
204
+ run && !run.has(key) ? h('span', { class: 'zcodepro-switcher-tag zcodepro-switcher-tag-new' }, L.switcherUnseen) : null,
152
205
  h('span', null, findTitle(key))),
153
206
  // 无项目归属(如置顶)时不渲染空行
154
207
  proj ? h('div', { class: 'zcodepro-switcher-ws' }, proj) : null);
155
208
  }));
209
+ if (full.length > shown.length) {
210
+ list.append(h('div', { class: 'zcodepro-switcher-more' },
211
+ L.switcherMore.replaceAll('{n}', String(full.length - shown.length))));
212
+ }
156
213
  }
157
214
 
158
- async function openPopup() {
215
+ async function openPopup(mode = 'mru') {
159
216
  if (popupEl) return;
217
+ popupMode = mode;
218
+ visibleCount = INITIAL_COUNT;
219
+ highlight = 0;
220
+ movedSinceOpen = false;
160
221
  const cfg = await getConfig().catch(() => null);
161
222
  aliasesCache = (cfg && cfg.aliases && typeof cfg.aliases === 'object') ? cfg.aliases : {};
162
223
  ensureStyle();
163
224
  const L = t();
164
- highlight = 0;
165
225
  popupEl = h('div', {
166
226
  class: 'zcodepro-switcher-overlay',
167
227
  // 点弹窗外 = 取消(不切)
168
228
  onMousedown: (e) => { if (e.target === popupEl) closePopup(false); },
169
229
  },
170
230
  h('div', { class: 'zcodepro-switcher' },
171
- h('div', { class: 'zcodepro-switcher-hint' }, L.switcherHint),
231
+ // 提示行键位跟随当前配置(改键后新开的弹窗即用新键位)
232
+ h('div', { class: 'zcodepro-switcher-hint' },
233
+ (mode === 'attention' ? L.switcherAttentionHint : L.switcherHint)
234
+ .replaceAll('{prev}', hotkeys.switchPrev)
235
+ .replaceAll('{next}', hotkeys.switchNext)),
172
236
  h('div', { 'data-zcodepro-switcher-list': '1', class: 'zcodepro-switcher-list' })));
173
237
  document.body.append(popupEl);
174
238
  renderPopup();
175
239
  }
176
240
 
177
- function moveHighlight(delta) {
178
- if (!popupEl || mru.length < 2) return;
179
- highlight = (highlight + delta + mru.length) % mru.length;
180
- renderPopup();
181
- const row = popupEl.querySelector('[data-active]');
241
+ function scrollHighlight() {
242
+ const row = popupEl && popupEl.querySelector('[data-active]');
182
243
  if (row) row.scrollIntoView({ block: 'nearest' });
183
244
  }
184
245
 
246
+ function moveUp() {
247
+ const len = Math.min(activeList().length, visibleCount);
248
+ if (len < 2) return;
249
+ highlight = highlight > 0 ? highlight - 1 : len - 1; // 到顶循环回已加载的末尾
250
+ movedSinceOpen = true;
251
+ renderPopup();
252
+ scrollHighlight();
253
+ }
254
+
255
+ function moveDown() {
256
+ const full = activeList();
257
+ const len = Math.min(full.length, visibleCount);
258
+ if (len < 2) return;
259
+ if (highlight < len - 1) {
260
+ highlight += 1;
261
+ } else if (visibleCount < full.length) {
262
+ // 到底且还有更多:加载 5 条,高亮落在新加载的第一条
263
+ visibleCount = Math.min(visibleCount + PAGE_SIZE, full.length);
264
+ highlight = len;
265
+ } else {
266
+ highlight = 0; // 全部加载完后再到底:循环回顶部
267
+ }
268
+ movedSinceOpen = true;
269
+ renderPopup();
270
+ scrollHighlight();
271
+ }
272
+
185
273
  export function startSessionSwitch() {
186
274
  scan();
275
+ void loadHotkeys();
276
+ window.addEventListener('zcodepro:config-changed', () => { void loadHotkeys(); });
187
277
  // 事件驱动为主:任务条目相关变动(去抖 150ms)立即重扫
188
278
  new MutationObserver((muts) => { if (taskRowMutations(muts)) scheduleScan(); })
189
279
  .observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] });
@@ -191,6 +281,21 @@ export function startSessionSwitch() {
191
281
  setInterval(() => { if (!document.hidden) scan(); }, SCAN_FALLBACK_MS);
192
282
  document.addEventListener('visibilitychange', () => { if (!document.hidden) scan(); });
193
283
 
284
+ // “运行完未查看”跟踪:周期采样运行键集合(ws-running 维护的实时状态),
285
+ // 从运行集合消失(转终态)且结束时不是当前会话的记为未查看;
286
+ // 用户切入该会话(record)即清除。注入前已结束的任务无从判定,不追溯
287
+ setInterval(() => {
288
+ if (document.hidden) return;
289
+ const now = wsRunningKeys();
290
+ if (lastRunKeys) {
291
+ for (const k of lastRunKeys) {
292
+ if (!now.has(k) && k !== current) unseen.add(k);
293
+ }
294
+ }
295
+ lastRunKeys = now;
296
+ if (unseen.size > UNSEEN_LIMIT) unseen = new Set([...unseen].slice(-UNSEEN_LIMIT));
297
+ }, 10_000);
298
+
194
299
  // 点击兜底:bg-selected 标记不可用/未出现时,点条目(含本功能派发的合成点击)即记录
195
300
  document.addEventListener('click', (e) => {
196
301
  const item = e.target instanceof Element && e.target.closest('[data-task-item-key]');
@@ -205,17 +310,29 @@ export function startSessionSwitch() {
205
310
  closePopup(false);
206
311
  return;
207
312
  }
313
+ // 弹窗开着时方向键 / Enter 也可操作(此时用户往往已松开 alt):
314
+ // ↑/↓ 与 x/c 同规则(下到底加载 5 条),Enter 提交高亮项
315
+ if (popupEl && (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Enter')) {
316
+ e.preventDefault();
317
+ e.stopImmediatePropagation();
318
+ if (e.key === 'ArrowUp') moveUp();
319
+ else if (e.key === 'ArrowDown') moveDown();
320
+ else if (!e.repeat) closePopup(true);
321
+ return;
322
+ }
208
323
  if (!e.altKey || e.ctrlKey || e.metaKey || e.isComposing) return;
209
324
  // 我们的设置弹窗开着时不抢键(它有自己的 Esc/焦点逻辑)
210
325
  if (document.querySelector('.zcodepro-overlay')) return;
211
326
  const k = e.key.toLowerCase();
212
- if (k !== 'z' && k !== 'x' && k !== 'c') return;
327
+ const isHot = k === hotkeys.switchLast || k === hotkeys.switchPrev || k === hotkeys.switchNext
328
+ || k === hotkeys.switchRecent || k === hotkeys.switchAttention;
329
+ if (!isHot) return;
213
330
  e.preventDefault();
214
331
  e.stopImmediatePropagation();
215
332
  void (async () => {
216
333
  const cfg = await getConfig(); // 带 3s 缓存
217
334
  if (cfg.features && cfg.features.sessionSwitch === false) return;
218
- if (k === 'z') {
335
+ if (k === hotkeys.switchLast) {
219
336
  if (e.repeat) return;
220
337
  // 即时切换:关掉可能开着的弹窗,切到最近使用的会话(上次使用的会话)
221
338
  closePopup(false);
@@ -231,16 +348,34 @@ export function startSessionSwitch() {
231
348
  void switchTo(target).then((ok) => { if (!ok) showToast(t().switcherFailed, 'error'); });
232
349
  return;
233
350
  }
234
- // x/c:弹窗导航(无可导航会话时与 z 一致轻提示)
351
+ // r:运行中 / 运行完未查看的任务(attention 弹窗)
352
+ if (k === hotkeys.switchAttention) {
353
+ if (e.repeat) return;
354
+ closePopup(false);
355
+ if (!attentionList().length) {
356
+ showToast(t().switcherAttentionEmpty);
357
+ return;
358
+ }
359
+ await openPopup('attention');
360
+ return;
361
+ }
362
+ // x/c/e:最近使用弹窗(无可导航会话时与 z 一致轻提示)
235
363
  if (mru.length < 2) { showToast(t().switcherEmpty); return; }
236
- if (!popupEl) await openPopup();
364
+ // e 只负责打开(初始显示最近 10 条,c 可继续加载更早的)
365
+ if (k === hotkeys.switchRecent) {
366
+ if (!e.repeat && !popupEl) await openPopup('mru');
367
+ return;
368
+ }
369
+ if (!popupEl) await openPopup('mru');
237
370
  // 键位对调:alt+x 上移一位(更靠上/更近的键管向上),alt+c 下移
238
- moveHighlight(k === 'x' ? -1 : 1);
371
+ if (k === hotkeys.switchPrev) moveUp(); else moveDown();
239
372
  })();
240
373
  }, true);
241
374
 
242
375
  document.addEventListener('keyup', (e) => {
243
- if (e.key === 'Alt' && popupEl) closePopup(true); // 松开 alt:提交高亮项
376
+ // 松开 alt:移动过高亮则提交(alt+tab 流);从未移动则保持弹窗打开,
377
+ // 让用户继续用方向键选择(alt+e / alt+r 打开后松手查看的场景)
378
+ if (e.key === 'Alt' && popupEl && movedSinceOpen) closePopup(true);
244
379
  }, true);
245
380
 
246
381
  // 窗口失焦(alt+tab 到别的应用等)视同取消