dsh-vibegap 0.1.1 → 0.1.3

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.
Files changed (3) hide show
  1. package/README.md +16 -4
  2. package/lib/client.js +186 -41
  3. package/package.json +4 -1
package/README.md CHANGED
@@ -3,8 +3,10 @@
3
3
  VibeGap 的 DeepSeek Harness 原生插件。Agent 持续运行 18 秒后,dsh web
4
4
  右下角会出现拼写单词卡;会话完成或等待确认时,完成当前单词后自动收起或继续。
5
5
  插件可独立运行,不要求安装 VibeGap Python 桌面端。
6
- 如果本机 VibeGap daemon 正在运行,插件会自动改用桌面端的当前词书和进度游标;
7
- 连接失败或运行中断线时,会回退到浏览器内的独立进度。
6
+ 如果本机 VibeGap Core 正在运行,插件会自动改用桌面端的当前词书和进度游标;
7
+ Core 在页面打开后才由另一个 Agent 拉起也能自动接入。为避免两张卡重复出现,
8
+ 共享模式下页内卡不自动弹出,但仍可用 `Ctrl+Alt+V` 手动呼出。运行中断线时
9
+ 保留当前词并自动重连,不会误跳到首次下载界面。
8
10
 
9
11
  ## 安装
10
12
 
@@ -42,8 +44,11 @@ dsh plugin --profile web remove dsh-vibegap
42
44
  自动发音偏好通过 dsh 官方 snapshot store 保存在当前浏览器中。无痕模式或浏览器
43
45
  拒绝持久化时,卡片仍可使用,但刷新后进度可能丢失。
44
46
 
45
- 本机 daemon 可用时,单词和进度改由 `http://127.0.0.1:8765/panel/*` 提供,
46
- 仅允许来自 `localhost` 或 `127.0.0.1` 的 HTTP(S) 浏览器 Origin。
47
+ 本机 Core 可用时,单词和进度改由 `http://127.0.0.1:8765/panel/*` 提供,
48
+ 插件每 5 秒低频探测一次,且仅允许来自 `localhost` 或 `127.0.0.1` 的
49
+ HTTP(S) 浏览器 Origin。DSH 单独使用时不会因此启动 Python/Core 进程。
50
+ 共享模式目前只发现默认端口 `8765`;若桌面端修改了 `daemon_port`,DSH 会保持
51
+ 独立本地模式。
47
52
 
48
53
  ## 使用
49
54
 
@@ -51,6 +56,7 @@ dsh plugin --profile web remove dsh-vibegap
51
56
  - 输入错误会清空当前拼写并计数。
52
57
  - `Tab` 显示或隐藏答案;看过答案后,本词按 fail 完成并前进。
53
58
  - `Esc` 或右上角 `×` 隐藏卡片;同一批运行中的会话不会再次弹出。
59
+ - `Ctrl+Alt+V` 手动呼出或隐藏卡片(dsh 标签页处于前台时有效,不受 18 秒规则限制)。
54
60
  - “发音”可手动播放美式发音,“自动发音”可随时开关。
55
61
 
56
62
  ## 开发
@@ -59,6 +65,12 @@ dsh plugin --profile web remove dsh-vibegap
59
65
  或其他构建步骤。修改 bundle 后刷新页面;修改 `package.json` 或
60
66
  `cordis.patch.yml` 后重启 `dsh web`。
61
67
 
68
+ 纯逻辑与 manifest 兼容性测试:
69
+
70
+ ```bash
71
+ npm test
72
+ ```
73
+
62
74
  服务端 `lib/index.js` 会在本机 VibeGap daemon 存在时继续上报 agent 生命周期;
63
75
  连接失败会静默降级,不影响 dsh。
64
76
 
package/lib/client.js CHANGED
@@ -18,6 +18,7 @@ window.__ModuleLoader__.load({
18
18
  var TRANSLATION_MAX_CHARS = 100;
19
19
  var DAEMON_URL = "http://127.0.0.1:8765/panel";
20
20
  var DAEMON_TIMEOUT_MS = 1000;
21
+ var DAEMON_PROBE_MS = 5000;
21
22
  var DICT_URL = "https://raw.githubusercontent.com/RealKai42/qwerty-learner/master/public/dicts/CET6_T.json";
22
23
  var STYLE_ID = "vg-card-styles";
23
24
  var DONE_NOTICE = "会话已完成 · 拼完当前词后收起";
@@ -34,12 +35,17 @@ window.__ModuleLoader__.load({
34
35
  { autoPronounce: true },
35
36
  { persist: { name: "vibegap.prefs" } },
36
37
  );
38
+ var posStore = runtime.createSnapshotStore(
39
+ { x: null, y: null },
40
+ { persist: { name: "vibegap.pos" } },
41
+ );
37
42
 
38
43
  var css = [
39
44
  ".vg-card{position:fixed;right:24px;bottom:24px;width:min(390px,calc(100vw - 32px));box-sizing:border-box;padding:16px;border:1px solid var(--dsw-alias-border-l,#d8d8d8);border-radius:14px;background:var(--dsw-alias-bg-base,#fff);color:var(--dsw-alias-label-primary,#202020);box-shadow:0 12px 34px rgba(0,0,0,.18);font-family:inherit;pointer-events:auto;outline:none;z-index:1}",
40
45
  ".vg-card:focus{border-color:var(--dsw-alias-border-focus,#5794ff);box-shadow:0 12px 34px rgba(0,0,0,.18),0 0 0 2px rgba(87,148,255,.2)}",
41
- ".vg-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}",
46
+ ".vg-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px;cursor:move;user-select:none;touch-action:none}",
42
47
  ".vg-title{font-size:13px;font-weight:650;letter-spacing:.02em}",
48
+ ".vg-prog{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:right;font-size:11px;color:var(--dsw-alias-label-tertiary,#888)}",
43
49
  ".vg-close{min-width:28px!important;padding:0!important;font-size:18px!important}",
44
50
  ".vg-banner{margin:-2px 0 14px;padding:8px 10px;border-radius:8px;background:var(--dsw-alias-interactive-bg-hover,#f2f3f5);font-size:12px;line-height:1.4}",
45
51
  ".vg-word{display:flex;flex-wrap:wrap;justify-content:center;gap:4px;min-height:48px;margin:16px 0 12px}",
@@ -53,6 +59,7 @@ window.__ModuleLoader__.load({
53
59
  ".vg-hint{margin-top:12px;text-align:center;color:var(--dsw-alias-label-tertiary,#888);font-size:11px}",
54
60
  ".vg-empty{padding:18px 4px 8px;text-align:center;color:var(--dsw-alias-label-secondary,#666);font-size:13px;line-height:1.6}",
55
61
  ".vg-error{margin-top:10px;color:var(--dsw-alias-state-danger-primary,#c93c37);font-size:12px}",
62
+ ".vg-reconnect{margin:8px 0 0;text-align:center;color:var(--dsw-alias-state-warning-primary,#9a6700);font-size:12px}",
56
63
  ".vg-shake{animation:vg-shake .28s ease-in-out}",
57
64
  "@keyframes vg-blink{50%{border-color:transparent}}",
58
65
  "@keyframes vg-shake{25%{transform:translateX(-5px)}75%{transform:translateX(5px)}}",
@@ -180,21 +187,49 @@ window.__ModuleLoader__.load({
180
187
  if (payload.error) throw new Error(payload.error);
181
188
  return payload;
182
189
  }
190
+ function syncLocalProgress(remoteProgress) {
191
+ if (!remoteProgress || !Number.isFinite(Number(remoteProgress.cursor))) return;
192
+ progressStore.update(function (draft) {
193
+ if (!Array.isArray(draft.words) || draft.words.length === 0) return;
194
+ if (Number(remoteProgress.total) !== draft.words.length) return;
195
+ draft.cursor = Math.min(Math.max(Number(remoteProgress.cursor), 0), draft.words.length - 1);
196
+ });
197
+ }
198
+ function daemonFailureState(current) {
199
+ if (current.mode === "daemon" || current.mode === "disconnected") {
200
+ return { mode: "disconnected", word: current.word };
201
+ }
202
+ if (current.mode === "probing") return { mode: "local", word: null };
203
+ return current;
204
+ }
183
205
  function useDaemonBackend() {
184
206
  var backend = React.useState({ mode: "probing", word: null });
207
+ var current = useCell({ mode: "probing", word: null });
208
+ var setBackend = function (next) { current.current = next; backend[1](next); };
185
209
  React.useEffect(function () {
186
210
  var active = true;
187
- (async function () {
211
+ var timer = null;
212
+ async function probe() {
188
213
  try {
189
214
  var state = await panelRequest("/state");
190
215
  if (!state.ready) throw new Error("daemon has no wordbook");
191
- var word = await panelRequest("/next-word");
192
- if (active) backend[1]({ mode: "daemon", word: word });
193
- } catch (_) { if (active) backend[1]({ mode: "local", word: null }); }
194
- })();
195
- return function () { active = false; };
216
+ syncLocalProgress(state.progress);
217
+ var cursor = state.progress && Number(state.progress.cursor);
218
+ var known = current.current.word && Number(current.current.word.position);
219
+ if (current.current.mode !== "daemon" || cursor !== known) {
220
+ var word = await panelRequest("/next-word");
221
+ if (active) setBackend({ mode: "daemon", word: word });
222
+ }
223
+ } catch (_) {
224
+ var failed = daemonFailureState(current.current);
225
+ if (active && failed !== current.current) setBackend(failed);
226
+ }
227
+ if (active) timer = setTimeout(probe, DAEMON_PROBE_MS);
228
+ }
229
+ probe();
230
+ return function () { active = false; if (timer) clearTimeout(timer); };
196
231
  }, []);
197
- return backend;
232
+ return [backend[0], setBackend];
198
233
  }
199
234
 
200
235
  function advanceProgress(total) {
@@ -232,10 +267,16 @@ window.__ModuleLoader__.load({
232
267
  );
233
268
  }
234
269
 
235
- function useLifecycle(sessionState, visible, setVisible, showBanner, onRestart) {
270
+ function canAutoPopup(backendMode) {
271
+ return backendMode !== "daemon" && backendMode !== "disconnected";
272
+ }
273
+
274
+ function useLifecycle(sessionState, visible, setVisible, showBanner, onRestart, allowAutoPopup) {
236
275
  var ref = useCell({ running: false, rows: {}, suppressed: false, arm: null, pending: null });
237
276
  var runningRef = useCell(false);
238
277
  var visibleRef = useCell(visible);
278
+ var autoPopupRef = useCell(allowAutoPopup);
279
+ autoPopupRef.current = allowAutoPopup;
239
280
  React.useEffect(function () { visibleRef.current = visible; }, [visible]);
240
281
  React.useEffect(function () {
241
282
  var anyRunning = sessionState.ids.some(function (id) {
@@ -247,7 +288,7 @@ window.__ModuleLoader__.load({
247
288
  life.suppressed = false; life.pending = null;
248
289
  onRestart();
249
290
  if (!visibleRef.current) life.arm = setTimeout(function () {
250
- if (!runningRef.current || life.suppressed) return;
291
+ if (!runningRef.current || life.suppressed || !autoPopupRef.current) return;
251
292
  setVisible(true);
252
293
  if (life.pending) { showBanner(life.pending); life.pending = null; }
253
294
  }, POPUP_DELAY_MS);
@@ -267,34 +308,47 @@ window.__ModuleLoader__.load({
267
308
  return { state: ref, running: runningRef };
268
309
  }
269
310
 
311
+ function createTypingHandler(configRef) {
312
+ return function onKey(event) {
313
+ var config = configRef.current;
314
+ if (document.activeElement !== config.card.current) return;
315
+ if (event.key === "Escape") { event.preventDefault(); config.hide(); return; }
316
+ if (event.key === "Tab") {
317
+ event.preventDefault(); config.setPeeked(true); config.setTyped(0);
318
+ config.setRevealed(function (value) { return !value; }); return;
319
+ }
320
+ if (event.key.length !== 1 || !/[a-zA-Z'\- ]/.test(event.key)) return;
321
+ event.preventDefault();
322
+ if (config.revealed) config.setRevealed(false);
323
+ if (event.key.toLowerCase() === config.word[config.typed].toLowerCase()) {
324
+ var next = config.typed + 1; config.setTyped(next);
325
+ if (next === config.word.length) config.complete();
326
+ return;
327
+ }
328
+ config.setTypos(function (value) { return value + 1; });
329
+ config.setTyped(0); config.shake();
330
+ };
331
+ }
332
+
270
333
  function useTyping(config) {
334
+ var configRef = useCell(config);
335
+ configRef.current = config;
271
336
  React.useEffect(function () {
272
337
  if (!config.visible || !config.focused || !config.word || config.busy) return;
273
- function onKey(event) {
274
- if (document.activeElement !== config.card.current) return;
275
- if (event.key === "Escape") { event.preventDefault(); config.hide(); return; }
276
- if (event.key === "Tab") {
277
- event.preventDefault(); config.setPeeked(true); config.setTyped(0);
278
- config.setRevealed(function (value) { return !value; }); return;
279
- }
280
- if (event.key.length !== 1 || !/[a-zA-Z'\- ]/.test(event.key)) return;
281
- event.preventDefault();
282
- if (config.revealed) config.setRevealed(false);
283
- if (event.key.toLowerCase() === config.word[config.typed].toLowerCase()) {
284
- var next = config.typed + 1; config.setTyped(next);
285
- if (next === config.word.length) config.complete();
286
- return;
287
- }
288
- config.setTypos(function (value) { return value + 1; });
289
- config.setTyped(0); config.shake();
290
- }
338
+ var onKey = createTypingHandler(configRef);
291
339
  document.addEventListener("keydown", onKey);
292
340
  return function () { document.removeEventListener("keydown", onKey); };
293
- }, [config]);
341
+ }, [config.visible, config.focused, config.word, config.busy]);
294
342
  }
295
343
 
296
344
  function CardBody(props) {
297
- if (!props.word) return h(EmptyCard, { status: props.download, setStatus: props.setDownload });
345
+ if (!props.word) {
346
+ if (props.connectionLost) {
347
+ return h("div", { className: "vg-empty", role: "status" },
348
+ "桌面端连接已断开,正在自动重连");
349
+ }
350
+ return h(EmptyCard, { status: props.download, setStatus: props.setDownload });
351
+ }
298
352
  var translation = props.word.trans.join(";");
299
353
  return h(React.Fragment, null,
300
354
  h(LetterGrid, {
@@ -304,15 +358,17 @@ window.__ModuleLoader__.load({
304
358
  h("div", { className: "vg-trans", title: translation }, translation.slice(0, TRANSLATION_MAX_CHARS)),
305
359
  h("div", { className: "vg-meta" },
306
360
  props.word.usphone ? "/" + props.word.usphone + "/" : null,
307
- props.typos ? "错误 " + props.typos : "尚无错误",
361
+ props.typos ? "错 " + props.typos : null,
308
362
  props.outcome ? h("span", { role: "status" }, props.outcome === "fail" ? "已记为需复习" : "拼写正确") : null,
309
363
  ),
364
+ props.connectionLost ? h("div", { className: "vg-reconnect", role: "status" },
365
+ "桌面端连接已断开,当前词会在重连后继续") : null,
310
366
  h("div", { className: "vg-actions" },
311
367
  h(Button, { size: "sm", variant: "outline", onClick: props.pronounce }, "发音"),
312
368
  h(Button, { size: "sm", variant: "ghost", onClick: props.toggleAuto },
313
369
  "自动发音:" + (props.autoPronounce ? "开" : "关")),
314
370
  ),
315
- h("div", { className: "vg-hint" }, "点击卡片后拼写 · Tab 查看答案 · Esc 隐藏"),
371
+ h("div", { className: "vg-hint" }, "点击卡片后拼写 · Tab 查看答案 · Esc 隐藏 · Ctrl+Alt+V 呼出"),
316
372
  );
317
373
  }
318
374
 
@@ -323,6 +379,7 @@ window.__ModuleLoader__.load({
323
379
  peeked: React.useState(false), shaking: React.useState(false), busy: React.useState(false),
324
380
  outcome: React.useState(""), holdWord: React.useState(null),
325
381
  download: React.useState({ busy: false, error: "" }),
382
+ pos: React.useState(null),
326
383
  };
327
384
  }
328
385
 
@@ -341,12 +398,18 @@ window.__ModuleLoader__.load({
341
398
  function activeSelection(local, backend) {
342
399
  var remote = backend[0];
343
400
  if (remote.mode === "daemon" && remote.word) {
344
- return { ordered: [remote.word], word: remote.word, isDaemon: true };
401
+ return { ordered: [remote.word], word: remote.word, isDaemon: true,
402
+ connectionLost: false, remoteUnavailable: false };
403
+ }
404
+ if (remote.mode === "disconnected") {
405
+ return { ordered: remote.word ? [remote.word] : [], word: remote.word, isDaemon: true,
406
+ connectionLost: true, remoteUnavailable: true };
345
407
  }
346
- return { ordered: local.ordered, word: local.word, isDaemon: false };
408
+ return { ordered: local.ordered, word: local.word, isDaemon: false,
409
+ connectionLost: false, remoteUnavailable: false };
347
410
  }
348
411
 
349
- function useLifecycleBridge(sessionState, states, refs) {
412
+ function useLifecycleBridge(sessionState, states, refs, allowAutoPopup) {
350
413
  var showBanner = function (message) {
351
414
  refs.banner.current = message; states.banner[1](message);
352
415
  };
@@ -355,7 +418,9 @@ window.__ModuleLoader__.load({
355
418
  clearTimeout(refs.hideTimer.current); refs.hideTimer.current = null;
356
419
  showBanner(null); resetInput(states);
357
420
  };
358
- var tracker = useLifecycle(sessionState, states.visible[0], states.visible[1], showBanner, onRestart);
421
+ var tracker = useLifecycle(
422
+ sessionState, states.visible[0], states.visible[1], showBanner, onRestart, allowAutoPopup,
423
+ );
359
424
  return { tracker: tracker, showBanner: showBanner };
360
425
  }
361
426
  function resetInput(states) {
@@ -398,16 +463,17 @@ window.__ModuleLoader__.load({
398
463
  advanceProgress(model.selection.ordered.length); return;
399
464
  }
400
465
  try {
401
- await panelRequest("/commit", {
466
+ var result = await panelRequest("/commit", {
402
467
  method: "POST", headers: { "Content-Type": "application/json" },
403
468
  body: JSON.stringify({
404
469
  result: model.states.peeked[0] ? "fail" : "pass",
405
470
  typo_count: model.states.typos[0],
406
471
  }),
407
472
  });
473
+ syncLocalProgress(result);
408
474
  var word = await panelRequest("/next-word");
409
475
  model.backend[1]({ mode: "daemon", word: word });
410
- } catch (_) { model.backend[1]({ mode: "local", word: null }); }
476
+ } catch (_) { model.backend[1](daemonFailureState(model.backend[0])); }
411
477
  }
412
478
 
413
479
  function shakeCard(states) {
@@ -415,6 +481,31 @@ window.__ModuleLoader__.load({
415
481
  setTimeout(function () { states.shaking[1](false); }, SHAKE_MS);
416
482
  }
417
483
 
484
+ function startDrag(model, event) {
485
+ if (event.target.closest("button,a,input,textarea,select")) return;
486
+ var card = model.refs.card.current;
487
+ if (!card || event.button !== 0) return;
488
+ event.preventDefault();
489
+ var rect = card.getBoundingClientRect();
490
+ var offsetX = event.clientX - rect.left;
491
+ var offsetY = event.clientY - rect.top;
492
+ var last = null;
493
+ function move(ev) {
494
+ last = {
495
+ x: Math.min(Math.max(ev.clientX - offsetX, 0), Math.max(window.innerWidth - rect.width, 0)),
496
+ y: Math.min(Math.max(ev.clientY - offsetY, 0), Math.max(window.innerHeight - 48, 0)),
497
+ };
498
+ model.states.pos[1](last);
499
+ }
500
+ function up() {
501
+ document.removeEventListener("pointermove", move);
502
+ document.removeEventListener("pointerup", up);
503
+ if (last) try { posStore.set(last); } catch (_) {}
504
+ }
505
+ document.addEventListener("pointermove", move);
506
+ document.addEventListener("pointerup", up);
507
+ }
508
+
418
509
  function useCardEffects(model) {
419
510
  var word = displayedWord(model);
420
511
  React.useEffect(function () { resetInput(model.states); }, [word && word.name]);
@@ -424,6 +515,29 @@ window.__ModuleLoader__.load({
424
515
  React.useEffect(function () {
425
516
  return function () { if (model.refs.hideTimer.current) clearTimeout(model.refs.hideTimer.current); };
426
517
  }, []);
518
+ React.useEffect(function () {
519
+ // 页面内手动唤醒:Ctrl+Alt+V 唤出/隐藏(dsh 标签页聚焦时有效)
520
+ function onSummon(event) {
521
+ if (!event.ctrlKey || !event.altKey || event.key.toLowerCase() !== "v") return;
522
+ event.preventDefault();
523
+ if (model.states.visible[0]) { hideCard(model, true); return; }
524
+ model.lifecycle.tracker.state.current.suppressed = false;
525
+ model.states.visible[1](true);
526
+ }
527
+ document.addEventListener("keydown", onSummon);
528
+ return function () { document.removeEventListener("keydown", onSummon); };
529
+ }, [model.states.visible[0]]);
530
+ React.useEffect(function () {
531
+ // 恢复上次拖到的位置;越出当前视口则放弃,回默认右下角
532
+ try {
533
+ var saved = posStore.getSnapshot();
534
+ if (saved && Number.isFinite(saved.x) && Number.isFinite(saved.y) &&
535
+ saved.x >= 0 && saved.y >= 0 &&
536
+ saved.x < window.innerWidth - 60 && saved.y < window.innerHeight - 48) {
537
+ model.states.pos[1]({ x: saved.x, y: saved.y });
538
+ }
539
+ } catch (_) {}
540
+ }, []);
427
541
  }
428
542
 
429
543
  function useCardTyping(model) {
@@ -432,7 +546,8 @@ window.__ModuleLoader__.load({
432
546
  useTyping({
433
547
  visible: states.visible[0], focused: states.focused[0],
434
548
  word: word && word.name,
435
- busy: states.busy[0], typed: states.typed[0], revealed: states.revealed[0],
549
+ busy: states.busy[0] || model.selection.remoteUnavailable,
550
+ typed: states.typed[0], revealed: states.revealed[0],
436
551
  card: model.refs.card, setTyped: states.typed[1], setTypos: states.typos[1],
437
552
  setRevealed: states.revealed[1], setPeeked: states.peeked[1],
438
553
  hide: function () { hideCard(model, true); },
@@ -446,13 +561,21 @@ window.__ModuleLoader__.load({
446
561
  return h(React.Fragment, null, h("style", { id: STYLE_ID }, css),
447
562
  h("section", {
448
563
  className: "vg-card", tabIndex: 0, ref: model.refs.card, "aria-label": "VibeGap 单词卡",
564
+ style: states.pos[0]
565
+ ? { left: states.pos[0].x + "px", top: states.pos[0].y + "px", right: "auto", bottom: "auto" }
566
+ : undefined,
449
567
  onFocus: function (event) { if (event.target === event.currentTarget) states.focused[1](true); },
450
568
  onBlur: function () { states.focused[1](false); },
451
569
  onMouseDown: function (event) {
452
570
  if (!event.target.closest("button,a,input,textarea,select")) model.refs.card.current.focus();
453
571
  },
454
572
  },
455
- h("div", { className: "vg-head" }, h("div", { className: "vg-title" }, "VibeGap"),
573
+ h("div", {
574
+ className: "vg-head", title: "按住拖动",
575
+ onPointerDown: function (event) { startDrag(model, event); },
576
+ },
577
+ h("div", { className: "vg-title" }, "VibeGap"),
578
+ model.progressText ? h("span", { className: "vg-prog" }, model.progressText) : null,
456
579
  h(Button, { className: "vg-close", size: "sm", variant: "ghost", title: "隐藏",
457
580
  onClick: function () { hideCard(model, true); } }, "×")),
458
581
  states.banner[0] ? h("div", { className: "vg-banner", role: "status" }, states.banner[0]) : null,
@@ -467,6 +590,7 @@ window.__ModuleLoader__.load({
467
590
  word: word, typed: states.typed[0], typos: states.typos[0], revealed: states.revealed[0],
468
591
  focused: states.focused[0], shaking: states.shaking[0], download: states.download[0],
469
592
  outcome: states.outcome[0], setDownload: states.download[1], autoPronounce: model.autoPronounce,
593
+ connectionLost: model.selection.connectionLost,
470
594
  pronounce: function () { if (word) safePronounce(word.name); },
471
595
  toggleAuto: function () { prefsStore.set({ autoPronounce: !model.autoPronounce }); },
472
596
  };
@@ -480,12 +604,24 @@ window.__ModuleLoader__.load({
480
604
  var prefs = useSnapshot(prefsStore);
481
605
  var states = useCardStates();
482
606
  var refs = { card: useCell(null), hideTimer: useCell(null), banner: useCell(null) };
483
- var lifecycle = useLifecycleBridge(sessionState, states, refs);
484
607
  var backend = useDaemonBackend();
608
+ var lifecycle = useLifecycleBridge(
609
+ sessionState, states, refs, canAutoPopup(backend[0].mode),
610
+ );
485
611
  var selection = activeSelection(useWordSelection(progress), backend);
612
+ var word = selection.word;
613
+ var progressText = "";
614
+ if (selection.isDaemon && word && Number.isFinite(Number(word.total))) {
615
+ progressText = word.position + "/" + word.total +
616
+ (selection.connectionLost ? " · 桌面端重连中" : " · 共享桌面进度");
617
+ } else if (selection.ordered.length) {
618
+ var cursorShown = Math.min(Math.max(Number(progress.cursor) || 0, 0), selection.ordered.length - 1);
619
+ progressText = cursorShown + "/" + selection.ordered.length + " · 本地进度";
620
+ }
486
621
  var model = {
487
622
  states: states, refs: refs, lifecycle: lifecycle, selection: selection, backend: backend,
488
623
  autoPronounce: !prefs || prefs.autoPronounce !== false,
624
+ progressText: progressText,
489
625
  };
490
626
  useCardEffects(model);
491
627
  useCardTyping(model);
@@ -494,6 +630,15 @@ window.__ModuleLoader__.load({
494
630
 
495
631
  exports.apply = apply;
496
632
  exports.inject = inject;
633
+ exports.__test = {
634
+ activeSelection: activeSelection,
635
+ canAutoPopup: canAutoPopup,
636
+ createTypingHandler: createTypingHandler,
637
+ daemonFailureState: daemonFailureState,
638
+ normalizeWords: normalizeWords,
639
+ transitionNotice: transitionNotice,
640
+ useTyping: useTyping,
641
+ };
497
642
  return module.exports;
498
643
  },
499
644
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vibegap",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Self-contained vocabulary flashcards for DeepSeek Harness web sessions",
5
5
  "main": "lib/index.js",
6
6
  "exports": {
@@ -10,6 +10,9 @@
10
10
  },
11
11
  "keywords": ["dsh-plugin", "vibegap", "vocabulary", "productivity"],
12
12
  "files": ["lib", "cordis.patch.yml", "README.md"],
13
+ "scripts": {
14
+ "test": "node --test test/*.test.js"
15
+ },
13
16
  "repository": {
14
17
  "type": "git",
15
18
  "url": "git+https://github.com/ktao732084-arch/vibegap.git",