zcode-pro 1.0.0 → 1.2.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.
@@ -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 到别的应用等)视同取消
@@ -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 } from '../core.js';
3
+ import { h, t, rpc, getConfig, clearConfigCache, errText, HELPER_URL, isZhLocale, isNewProjectSidebar, DEFAULT_SHORTCUTS } 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';
@@ -278,6 +278,40 @@ export function openSettingsDialog() {
278
278
  : L.pluginsUpToDate;
279
279
  })();
280
280
 
281
+ // 会话快捷切换:入口键位(“快捷键”标签页可改);此处定义在 refreshRows 之前,
282
+ // 功能行的描述与快捷键面板共用同一份当前键位
283
+ const HK_ACTIONS = [
284
+ ['switchLast', 'hkAltZ'],
285
+ ['switchPrev', 'hkAltX'],
286
+ ['switchNext', 'hkAltC'],
287
+ ['switchRecent', 'hkAltE'],
288
+ ['switchAttention', 'hkAltR'],
289
+ ];
290
+ const hkNames = () => ({
291
+ switchLast: L.hkNameLast, switchPrev: L.hkNamePrev, switchNext: L.hkNameNext,
292
+ switchRecent: L.hkNameRecent, switchAttention: L.hkNameAttention,
293
+ });
294
+ // 当前生效键位:配置里合法的单字符覆盖默认,其余回退默认
295
+ const hkKeys = () => {
296
+ const sc = config.shortcuts || {};
297
+ const keys = {};
298
+ for (const [a] of HK_ACTIONS) {
299
+ keys[a] = /^[a-z0-9]$/.test(sc[a]) ? sc[a] : DEFAULT_SHORTCUTS[a];
300
+ }
301
+ return keys;
302
+ };
303
+ // 开关描述按当前键位渲染(改键后 refreshRows 重绘即更新);
304
+ // prev/next 出现在“按住 alt 再按 {prev}/{next}”语境,用裸键位避免 alt 重复
305
+ const sessionSwitchDesc = () => {
306
+ const k = hkKeys();
307
+ return L.featureSessionSwitchDesc
308
+ .replaceAll('{last}', 'alt+' + k.switchLast)
309
+ .replaceAll('{prev}', k.switchPrev)
310
+ .replaceAll('{next}', k.switchNext)
311
+ .replaceAll('{recent}', 'alt+' + k.switchRecent)
312
+ .replaceAll('{attention}', 'alt+' + k.switchAttention);
313
+ };
314
+
281
315
  const refreshRows = () => {
282
316
  const f = config.features || {};
283
317
  rows.replaceChildren(
@@ -296,7 +330,7 @@ export function openSettingsDialog() {
296
330
  if (await setFeature('taskOrder', next)) f.taskOrder = next;
297
331
  refreshRows();
298
332
  }),
299
- settingRow(L.featureSessionSwitch, L.featureSessionSwitchDesc, f.sessionSwitch !== false, async () => {
333
+ settingRow(L.featureSessionSwitch, sessionSwitchDesc(), f.sessionSwitch !== false, async () => {
300
334
  const next = !(f.sessionSwitch !== false);
301
335
  if (await setFeature('sessionSwitch', next)) f.sessionSwitch = next;
302
336
  refreshRows();
@@ -326,6 +360,11 @@ export function openSettingsDialog() {
326
360
  if (await setFeature('wsRunningSpin', next)) f.wsRunningSpin = next;
327
361
  refreshRows();
328
362
  }),
363
+ settingRow(L.featureNewtabPanels, L.featureNewtabPanelsDesc, f.newtabPanels !== false, async () => {
364
+ const next = !(f.newtabPanels !== false);
365
+ if (await setFeature('newtabPanels', next)) f.newtabPanels = next;
366
+ refreshRows();
367
+ }),
329
368
  (() => {
330
369
  // 标题 = “自动更新 duanluan/zcode-plugins 插件”,仓库名可点击跳转仓库
331
370
  const repoLink = h('span', {
@@ -398,6 +437,103 @@ export function openSettingsDialog() {
398
437
  h('div', {}, rows),
399
438
  communityCards,
400
439
  );
440
+ // “快捷键”:五个入口键可改(点“修改”后按下新键,字母/数字,Esc 取消),
441
+ // 弹窗内通用键(↑/↓、Enter、Esc、松开 alt)固定只读;开关仍在“功能”标签页。
442
+ // 键位与名称定义(HK_ACTIONS/hkNames/hkKeys)在 refreshRows 前共用
443
+ let hkRecording = null; // 正在录制新键的动作(null = 未在录制)
444
+ let hkKeyHandler = null;
445
+ const hkCard = h('div', { class: 'divide-y divide-border rounded-xl border border-border' });
446
+ const hkCell = (keyLabel, desc, extra) => h('div', { class: 'flex items-start gap-3 px-3 py-2.5' },
447
+ h('kbd', {
448
+ class: 'shrink-0 min-w-20 rounded-md border border-border px-2 py-0.5 text-center text-ui-xs text-foreground-subtle',
449
+ style: 'font-family: ui-monospace, SFMono-Regular, Menlo, monospace; line-height: 1.5;',
450
+ }, keyLabel),
451
+ h('div', { class: 'min-w-0 flex-1 text-ui-sm text-foreground-subtle' }, desc),
452
+ extra ? h('div', { class: 'ml-auto shrink-0 pl-3' }, extra) : null);
453
+ const renderShortcuts = () => {
454
+ const keys = hkKeys();
455
+ const names = hkNames();
456
+ hkCard.replaceChildren(
457
+ ...HK_ACTIONS.map(([a, descKey]) => {
458
+ const recording = hkRecording === a;
459
+ return hkCell(
460
+ recording ? L.hkRecording : 'alt + ' + keys[a],
461
+ L[descKey],
462
+ btnSmall(recording ? L.hkRecording : L.hkModify, () => { recording ? stopHkRecording() : startHkRecording(a); }));
463
+ }),
464
+ hkCell('↑ / ↓', L.hkArrow.replaceAll('{prev}', keys.switchPrev).replaceAll('{next}', keys.switchNext)),
465
+ hkCell('Enter', L.hkEnter),
466
+ hkCell('Esc', L.hkEsc),
467
+ hkCell(L.hkReleaseKey, L.hkRelease));
468
+ };
469
+ const stopHkRecording = () => {
470
+ hkRecording = null;
471
+ if (hkKeyHandler) {
472
+ document.removeEventListener('keydown', hkKeyHandler, true);
473
+ hkKeyHandler = null;
474
+ }
475
+ renderShortcuts();
476
+ };
477
+ const startHkRecording = (action) => {
478
+ hkRecording = action;
479
+ if (!hkKeyHandler) {
480
+ // 绑定本次弹窗实例:弹窗关闭(哪怕马上重开新弹窗)后旧监听器自摘,
481
+ // 不会吞掉新弹窗里的按键
482
+ const myOverlay = document.querySelector('.zcodepro-overlay');
483
+ hkKeyHandler = (e) => {
484
+ if (!myOverlay || !myOverlay.isConnected) { stopHkRecording(); return; }
485
+ if (e.isComposing) return;
486
+ e.preventDefault();
487
+ e.stopImmediatePropagation();
488
+ if (e.key === 'Escape') { stopHkRecording(); return; }
489
+ const k = e.key.toLowerCase();
490
+ if (!/^[a-z0-9]$/.test(k)) { showToast(L.hkInvalid, 'error'); return; }
491
+ const keys = hkKeys();
492
+ const names = hkNames();
493
+ const conflict = HK_ACTIONS.find(([a]) => a !== hkRecording && keys[a] === k);
494
+ if (conflict) {
495
+ showToast(L.hkConflict.replaceAll('{k}', k).replaceAll('{name}', names[conflict[0]]), 'error');
496
+ return;
497
+ }
498
+ const a = hkRecording;
499
+ stopHkRecording();
500
+ void (async () => {
501
+ const res = await rpc('/config', { method: 'POST', body: { shortcuts: { [a]: k } } });
502
+ clearConfigCache();
503
+ window.dispatchEvent(new CustomEvent('zcodepro:config-changed'));
504
+ if (!res.ok) { showToast(L.failed + ': ' + errText(res), 'error'); renderShortcuts(); return; }
505
+ if (res.config && res.config.shortcuts) config.shortcuts = res.config.shortcuts;
506
+ showToast(L.hkSaved, 'success');
507
+ refreshRows(); // 功能标签页的开关描述跟随新键位
508
+ renderShortcuts();
509
+ })();
510
+ };
511
+ document.addEventListener('keydown', hkKeyHandler, true);
512
+ }
513
+ renderShortcuts();
514
+ };
515
+ renderShortcuts();
516
+ // 重置为默认:五个入口键一次性写回默认值,保存路径与单个改键一致
517
+ const resetShortcuts = async () => {
518
+ stopHkRecording(); // 录制进行中点重置:先结束录制再写回默认
519
+ const all = {};
520
+ for (const [a] of HK_ACTIONS) all[a] = DEFAULT_SHORTCUTS[a];
521
+ const res = await rpc('/config', { method: 'POST', body: { shortcuts: all } });
522
+ clearConfigCache();
523
+ window.dispatchEvent(new CustomEvent('zcodepro:config-changed'));
524
+ if (!res.ok) { showToast(L.failed + ': ' + errText(res), 'error'); return; }
525
+ if (res.config && res.config.shortcuts) config.shortcuts = res.config.shortcuts;
526
+ showToast(L.hkResetDone, 'success');
527
+ refreshRows(); // 功能标签页的开关描述跟随默认键位
528
+ renderShortcuts();
529
+ };
530
+ const paneShortcuts = h('div', { role: 'tabpanel', class: 'mt-4', style: 'display:none' },
531
+ h('div', { class: 'mb-2 text-ui-sm font-medium' }, L.hkTitle),
532
+ h('div', { class: 'mb-2 flex items-center gap-2' },
533
+ h('div', { class: 'min-w-0 flex-1 text-ui-xs/relaxed text-foreground-subtle' }, L.hkEditHint),
534
+ h('div', { class: 'shrink-0' }, btnSmall(L.hkReset, () => { void resetShortcuts(); }))),
535
+ hkCard,
536
+ );
401
537
  const paneStyles = h('div', { role: 'tabpanel', class: 'mt-4', style: 'display:none' });
402
538
  const panePerf = h('div', { role: 'tabpanel', class: 'mt-4', style: 'display:none' });
403
539
  const paneAgents = h('div', { role: 'tabpanel', class: 'mt-2', style: 'display:none' });
@@ -408,9 +544,10 @@ export function openSettingsDialog() {
408
544
  const paneHeadroom = h('div', { role: 'tabpanel', class: 'mt-4', style: 'display:none' });
409
545
  const paneTitleGen = h('div', { role: 'tabpanel', class: 'mt-4', style: 'display:none' });
410
546
  const paneDebug = h('div', { role: 'tabpanel', class: 'mt-4', style: 'display:none' });
411
- const panes = { features: paneFeatures, styles: paneStyles, perf: panePerf, agents: paneAgents, proxy: paneProxy, sync: paneSync, vision: paneVision, headroom: paneHeadroom, rtk: paneRtk, debug: paneDebug };
547
+ const panes = { features: paneFeatures, shortcuts: paneShortcuts, styles: paneStyles, perf: panePerf, agents: paneAgents, proxy: paneProxy, sync: paneSync, vision: paneVision, headroom: paneHeadroom, rtk: paneRtk, debug: paneDebug };
412
548
  const tabDefs = [
413
549
  ['features', L.tabFeatures],
550
+ ['shortcuts', L.tabShortcuts],
414
551
  ['agents', L.tabBehavior],
415
552
  ['styles', L.tabStyles],
416
553
  ['perf', L.tabPerformance],
@@ -433,6 +570,8 @@ export function openSettingsDialog() {
433
570
  }, label)));
434
571
  const switchTab = (name) => {
435
572
  activeTab = name;
573
+ // 切走“快捷键”标签页时结束键位录制:否则在其它标签页打字会被录制监听吞掉
574
+ if (name !== 'shortcuts' && hkRecording) stopHkRecording();
436
575
  for (const [id, pane] of Object.entries(panes)) pane.style.display = id === name ? '' : 'none';
437
576
  renderTabs();
438
577
  };
@@ -2256,6 +2395,7 @@ export function openSettingsDialog() {
2256
2395
  statusLine,
2257
2396
  tablist,
2258
2397
  paneFeatures,
2398
+ paneShortcuts,
2259
2399
  paneAgents,
2260
2400
  paneStyles,
2261
2401
  panePerf,
@@ -640,6 +640,13 @@ export function wsRunningSnapshot() {
640
640
  };
641
641
  }
642
642
 
643
+ // 当前所有运行中会话键的集合(会话切换的 alt+r 以此筛“正在运行”的任务)
644
+ export function wsRunningKeys() {
645
+ const keys = new Set();
646
+ for (const set of runningByWs.values()) for (const k of set) keys.add(k);
647
+ return keys;
648
+ }
649
+
643
650
  // 无感复核指定工作区当前是否仍有运行中会话(展开读行后原样收起)。
644
651
  // 返回 true = 确认无运行;false = 有运行、窗口隐藏或行不可达(一律视为忙,调用方不得回收)
645
652
  export async function verifyWorkspaceNotRunning(ws) {
@@ -10,6 +10,7 @@ import { startAliasWatcher } from './features/alias.js';
10
10
  import { startAliasMigrationWatcher } from './features/alias-migration.js';
11
11
  import { startTaskOrderWatcher } from './features/task-order.js';
12
12
  import { startSessionSwitch } from './features/session-switch.js';
13
+ import { startNewtabPanels } from './features/newtab-panels.js';
13
14
  import { startTitleLock } from './features/title-lock.js';
14
15
  import { startTitleGen } from './features/title-gen.js';
15
16
  import { startWsRunningSpin } from './features/ws-running.js';
@@ -58,6 +59,7 @@ import { ensureStyle } from './ui.js';
58
59
  try { startWsRunningSpin(); } catch { /* ignore */ }
59
60
  try { startIdleReclaim(); } catch { /* ignore */ }
60
61
  try { startToolbarIcons(); } catch { /* ignore */ }
62
+ try { startNewtabPanels(); } catch { /* ignore */ }
61
63
  try { startPinnedCollapse(); } catch { /* ignore */ }
62
64
  try { startStyleAdjustments(); } catch { /* ignore */ }
63
65
  try { startSettingsEntry(); } catch { /* ignore */ }
package/src/inject/ui.js CHANGED
@@ -616,6 +616,20 @@ export function ensureStyle() {
616
616
  background-color: color-mix(in oklab, var(--color-primary, #111) 12%, transparent);
617
617
  color: var(--color-foreground-subtle, #666);
618
618
  }
619
+ /* attention 弹窗(alt+r)的行状态标签:运行中绿、未查看琥珀 */
620
+ .zcodepro-switcher-tag-run {
621
+ background-color: color-mix(in oklab, var(--color-emerald-500, #10b981) 20%, transparent);
622
+ }
623
+ .zcodepro-switcher-tag-new {
624
+ background-color: color-mix(in oklab, var(--color-amber-500, #f59e0b) 22%, transparent);
625
+ }
626
+ /* 列表底部“还有 N 条”提示行 */
627
+ .zcodepro-switcher-more {
628
+ padding: 6px 12px 2px;
629
+ font-size: 11px;
630
+ text-align: center;
631
+ color: var(--color-muted-foreground, #888);
632
+ }
619
633
  .zcodepro-switcher-ws {
620
634
  font-size: 11px;
621
635
  color: var(--color-muted-foreground, #888);