@konnga/dsh-hud 0.9.0 → 0.9.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/README.en.md +176 -0
- package/README.md +99 -65
- package/docs/images/capsules.webp +0 -0
- package/docs/images/image-diff.webp +0 -0
- package/docs/images/pill-tip.webp +0 -0
- package/lib/client.js +585 -10
- package/lib/index.js +166 -2
- package/package.json +11 -2
package/lib/client.js
CHANGED
|
@@ -139,6 +139,12 @@ window.__ModuleLoader__.load({
|
|
|
139
139
|
"image.zoomIn": "放大",
|
|
140
140
|
"image.fitWidth": "适应宽度",
|
|
141
141
|
"image.zoomValue": "{percent}%",
|
|
142
|
+
// ── 会话计时胶囊 ──
|
|
143
|
+
"timer.title": "本次会话运行时长",
|
|
144
|
+
"timer.created": "创建于 {time}",
|
|
145
|
+
"timer.work": "其中模型 {llm}、工具 {tool}",
|
|
146
|
+
"timer.turns": "{turns} 轮 / {steps} 步",
|
|
147
|
+
// ── Session timer capsule ──
|
|
142
148
|
// ── 轮尾的「本轮图片改动」条 ──
|
|
143
149
|
"turnImages.title": "本轮图片改动",
|
|
144
150
|
"turnImages.titleSession": "本会话图片改动",
|
|
@@ -275,6 +281,11 @@ window.__ModuleLoader__.load({
|
|
|
275
281
|
"image.zoomIn": "Zoom in",
|
|
276
282
|
"image.fitWidth": "Fit width",
|
|
277
283
|
"image.zoomValue": "{percent}%",
|
|
284
|
+
// ── Session timer capsule ──
|
|
285
|
+
"timer.title": "This session's running time",
|
|
286
|
+
"timer.created": "started {time}",
|
|
287
|
+
"timer.work": "model {llm}, tools {tool}",
|
|
288
|
+
"timer.turns": "{turns} turns / {steps} steps",
|
|
278
289
|
// ── Turn-tail "images this turn" strip ──
|
|
279
290
|
"turnImages.title": "Images this turn",
|
|
280
291
|
"turnImages.titleSession": "Images this session",
|
|
@@ -412,6 +423,327 @@ window.__ModuleLoader__.load({
|
|
|
412
423
|
|
|
413
424
|
/* --------------------------------------------------------- 本地 UI 状态 */
|
|
414
425
|
|
|
426
|
+
/** prompt 历史的条数上限(与宿主半的 `PROMPT_HISTORY_MAX` 同一个数)。 */
|
|
427
|
+
var PROMPT_HISTORY_MAX = 100;
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* 光标此刻允许上下翻历史吗?—— 照抄 OpenCode Desktop 的 `canNavigateHistoryAtCursor`。
|
|
431
|
+
*
|
|
432
|
+
* 它的规矩比 Codex 保守得多:**不在历史里时,`↑` 只在输入框完全为空时才接管**。
|
|
433
|
+
* 于是多行草稿里 `↑` 永远是光标移动,跟内置的 `/` 命令、`@` 提及弹窗也天然不冲突
|
|
434
|
+
* (弹窗必然带着文字)。进了历史之后,只要光标在两端就能继续翻。
|
|
435
|
+
*/
|
|
436
|
+
function canNavigatePromptHistory(direction, text, caret, inHistory) {
|
|
437
|
+
var length = typeof text === "string" ? text.length : 0;
|
|
438
|
+
var position = Math.max(0, Math.min(typeof caret === "number" ? caret : 0, length));
|
|
439
|
+
if (inHistory) return position === 0 || position === length;
|
|
440
|
+
if (direction === "up") return position === 0 && length === 0;
|
|
441
|
+
return position === length;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* `↑` / `↓` 翻 prompt 历史的**纯状态机**(语义照抄 OpenCode Desktop 的 `navigatePromptHistory`)。
|
|
446
|
+
*
|
|
447
|
+
* `index === -1` 表示"不在历史里";`entries[0]` 是最新一条。往旧翻时先把当前草稿存进
|
|
448
|
+
* `draft`,一直翻到最新再往下就把草稿**原样放回**(连光标位置都按方向摆好:往旧放开头、
|
|
449
|
+
* 往新放末尾)。没接管的情况一律回 `handled: false`,调用方据此**不**调用 preventDefault。
|
|
450
|
+
*/
|
|
451
|
+
function promptHistoryStep(state, direction, input) {
|
|
452
|
+
var entries = (input && input.entries) || [];
|
|
453
|
+
var text = input && typeof input.text === "string" ? input.text : "";
|
|
454
|
+
var caret = input && typeof input.caret === "number" ? input.caret : 0;
|
|
455
|
+
var index = state.index;
|
|
456
|
+
var draft = state.draft;
|
|
457
|
+
|
|
458
|
+
if (!canNavigatePromptHistory(direction, text, caret, index >= 0)) return { handled: false, state: state };
|
|
459
|
+
|
|
460
|
+
if (direction === "up") {
|
|
461
|
+
if (entries.length === 0) return { handled: false, state: state };
|
|
462
|
+
if (index === -1) return { handled: true, state: { index: 0, draft: text }, text: entries[0], caret: "start" };
|
|
463
|
+
if (index < entries.length - 1) {
|
|
464
|
+
return { handled: true, state: { index: index + 1, draft: draft }, text: entries[index + 1], caret: "start" };
|
|
465
|
+
}
|
|
466
|
+
return { handled: false, state: state }; // 已经到最旧一条:把 ↑ 还给编辑器
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (index > 0) return { handled: true, state: { index: index - 1, draft: draft }, text: entries[index - 1], caret: "end" };
|
|
470
|
+
if (index === 0) {
|
|
471
|
+
return { handled: true, state: { index: -1, draft: null }, text: draft === null || draft === undefined ? "" : draft, caret: "end" };
|
|
472
|
+
}
|
|
473
|
+
return { handled: false, state: state };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/* -------------------------------------------- 输入框的 ↑ 召回(纯文本) */
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* 从一个事件目标往上找"输入框根节点"。
|
|
480
|
+
*
|
|
481
|
+
* DSH 的输入框是 **Lexical** 的 contenteditable(根节点带 `data-lexical-editor="true"`),
|
|
482
|
+
* 不是 textarea —— 所以不能读 `value`。手写 parentElement 上溯(不用 `closest`):
|
|
483
|
+
* 一来少依赖一个 API,二来深度有上限,绝不会误判到页面别处。
|
|
484
|
+
*/
|
|
485
|
+
function composerRootOf(target) {
|
|
486
|
+
var node = target;
|
|
487
|
+
for (var depth = 0; depth < 6 && node !== null && node !== undefined; depth += 1) {
|
|
488
|
+
if (typeof node.getAttribute === "function") {
|
|
489
|
+
var marker = node.getAttribute("data-lexical-editor");
|
|
490
|
+
var editable = typeof node.isContentEditable === "boolean" ? node.isContentEditable : node.getAttribute("contenteditable") === "true";
|
|
491
|
+
if (marker === "true" || editable) return node;
|
|
492
|
+
}
|
|
493
|
+
node = node.parentElement;
|
|
494
|
+
}
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** 输入框里的纯文本(Lexical 会把内容画在 DOM 里,`innerText` 最贴近用户看到的)。 */
|
|
499
|
+
function composerText(root) {
|
|
500
|
+
if (root === null || root === undefined) return "";
|
|
501
|
+
var text = typeof root.innerText === "string" ? root.innerText : (typeof root.textContent === "string" ? root.textContent : "");
|
|
502
|
+
return text.replace(/\u200b/g, "");
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* "输入框实际上是空的吗"用的归一化文本。
|
|
507
|
+
*
|
|
508
|
+
* Lexical 的空根节点在 DOM 里通常是 `<p><br></p>`,`innerText` 会给出一个换行符 ——
|
|
509
|
+
* 拿它当"有内容"就会让 `↑` 永远排不上队(整个功能静默失效)。所以去掉零宽字符与
|
|
510
|
+
* **只在末尾的空白**:开头的空格是用户真的敲进去的,不能吞。
|
|
511
|
+
*/
|
|
512
|
+
function composerTextKey(root) {
|
|
513
|
+
return composerText(root).replace(/\s+$/, "");
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** 写入是否成功:两边都归一化再比 —— Lexical 可能补一个尾换行,那不是"没写进去"。 */
|
|
517
|
+
function composerTextMatches(text, expected) {
|
|
518
|
+
return text.replace(/\s+$/, "") === expected.replace(/\s+$/, "");
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** 光标在输入框里的字符偏移;没有选区、有选中内容、或拿不到选区时回 null(一律不接管)。 */
|
|
522
|
+
function composerCaretOffset(root) {
|
|
523
|
+
var selection = typeof window.getSelection === "function" ? window.getSelection() : null;
|
|
524
|
+
if (!selection || selection.rangeCount === 0 || selection.isCollapsed !== true) return null;
|
|
525
|
+
var node = selection.focusNode;
|
|
526
|
+
if (!node) return null;
|
|
527
|
+
var range = document.createRange();
|
|
528
|
+
range.selectNodeContents(root);
|
|
529
|
+
var before = range.cloneRange();
|
|
530
|
+
try {
|
|
531
|
+
before.setEnd(node, selection.focusOffset);
|
|
532
|
+
} catch (error) {
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
return before.toString().length;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** 把光标放到开头或末尾(`↑` 往旧翻放开头,`↓` 往回翻放末尾 —— 与 OpenCode 一致)。 */
|
|
539
|
+
function placeCaretInComposer(root, position) {
|
|
540
|
+
var selection = typeof window.getSelection === "function" ? window.getSelection() : null;
|
|
541
|
+
if (!selection) return;
|
|
542
|
+
try {
|
|
543
|
+
var range = document.createRange();
|
|
544
|
+
range.selectNodeContents(root);
|
|
545
|
+
range.collapse(position !== "end");
|
|
546
|
+
selection.removeAllRanges();
|
|
547
|
+
selection.addRange(range);
|
|
548
|
+
} catch (error) {
|
|
549
|
+
// 光标没摆好不影响内容已经写进去了
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** 全选输入框内容(模拟粘贴前先选中,粘贴才能"替换"而不是"追加")。 */
|
|
554
|
+
function selectComposerContents(root) {
|
|
555
|
+
var selection = typeof window.getSelection === "function" ? window.getSelection() : null;
|
|
556
|
+
if (!selection) return;
|
|
557
|
+
try {
|
|
558
|
+
var range = document.createRange();
|
|
559
|
+
range.selectNodeContents(root);
|
|
560
|
+
selection.removeAllRanges();
|
|
561
|
+
selection.addRange(range);
|
|
562
|
+
} catch (error) {
|
|
563
|
+
// 选不中也还能走 execCommand 那条退路
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** 等"写入落地":微任务总有,有 rAF 的浏览器里再多等一帧(Lexical 的粘贴是帧后落 DOM)。 */
|
|
568
|
+
function settleComposer() {
|
|
569
|
+
var chain = Promise.resolve();
|
|
570
|
+
if (typeof window.requestAnimationFrame === "function") {
|
|
571
|
+
chain = chain.then(function () {
|
|
572
|
+
return new Promise(function (resolve) {
|
|
573
|
+
window.requestAnimationFrame(function () { resolve(); });
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
return chain;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* 把一段纯文本写进 Lexical 输入框 —— **三层依次降级,每层都等落地了再回读**。
|
|
582
|
+
*
|
|
583
|
+
* 为什么要等:Lexical 的粘贴处理是**异步**的(它得先把剪贴板读出来)。写完立刻回读会读到
|
|
584
|
+
* 旧内容、误判成失败,于是下一层再插一次 —— 实测表现就是**文字出现两遍**(第一次真机上
|
|
585
|
+
* 就是这样:粘贴插了一遍,`insertText` 又追加了一遍)。
|
|
586
|
+
*
|
|
587
|
+
* 三层次序:① 模拟粘贴(Lexical 的正规插入通道)② `execCommand("insertText")`
|
|
588
|
+
* ③ 直接改文本 + 派发 `input`。**每次降级前都重新全选**,让重试是"替换"而不是"追加"。
|
|
589
|
+
*/
|
|
590
|
+
function writeComposerText(root, value, caret) {
|
|
591
|
+
function satisfied() {
|
|
592
|
+
return composerTextMatches(composerText(root), value);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
return settleComposer()
|
|
596
|
+
.then(function () {
|
|
597
|
+
if (typeof window.ClipboardEvent !== "function" || typeof window.DataTransfer !== "function") return false;
|
|
598
|
+
if (typeof root.dispatchEvent !== "function") return false;
|
|
599
|
+
try {
|
|
600
|
+
selectComposerContents(root);
|
|
601
|
+
var data = new window.DataTransfer();
|
|
602
|
+
data.setData("text/plain", value);
|
|
603
|
+
root.dispatchEvent(new window.ClipboardEvent("paste", { clipboardData: data, bubbles: true, cancelable: true }));
|
|
604
|
+
} catch (error) {
|
|
605
|
+
return false;
|
|
606
|
+
}
|
|
607
|
+
return settleComposer().then(satisfied);
|
|
608
|
+
})
|
|
609
|
+
.then(function (wrote) {
|
|
610
|
+
if (wrote) return true;
|
|
611
|
+
try {
|
|
612
|
+
if (typeof document.execCommand === "function") {
|
|
613
|
+
selectComposerContents(root);
|
|
614
|
+
if (document.execCommand("insertText", false, value) === true) {
|
|
615
|
+
return settleComposer().then(satisfied);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
} catch (error) {
|
|
619
|
+
// 落到最后一层
|
|
620
|
+
}
|
|
621
|
+
return settleComposer().then(function () {
|
|
622
|
+
if (satisfied()) return true;
|
|
623
|
+
try {
|
|
624
|
+
selectComposerContents(root);
|
|
625
|
+
root.textContent = value;
|
|
626
|
+
if (typeof window.Event === "function") root.dispatchEvent(new window.Event("input", { bubbles: true }));
|
|
627
|
+
return true;
|
|
628
|
+
} catch (error) {
|
|
629
|
+
return false;
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
})
|
|
633
|
+
.then(function (wrote) {
|
|
634
|
+
if (!wrote) return false;
|
|
635
|
+
placeCaretInComposer(root, caret);
|
|
636
|
+
// 重写内容会让编辑器失焦/滚动跳,下一帧再摆一次光标(OpenCode 也是这么做的)。
|
|
637
|
+
if (typeof window.requestAnimationFrame === "function") {
|
|
638
|
+
window.requestAnimationFrame(function () { placeCaretInComposer(root, caret); });
|
|
639
|
+
}
|
|
640
|
+
return true;
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* 把"输入框里按 ↑/↓ 召回上一条 prompt"接到窗口上 —— 只接线,判断与翻页逻辑都在纯函数里。
|
|
646
|
+
*
|
|
647
|
+
* 门禁(任一不满足就**完全不介入**,`↑`/`↓` 行为与不装插件时一模一样):
|
|
648
|
+
* 默认行为没被别处吃掉、不是输入法组合中、无修饰键、目标是输入框、历史非空、
|
|
649
|
+
* 选区收起、光标位置通过 `canNavigatePromptHistory`。**接管了才 preventDefault**。
|
|
650
|
+
*/
|
|
651
|
+
function bindPromptHistory(store) {
|
|
652
|
+
var nav = { sessionId: null, index: -1, draft: null, writing: false };
|
|
653
|
+
|
|
654
|
+
function onKeyDown(event) {
|
|
655
|
+
if (event === null || event === undefined) return;
|
|
656
|
+
if (event.defaultPrevented === true || event.isComposing === true) return;
|
|
657
|
+
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
|
|
658
|
+
if (event.altKey === true || event.ctrlKey === true || event.metaKey === true) return;
|
|
659
|
+
if (typeof event.preventDefault !== "function") return;
|
|
660
|
+
|
|
661
|
+
var root = composerRootOf(event.target);
|
|
662
|
+
if (root === null) return;
|
|
663
|
+
|
|
664
|
+
var snapshot = store.getSnapshot().promptHistory;
|
|
665
|
+
var value = snapshot !== null && snapshot !== undefined && snapshot.sessionId !== null ? snapshot.value : null;
|
|
666
|
+
var entries = value !== null && value !== undefined && value.available === true && Array.isArray(value.prompts) ? value.prompts : [];
|
|
667
|
+
if (entries.length === 0) return;
|
|
668
|
+
|
|
669
|
+
if (nav.sessionId !== snapshot.sessionId) {
|
|
670
|
+
nav.sessionId = snapshot.sessionId;
|
|
671
|
+
nav.index = -1;
|
|
672
|
+
nav.draft = null;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
var caret = composerCaretOffset(root);
|
|
676
|
+
if (caret === null) return;
|
|
677
|
+
var direction = event.key === "ArrowUp" ? "up" : "down";
|
|
678
|
+
// 归一化后再判"空不空"与"光标在哪端":Lexical 空输入框里的那个换行不算内容。
|
|
679
|
+
var text = composerTextKey(root);
|
|
680
|
+
var step = promptHistoryStep({ index: nav.index, draft: nav.draft }, direction, {
|
|
681
|
+
entries: entries,
|
|
682
|
+
text: text,
|
|
683
|
+
caret: Math.max(0, Math.min(caret, text.length)),
|
|
684
|
+
});
|
|
685
|
+
if (!step.handled) return;
|
|
686
|
+
|
|
687
|
+
// 到这里才确定要接管:先挡住默认行为(别让 ↑ 先把光标移走),再写内容。
|
|
688
|
+
event.preventDefault();
|
|
689
|
+
if (typeof event.stopPropagation === "function") event.stopPropagation();
|
|
690
|
+
|
|
691
|
+
// 写入是异步的(粘贴要等落地),但翻页状态先记下:连按 ↑ 时不会两次都从同一条开始。
|
|
692
|
+
// 真写不进去再回滚 —— 宁可状态与画面都对不上时退回一步,也不要悄悄漂移。
|
|
693
|
+
var previous = { index: nav.index, draft: nav.draft };
|
|
694
|
+
nav.index = step.state.index;
|
|
695
|
+
nav.draft = step.state.draft;
|
|
696
|
+
nav.writing = true;
|
|
697
|
+
|
|
698
|
+
writeComposerText(root, step.text, step.caret).then(function (wrote) {
|
|
699
|
+
nav.writing = false;
|
|
700
|
+
if (wrote === true) {
|
|
701
|
+
probe.log("prompt-history", { index: nav.index, entries: entries.length, direction: direction });
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
nav.index = previous.index;
|
|
705
|
+
nav.draft = previous.draft;
|
|
706
|
+
probe.log("prompt-history: 写不进输入框(三层都失败),已回滚并放弃接管", { entries: entries.length });
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** 用户自己动了内容(不是我们写进去的)→ 退出历史、草稿作废,免得把草稿还原到新内容上。 */
|
|
711
|
+
function onInput(event) {
|
|
712
|
+
if (nav.writing) return;
|
|
713
|
+
if (composerRootOf(event && event.target) === null) return;
|
|
714
|
+
nav.index = -1;
|
|
715
|
+
nav.draft = null;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (typeof window.addEventListener !== "function") return function () {};
|
|
719
|
+
window.addEventListener("keydown", onKeyDown, true);
|
|
720
|
+
window.addEventListener("input", onInput, true);
|
|
721
|
+
return function () {
|
|
722
|
+
window.removeEventListener("keydown", onKeyDown, true);
|
|
723
|
+
window.removeEventListener("input", onInput, true);
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* 时长格式化:**与 codex-hud 同一套口径**。
|
|
729
|
+
*
|
|
730
|
+
* 不足一分钟给 `<1m`;然后按 `Xd Yh Zm` 只列非零项(`45m` / `2h 14m` / `3d 2h`)。
|
|
731
|
+
* 取的是 floor —— 计时器宁可比真实少一点,也不要"明明刚过 1 分钟就写 2m"。
|
|
732
|
+
*/
|
|
733
|
+
function formatDuration(ms) {
|
|
734
|
+
var safe = typeof ms === "number" && isFinite(ms) ? Math.max(0, ms) : 0;
|
|
735
|
+
if (safe < 60000) return "<1m";
|
|
736
|
+
var minutes = Math.floor(safe / 60000);
|
|
737
|
+
var days = Math.floor(minutes / 1440);
|
|
738
|
+
var hours = Math.floor((minutes % 1440) / 60);
|
|
739
|
+
var rest = minutes % 60;
|
|
740
|
+
var parts = [];
|
|
741
|
+
if (days > 0) parts.push(String(days) + "d");
|
|
742
|
+
if (hours > 0) parts.push(String(hours) + "h");
|
|
743
|
+
if (rest > 0) parts.push(String(rest) + "m");
|
|
744
|
+
return parts.join(" ");
|
|
745
|
+
}
|
|
746
|
+
|
|
415
747
|
/** 固定缩放档位:与内置文档预览的菜单一致(25% / 50% / 100% / 150% / 200%)。 */
|
|
416
748
|
var ZOOM_STEPS = [0.25, 0.5, 1, 1.5, 2];
|
|
417
749
|
|
|
@@ -770,6 +1102,14 @@ window.__ModuleLoader__.load({
|
|
|
770
1102
|
* 实时秒数由客户端本地算(`active.startedAt`),不靠把轮询压到亚秒级。
|
|
771
1103
|
*/
|
|
772
1104
|
tools: { phase: "idle", active: null, recent: [], counts: [], skills: [], skillsDropped: 0, total: 0, updatedAt: null },
|
|
1105
|
+
// 会话时长(计时胶囊用):`{ sessionId, value }`;取数写进快照,渲染那边就是纯函数。
|
|
1106
|
+
sessionTime: { sessionId: null, value: null },
|
|
1107
|
+
// prompt 历史(输入框 `↑` 召回用):`{ sessionId, value }`,value 里 prompts 是**新的在前**。
|
|
1108
|
+
promptHistory: { sessionId: null, value: null },
|
|
1109
|
+
// 胶囊悬停提示:`{ key, text, rect }` 或 null。**自己画**,不用浏览器原生 tooltip ——
|
|
1110
|
+
// 原生那个在真机上按了没反应(试过三种节点/容器查证,都不是被谁挡住),
|
|
1111
|
+
// 而它延迟、样式、能否出现我们都控制不了;自己画则位置、换行、颜色全说了算。
|
|
1112
|
+
tip: null,
|
|
773
1113
|
/** 侧栏行内 chip 要的摘要:一次批量取回,按 sessionId 索引。 */
|
|
774
1114
|
rows: { phase: "idle", byId: {}, unavailable: false, truncated: false, updatedAt: null },
|
|
775
1115
|
/**
|
|
@@ -1103,6 +1443,124 @@ window.__ModuleLoader__.load({
|
|
|
1103
1443
|
var imagePairs = new Map();
|
|
1104
1444
|
var IMAGE_CACHE_MAX = 8;
|
|
1105
1445
|
|
|
1446
|
+
/**
|
|
1447
|
+
* 会话运行时长的缓存:每分钟最多问一次(统计数字不需要更密),同会话复用。
|
|
1448
|
+
*
|
|
1449
|
+
* 与图片那种"重"缓存分开存:这个条目只有几十字节,TTL 到期直接覆盖。
|
|
1450
|
+
*/
|
|
1451
|
+
var sessionTimes = new Map();
|
|
1452
|
+
/** 拿到数的缓存时长。 */
|
|
1453
|
+
var SESSION_TIME_TTL_MS = 60000;
|
|
1454
|
+
/**
|
|
1455
|
+
* **没拿到**时的重问间隔。
|
|
1456
|
+
*
|
|
1457
|
+
* 会话是异步恢复到宿主 `sessions` 存储里的:重启后头几百毫秒问 `session-time` 得到的是
|
|
1458
|
+
* "这个会话还不存在"(实测启动 0.6 秒时 live=0,30 秒后才 live=1)。
|
|
1459
|
+
* 把这种答案按 60 秒缓存,就会让计时胶囊在你重启后一两分钟才出现 —— 所以负面结果只记 3 秒。
|
|
1460
|
+
*/
|
|
1461
|
+
var SESSION_TIME_RETRY_MS = 3000;
|
|
1462
|
+
|
|
1463
|
+
/** 显示某枚胶囊的悬停提示(`rect` 由调用方从 `getBoundingClientRect()` 给)。 */
|
|
1464
|
+
function showTip(key, text, rect) {
|
|
1465
|
+
if (typeof text !== "string" || text.length === 0) return;
|
|
1466
|
+
var at = rect && typeof rect === "object"
|
|
1467
|
+
? { left: rect.left, top: rect.top, right: rect.right }
|
|
1468
|
+
: { left: 0, top: 0, right: 0 };
|
|
1469
|
+
set({ tip: { key: key, text: text, rect: at } });
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
/** 收起悬停提示。带 `key` 时只在"还是这一枚"的情况下收 —— 免得旧的 leave 把新的提示关掉。 */
|
|
1473
|
+
function hideTip(key) {
|
|
1474
|
+
var current = state.tip;
|
|
1475
|
+
if (current === null || current === undefined) return;
|
|
1476
|
+
if (typeof key === "string" && current.key !== key) return;
|
|
1477
|
+
set({ tip: null });
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
/**
|
|
1481
|
+
* 一枚胶囊的悬停接线:算出锚点矩形,把文案交给 store。
|
|
1482
|
+
*
|
|
1483
|
+
* 为什么走 store 而不是就地渲染气泡:气泡必须**逃出行内盒**(不被 dock 那行的裁切与
|
|
1484
|
+
* 堆叠上下文限制),所以它由 `shell.overlay` 里一枚单独的座位画,位置用 `position:fixed`。
|
|
1485
|
+
*/
|
|
1486
|
+
function tipHandlers(key, textOf) {
|
|
1487
|
+
return {
|
|
1488
|
+
onPointerEnter: function (event) {
|
|
1489
|
+
var target = event && event.currentTarget ? event.currentTarget : null;
|
|
1490
|
+
var rect = target && typeof target.getBoundingClientRect === "function" ? target.getBoundingClientRect() : null;
|
|
1491
|
+
showTip(key, textOf(), rect);
|
|
1492
|
+
},
|
|
1493
|
+
onPointerLeave: function () { hideTip(key); },
|
|
1494
|
+
onPointerDown: function () { hideTip(key); },
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
/** 取一个会话的运行时长(含内置 `sessionStats` 的累计数字);拿不到回 `available: false`。 */
|
|
1499
|
+
function loadSessionTime(sessionId) {
|
|
1500
|
+
if (typeof sessionId !== "string" || sessionId === "") return Promise.resolve({ available: false });
|
|
1501
|
+
var cached = sessionTimes.get(sessionId);
|
|
1502
|
+
if (cached !== undefined) {
|
|
1503
|
+
var ttl = cached.value && cached.value.available === true ? SESSION_TIME_TTL_MS : SESSION_TIME_RETRY_MS;
|
|
1504
|
+
if (Date.now() - cached.at < ttl) return Promise.resolve(cached.value);
|
|
1505
|
+
}
|
|
1506
|
+
return Promise.resolve()
|
|
1507
|
+
.then(function () {
|
|
1508
|
+
return ctx.connection.rpc.call(CHANNEL, "session-time", { args: { sessionId: sessionId } });
|
|
1509
|
+
})
|
|
1510
|
+
.then(function (result) {
|
|
1511
|
+
var value = result && result.ok === true && result.value ? result.value : { available: false, sessionId: sessionId };
|
|
1512
|
+
if (sessionTimes.size >= 16) sessionTimes.clear();
|
|
1513
|
+
sessionTimes.set(sessionId, { at: Date.now(), value: value });
|
|
1514
|
+
var previous = state.sessionTime && state.sessionTime.sessionId === sessionId ? state.sessionTime.value : null;
|
|
1515
|
+
// 深度比较没必要:这两个字段(会话创建时刻 + 累计墙钟)就是整份值里会变的部分。
|
|
1516
|
+
var same = previous !== null
|
|
1517
|
+
&& previous.available === value.available
|
|
1518
|
+
&& previous.createdAt === value.createdAt
|
|
1519
|
+
&& previous.llmMs === value.llmMs
|
|
1520
|
+
&& previous.toolMs === value.toolMs
|
|
1521
|
+
&& previous.turns === value.turns
|
|
1522
|
+
&& previous.steps === value.steps;
|
|
1523
|
+
if (!same) set({ sessionTime: { sessionId: sessionId, value: value } });
|
|
1524
|
+
return value;
|
|
1525
|
+
})
|
|
1526
|
+
.catch(function () {
|
|
1527
|
+
return { available: false, sessionId: sessionId };
|
|
1528
|
+
});
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
/**
|
|
1532
|
+
* prompt 历史:宿主从内置 `turnOutline` 投影里取,比客户端自己记一份可靠得多
|
|
1533
|
+
* (分页、压实、重启、换设备都不影响)。prompt 只在"一整轮发出去"之后才会变,
|
|
1534
|
+
* 所以 5 秒 TTL 足够,而且顺手把值写进快照 —— 按键那侧只读快照,不必再 await。
|
|
1535
|
+
*/
|
|
1536
|
+
var promptHistories = new Map();
|
|
1537
|
+
var PROMPT_HISTORY_TTL_MS = 5000;
|
|
1538
|
+
|
|
1539
|
+
function loadPromptHistory(sessionId) {
|
|
1540
|
+
if (typeof sessionId !== "string" || sessionId === "") return Promise.resolve({ available: false, prompts: [] });
|
|
1541
|
+
var cached = promptHistories.get(sessionId);
|
|
1542
|
+
if (cached !== undefined && Date.now() - cached.at < PROMPT_HISTORY_TTL_MS) return Promise.resolve(cached.value);
|
|
1543
|
+
return Promise.resolve()
|
|
1544
|
+
.then(function () {
|
|
1545
|
+
return ctx.connection.rpc.call(CHANNEL, "prompt-history", { args: { sessionId: sessionId, limit: PROMPT_HISTORY_MAX } });
|
|
1546
|
+
})
|
|
1547
|
+
.then(function (result) {
|
|
1548
|
+
var value = result && result.ok === true && result.value ? result.value : { available: false, sessionId: sessionId, prompts: [] };
|
|
1549
|
+
if (promptHistories.size >= 16) promptHistories.clear();
|
|
1550
|
+
promptHistories.set(sessionId, { at: Date.now(), value: value });
|
|
1551
|
+
var previous = state.promptHistory && state.promptHistory.sessionId === sessionId ? state.promptHistory.value : null;
|
|
1552
|
+
var same = previous !== null
|
|
1553
|
+
&& previous.available === value.available
|
|
1554
|
+
&& (previous.prompts || []).length === (value.prompts || []).length
|
|
1555
|
+
&& (previous.prompts || []).every(function (item, index) { return item === value.prompts[index]; });
|
|
1556
|
+
if (!same) set({ promptHistory: { sessionId: sessionId, value: value } });
|
|
1557
|
+
return value;
|
|
1558
|
+
})
|
|
1559
|
+
.catch(function () {
|
|
1560
|
+
return { available: false, sessionId: sessionId, prompts: [] };
|
|
1561
|
+
});
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1106
1564
|
/**
|
|
1107
1565
|
* 同步看一眼缓存:命中就返回条目,否则 null。
|
|
1108
1566
|
*
|
|
@@ -1766,6 +2224,11 @@ window.__ModuleLoader__.load({
|
|
|
1766
2224
|
loadImagePair: loadImagePair,
|
|
1767
2225
|
cachedImagePair: cachedImagePair,
|
|
1768
2226
|
loadSessionChanges: loadSessionChanges,
|
|
2227
|
+
loadSessionTime: loadSessionTime,
|
|
2228
|
+
loadPromptHistory: loadPromptHistory,
|
|
2229
|
+
showTip: showTip,
|
|
2230
|
+
hideTip: hideTip,
|
|
2231
|
+
tipHandlers: tipHandlers,
|
|
1769
2232
|
closeDiff: closeDiff,
|
|
1770
2233
|
closeDetail: closeDetail,
|
|
1771
2234
|
setDiffSide: setDiffSide,
|
|
@@ -3200,6 +3663,24 @@ window.__ModuleLoader__.load({
|
|
|
3200
3663
|
var toolsTickKey = state.tools && state.tools.active !== null && state.tools.active !== undefined
|
|
3201
3664
|
? String(state.tools.active.callId)
|
|
3202
3665
|
: null;
|
|
3666
|
+
// 会话计时 + 输入框 ↑ 召回的数据(两个 loader 各自带 TTL,这里按最密的节奏问就行)。
|
|
3667
|
+
React.useEffect(function () {
|
|
3668
|
+
if (sessionId === undefined) return undefined;
|
|
3669
|
+
var load = function () {
|
|
3670
|
+
if (store.loadSessionTime !== undefined) store.loadSessionTime(sessionId);
|
|
3671
|
+
if (store.loadPromptHistory !== undefined) store.loadPromptHistory(sessionId);
|
|
3672
|
+
};
|
|
3673
|
+
load();
|
|
3674
|
+
var poll = window.setInterval(load, 3000);
|
|
3675
|
+
return function () { window.clearInterval(poll); };
|
|
3676
|
+
}, [sessionId]);
|
|
3677
|
+
|
|
3678
|
+
// 挂钟每 4 秒跳一次:计时胶囊的显示粒度是分钟,但跨分钟不用等下一次取数。
|
|
3679
|
+
React.useEffect(function () {
|
|
3680
|
+
var clock = window.setInterval(function () { tickSet(Date.now()); }, 4000);
|
|
3681
|
+
return function () { window.clearInterval(clock); };
|
|
3682
|
+
}, []);
|
|
3683
|
+
|
|
3203
3684
|
React.useEffect(function () {
|
|
3204
3685
|
if (toolsTickKey === null) return undefined;
|
|
3205
3686
|
var timer = window.setInterval(function () { tickSet(Date.now()); }, 500);
|
|
@@ -3280,27 +3761,55 @@ window.__ModuleLoader__.load({
|
|
|
3280
3761
|
// 外层容器复刻框架自己的 dock 行几何(见 queue 公告):仅靠胶囊自身对齐,
|
|
3281
3762
|
// 胶囊会飘到整栏的最左边,而输入卡片是居中的。
|
|
3282
3763
|
return h("div", { className: "dshh-dock", "data-dsh-hud": "dock" },
|
|
3283
|
-
h("button", {
|
|
3764
|
+
h("button", Object.assign({
|
|
3284
3765
|
type: "button",
|
|
3285
3766
|
className: "dshh-pill",
|
|
3286
3767
|
"data-dsh-hud": "pill",
|
|
3287
|
-
title: titleLines.join("\n"),
|
|
3288
3768
|
"aria-expanded": state.panelOpen ? "true" : "false",
|
|
3289
3769
|
style: { color: state.panelOpen ? "var(--dsw-alias-brand-primary, #4b8bf5)" : undefined },
|
|
3290
3770
|
onClick: function () {
|
|
3291
3771
|
store.togglePanel();
|
|
3292
3772
|
},
|
|
3293
|
-
}, children),
|
|
3773
|
+
}, store.tipHandlers("pill", function () { return titleLines.join("\n"); })), children),
|
|
3294
3774
|
// 工具活动那一枚并排在后面 —— dock 是竖列,独立注册会另起一整行(用户实测"换行了")。
|
|
3295
|
-
toolPillNode(state, { t: t, now: props.now === undefined ? tick[0] : props.now })
|
|
3775
|
+
toolPillNode(state, { t: t, store: store, now: props.now === undefined ? tick[0] : props.now }),
|
|
3776
|
+
// 计时胶囊放在**最后**(三枚同在一条注册里 → 同一行;独立注册会另起一整行)。
|
|
3777
|
+
timerPillNode(state, { t: t, store: store, sessionId: sessionId, now: props.now === undefined ? tick[0] : props.now }));
|
|
3778
|
+
}
|
|
3779
|
+
|
|
3780
|
+
/**
|
|
3781
|
+
* 胶囊悬停提示的气泡。**自己画**,不用浏览器原生 `title`。
|
|
3782
|
+
*
|
|
3783
|
+
* 由一个独立的 `shell.overlay` 座位渲染、`position:fixed` 定位 —— 这样它不受 dock 那行的
|
|
3784
|
+
* 裁切与堆叠上下文限制。位置只用锚点矩形的 `top` 与 `right`:
|
|
3785
|
+
* 贴在那枚胶囊**上方**,横向按视口夹住(不量自己的宽度,避免"先渲染再回填"的抖动)。
|
|
3786
|
+
*/
|
|
3787
|
+
function PillTip(props) {
|
|
3788
|
+
var store = props.store;
|
|
3789
|
+
var state = useStore(store);
|
|
3790
|
+
var tip = state.tip;
|
|
3791
|
+
|
|
3792
|
+
if (tip === null || tip === undefined) return null;
|
|
3793
|
+
var innerWidth = typeof window.innerWidth === "number" ? window.innerWidth : 1200;
|
|
3794
|
+
var innerHeight = typeof window.innerHeight === "number" ? window.innerHeight : 800;
|
|
3795
|
+
var margin = 8;
|
|
3796
|
+
var left = Math.max(margin, Math.min(typeof tip.rect.left === "number" ? tip.rect.left : margin, innerWidth - 428));
|
|
3797
|
+
var bottom = Math.max(margin, innerHeight - (typeof tip.rect.top === "number" ? tip.rect.top : 0) + 6);
|
|
3798
|
+
|
|
3799
|
+
return h("div", {
|
|
3800
|
+
className: "dshh-tip",
|
|
3801
|
+
"data-dsh-hud": "pill-tip",
|
|
3802
|
+
role: "tooltip",
|
|
3803
|
+
style: { left: String(left) + "px", bottom: String(bottom) + "px" },
|
|
3804
|
+
}, tip.text);
|
|
3296
3805
|
}
|
|
3297
3806
|
|
|
3298
3807
|
/**
|
|
3299
|
-
*
|
|
3808
|
+
* 本会话工具活动的胶囊(**与 Git 胶囊同一行**:dock 是竖列,独立注册会另起一行,
|
|
3809
|
+
* 所以它只是那一条注册里的第二个节点)。
|
|
3300
3810
|
*
|
|
3301
3811
|
* 照 codex-hud 那条 `Tools:` 的口径:**正在跑**就报工具名与实时秒数(秒数本地算,不靠轮询);
|
|
3302
|
-
*
|
|
3303
|
-
* 空白注册会白占一行(dock 是竖列,一条注册就是一整行)。
|
|
3812
|
+
* 空闲就报最近一次是哪个工具、本会话一共几次(失败打叉)。完全没跑过工具时整枚不画。
|
|
3304
3813
|
*/
|
|
3305
3814
|
/**
|
|
3306
3815
|
* 工具活动那一枚(**与 Git 胶囊同一行**,所以它只是那一行里的第二个节点,不是独立注册)。
|
|
@@ -3371,12 +3880,47 @@ window.__ModuleLoader__.load({
|
|
|
3371
3880
|
active: tools.active === null ? null : String(tools.active.name),
|
|
3372
3881
|
total: tools.total,
|
|
3373
3882
|
});
|
|
3374
|
-
return h("span", {
|
|
3883
|
+
return h("span", Object.assign({
|
|
3375
3884
|
className: "dshh-pill dshh-toolsPill",
|
|
3376
3885
|
"data-dsh-hud": "tools-pill",
|
|
3377
3886
|
key: "tools",
|
|
3378
|
-
|
|
3379
|
-
|
|
3887
|
+
}, options.store.tipHandlers("tools-pill", function () { return toolsTitle(tools, t); })), children);
|
|
3888
|
+
}
|
|
3889
|
+
|
|
3890
|
+
/**
|
|
3891
|
+
* 会话计时胶囊(**与 Git / 工具两枚同一行、排在最后**,所以它只是那一条注册里的第三个节点 ——
|
|
3892
|
+
* dock 是竖列,独立注册会另起一整行,工具胶囊与计时胶囊都踩过这个坑)。
|
|
3893
|
+
*
|
|
3894
|
+
* 口径与 codex-hud 的会话时长一致:挂钟 = 现在 − 会话创建时刻;悬停补上模型/工具墙钟与轮次。
|
|
3895
|
+
* 数据来自 store 快照(`sessionTime`),所以这里是纯函数,没有 hooks。
|
|
3896
|
+
*/
|
|
3897
|
+
function timerPillNode(state, options) {
|
|
3898
|
+
var t = options.t;
|
|
3899
|
+
var sessionId = options.sessionId;
|
|
3900
|
+
var now = options.now;
|
|
3901
|
+
var snapshot = state.sessionTime;
|
|
3902
|
+
var value = snapshot !== null && snapshot !== undefined && snapshot.sessionId === sessionId ? snapshot.value : null;
|
|
3903
|
+
// 会话不在(还没恢复进存储、已归档、子会话已回收)、宿主服务缺失:不画这枚胶囊,也不占位。
|
|
3904
|
+
if (value === null || value.available !== true || typeof value.createdAt !== "number") return null;
|
|
3905
|
+
|
|
3906
|
+
var age = Math.max(0, now - value.createdAt);
|
|
3907
|
+
var hints = [t("timer.title"), t("timer.created", { time: fullWhen(value.createdAt) })];
|
|
3908
|
+
if (typeof value.llmMs === "number" && typeof value.toolMs === "number") {
|
|
3909
|
+
hints.push(t("timer.work", { llm: formatDuration(value.llmMs), tool: formatDuration(value.toolMs) }));
|
|
3910
|
+
}
|
|
3911
|
+
if (typeof value.turns === "number" && typeof value.steps === "number") {
|
|
3912
|
+
hints.push(t("timer.turns", { turns: String(value.turns), steps: String(value.steps) }));
|
|
3913
|
+
}
|
|
3914
|
+
probe.log("timer-pill", { age: Math.round(age / 60000) });
|
|
3915
|
+
var tipKey = "session-timer";
|
|
3916
|
+
return h("span", Object.assign({
|
|
3917
|
+
className: "dshh-pill dshh-timer",
|
|
3918
|
+
"data-dsh-hud": "session-timer",
|
|
3919
|
+
key: "timer",
|
|
3920
|
+
}, options.store.tipHandlers(tipKey, function () { return hints.join(" · "); })), [
|
|
3921
|
+
h("span", { key: "glyph", className: "dshh-timerGlyph" }, "⏱"),
|
|
3922
|
+
h("span", { key: "value", className: "dshh-timerValue" }, formatDuration(age)),
|
|
3923
|
+
]);
|
|
3380
3924
|
}
|
|
3381
3925
|
|
|
3382
3926
|
/** 已经跑了多久:`1.2s` / `12s`(拿不到开始时刻就返回 null,不编一个数)。 */
|
|
@@ -3962,6 +4506,16 @@ window.__ModuleLoader__.load({
|
|
|
3962
4506
|
"background:var(--dsw-alias-bg-layer-1,transparent);color:var(--dsw-alias-label-secondary,#8a8f98);",
|
|
3963
4507
|
"cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}",
|
|
3964
4508
|
".dshh-pill:hover{color:var(--dsw-alias-label-primary,#e5e7eb);border-color:var(--dsw-alias-border-l2,rgba(127,127,127,0.45));}",
|
|
4509
|
+
// 会话计时胶囊:只读读数,所以指针不该是"可点";数字用等宽数字,跳动时宽度不抖。
|
|
4510
|
+
".dshh-timer{cursor:default;gap:5px;}",
|
|
4511
|
+
// 悬停气泡:`pointer-events:none` 保证它永远不挡指针;`pre-wrap` 保留调用方给的多行结构。
|
|
4512
|
+
".dshh-tip{position:fixed;z-index:60;box-sizing:border-box;max-width:420px;padding:6px 10px;border-radius:8px;"
|
|
4513
|
+
+ "background:var(--dsw-specific-menu,var(--dsw-alias-bg-layer-2,#2c2c2e));"
|
|
4514
|
+
+ "border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,0.25));"
|
|
4515
|
+
+ "box-shadow:0 6px 20px rgba(0,0,0,0.28);color:var(--dsw-alias-label-primary,#f9fafb);"
|
|
4516
|
+
+ "font-size:11.5px;line-height:1.55;white-space:pre-wrap;word-break:break-word;pointer-events:none;}",
|
|
4517
|
+
".dshh-timerGlyph{flex:none;opacity:.85;}",
|
|
4518
|
+
".dshh-timerValue{font-variant-numeric:tabular-nums;}",
|
|
3965
4519
|
".dshh-panel{color:var(--dsw-alias-label-primary,#e5e7eb);font-size:12.5px;line-height:1.6;}",
|
|
3966
4520
|
".dshh-header{display:flex;align-items:center;gap:8px;padding:6px 8px;flex:none;cursor:move;line-height:1.4;",
|
|
3967
4521
|
"user-select:none;touch-action:none;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,0.22));}",
|
|
@@ -4276,6 +4830,20 @@ window.__ModuleLoader__.load({
|
|
|
4276
4830
|
};
|
|
4277
4831
|
}, "dsh-hud: focus refresh");
|
|
4278
4832
|
|
|
4833
|
+
// 悬停气泡单独一枚 overlay 座位:它要浮在 dock 行之外(行内会被裁切/堆叠上下文限制)。
|
|
4834
|
+
// order 比面板小 —— 面板开着时仍然压在最上层。
|
|
4835
|
+
ctx.slots.inject("shell.overlay", function () {
|
|
4836
|
+
return ctx.slots.register({
|
|
4837
|
+
name: "shell.overlay",
|
|
4838
|
+
id: "dsh-hud-pill-tip",
|
|
4839
|
+
order: 10,
|
|
4840
|
+
locale: NS,
|
|
4841
|
+
inject: function () {
|
|
4842
|
+
return { store: store };
|
|
4843
|
+
},
|
|
4844
|
+
}, PillTip);
|
|
4845
|
+
});
|
|
4846
|
+
|
|
4279
4847
|
ctx.slots.inject("shell.overlay", function () {
|
|
4280
4848
|
return ctx.slots.register({
|
|
4281
4849
|
name: "shell.overlay",
|
|
@@ -4364,6 +4932,13 @@ window.__ModuleLoader__.load({
|
|
|
4364
4932
|
},
|
|
4365
4933
|
}, TurnImages);
|
|
4366
4934
|
});
|
|
4935
|
+
|
|
4936
|
+
// 输入框里的 ↑ 召回(纯文本):**最后接线** —— 逻辑全在纯函数里,这一层只负责
|
|
4937
|
+
// 读值 / 写值 / 摆光标,且**真接管了才拦默认行为**。刻意排在最后注册,好让
|
|
4938
|
+
// "第一个 keydown 监听是面板那条"这个既有事实保持不变。
|
|
4939
|
+
ctx.effect(function () {
|
|
4940
|
+
return bindPromptHistory(store);
|
|
4941
|
+
}, "dsh-hud: prompt history (↑ 召回)");
|
|
4367
4942
|
}
|
|
4368
4943
|
|
|
4369
4944
|
exports.apply = apply;
|