mocode-ai 1.6.4 → 1.6.5
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.md +376 -365
- package/README.zh-CN.md +13 -2
- package/dist/agent/model-turn.js +14 -1
- package/dist/agent/run-coordinator.js +5 -0
- package/dist/agent/spawn.js +38 -5
- package/dist/agent/stages/model-runner.js +1 -1
- package/dist/agent/stages/tool-dispatcher.js +1 -0
- package/dist/agent/work-discipline.js +3 -1
- package/dist/config/index.js +52 -2
- package/dist/config/presets.js +40 -1
- package/dist/config/profiles.js +9 -1
- package/dist/context/clearing.js +53 -0
- package/dist/context/relevance.js +6 -0
- package/dist/context/text-search.js +113 -0
- package/dist/llm/index.js +35 -8
- package/dist/llm/providers/anthropic.js +14 -4
- package/dist/llm/reasoning.js +141 -0
- package/dist/memory/reflect.js +1 -1
- package/dist/memory/store.js +31 -28
- package/dist/models/budgets.js +11 -0
- package/dist/models/catalog.js +130 -0
- package/dist/models/map-preset.js +50 -0
- package/dist/models/model-panel.js +227 -0
- package/dist/models/protocol.js +44 -0
- package/dist/models/reasoning-cap.js +88 -0
- package/dist/models/search.js +58 -0
- package/dist/models/types.js +8 -0
- package/dist/repl/commands/effort.js +81 -0
- package/dist/repl/commands/model-actions.js +138 -0
- package/dist/repl/commands/model-catalog.js +67 -0
- package/dist/repl/commands/model-panel-ui.js +107 -0
- package/dist/repl/commands/model.js +17 -0
- package/dist/repl/commands/registry.js +6 -0
- package/dist/repl/commands/session.js +54 -0
- package/dist/repl/commands/stats.js +28 -0
- package/dist/repl/commands.js +3 -0
- package/dist/repl/runtime.js +5 -1
- package/dist/repl/status-bar.js +4 -2
- package/dist/rollback/store.js +17 -0
- package/dist/runtime/runtime.js +31 -1
- package/dist/session/compact.js +84 -3
- package/dist/session/index.js +2 -0
- package/dist/session/retention.js +297 -0
- package/dist/session/scheduler.js +30 -3
- package/dist/session/store.js +108 -6
- package/dist/session/usage-stats.js +90 -0
- package/dist/skills/activation.js +2 -0
- package/dist/skills/builtin-skills.js +153 -0
- package/dist/skills/discover.js +3 -1
- package/dist/skills/runner.js +4 -1
- package/dist/tools/builtins/index.js +4 -0
- package/dist/tools/builtins/read-file.js +19 -1
- package/dist/tools/builtins/session-search.js +33 -0
- package/dist/tools/builtins/task.js +2 -0
- package/dist/tools/constants.js +8 -0
- package/dist/tools/read-dedup.js +62 -0
- package/dist/tools/tool-runtime.js +1 -0
- package/dist/ui/batch.js +109 -103
- package/dist/ui/fuzzy-picker.js +227 -0
- package/dist/ui/hierarchical-picker.js +302 -0
- package/dist/ui/intervention.js +11 -1
- package/dist/ui/layout-internal/statusbar.js +18 -7
- package/dist/ui/render.js +2 -0
- package/package.json +1 -1
package/dist/ui/batch.js
CHANGED
|
@@ -82,6 +82,12 @@ export function reset() {
|
|
|
82
82
|
* opts.groupChildIndex:本批作为组子批时在父 entries 中的序号(插入锚点用)。 */
|
|
83
83
|
export function beginBatch(label, opts) {
|
|
84
84
|
const id = `b${++_idCounter}`;
|
|
85
|
+
const parentId = opts?.parentId && batches.has(opts.parentId) ? opts.parentId : undefined;
|
|
86
|
+
const parent = parentId ? batches.get(parentId) : undefined;
|
|
87
|
+
// 缩进未显式给定时按父层级自动推导:组容器批的子批 = 两层 entry 缩进;
|
|
88
|
+
// 普通子 agent 批下再嵌套 = 父批缩进 + 两层(树状逐层加深)。
|
|
89
|
+
const indent = opts?.indent ??
|
|
90
|
+
(parent ? (parent.groupParent ? SUB_BATCH_INDENT : (parent.indent ?? '') + SUB_BATCH_INDENT) : undefined);
|
|
85
91
|
batches.set(id, {
|
|
86
92
|
id,
|
|
87
93
|
summaryAbsIdx: -1,
|
|
@@ -90,8 +96,8 @@ export function beginBatch(label, opts) {
|
|
|
90
96
|
renderedCount: 0,
|
|
91
97
|
startedAt: Date.now(),
|
|
92
98
|
label,
|
|
93
|
-
indent
|
|
94
|
-
parentId
|
|
99
|
+
indent,
|
|
100
|
+
parentId,
|
|
95
101
|
groupParent: opts?.groupParent ?? false,
|
|
96
102
|
groupChildIndex: opts?.groupChildIndex,
|
|
97
103
|
running: opts?.running ?? false,
|
|
@@ -123,7 +129,8 @@ export function recordCall(id, name, callSummary, callId) {
|
|
|
123
129
|
// 已完成的累计探索后又追加工具:恢复进行中,待新结果返回再完成。
|
|
124
130
|
b.finishedAt = undefined;
|
|
125
131
|
b.entries.push({ name, callSummary, resultSummary: '', diffBlock: null });
|
|
126
|
-
|
|
132
|
+
// 对所有批登记:普通批内的 sub-agent 调用(子 agent 嵌套派生)同样要靠它定位锚点。
|
|
133
|
+
if (callId)
|
|
127
134
|
groupChildIndexByCall.set(callId, b.entries.length - 1);
|
|
128
135
|
// 新增 entry = 本批重新「在飞」→ 扫光心跳起(此前收口时可能已停)。
|
|
129
136
|
syncSweepTimer();
|
|
@@ -149,8 +156,8 @@ export function recordResult(id, name, resultSummary, diffBlock, fullOutput, fai
|
|
|
149
156
|
const b = batches.get(id);
|
|
150
157
|
if (!b || b.entries.length === 0)
|
|
151
158
|
return;
|
|
152
|
-
//
|
|
153
|
-
if (callId
|
|
159
|
+
// 优先按 callId 定位 entry,防止多个同名工具(并行调用 / 嵌套 sub-agent)结果互相填错。
|
|
160
|
+
if (callId) {
|
|
154
161
|
const idx = groupChildIndexByCall.get(callId);
|
|
155
162
|
if (idx != null && idx < b.entries.length && !isEntryDone(b.entries[idx])) {
|
|
156
163
|
const e = b.entries[idx];
|
|
@@ -544,16 +551,16 @@ export function endBatch(id, layout) {
|
|
|
544
551
|
absLineToBatchId.set(b.summaryAbsIdx, b.id);
|
|
545
552
|
return;
|
|
546
553
|
}
|
|
547
|
-
// 子批尚未落盘(
|
|
548
|
-
//
|
|
554
|
+
// 子批尚未落盘(父批折叠期间被隐藏 / 折叠后才新建)。绝不能 contentWrite 到 buffer 末尾:
|
|
555
|
+
// 那会在正文区留下一条游离的子批摘要行,父批再展开时就变成「多出来的第三条」。
|
|
549
556
|
if (b.parentId) {
|
|
550
557
|
const parent = batches.get(b.parentId);
|
|
551
|
-
if (parent
|
|
558
|
+
if (parent) {
|
|
552
559
|
// 父批折叠中:不渲染,等 expand() 统一恢复(那时会用最新状态重建摘要行)。
|
|
553
560
|
if (!expandedBatches.has(parent.id) || parent.summaryAbsIdx < 0)
|
|
554
561
|
return;
|
|
555
|
-
//
|
|
556
|
-
const anchor =
|
|
562
|
+
// 父批已展开:插到对应的内部 sub-agent entry 行下方。
|
|
563
|
+
const anchor = childBatchAnchor(parent, b.groupChildIndex);
|
|
557
564
|
layout.contentInsertAfter(anchor, [sanitizeRow(buildSummaryLine(b))]);
|
|
558
565
|
b.summaryAbsIdx = anchor + 1;
|
|
559
566
|
absLineToBatchId.set(b.summaryAbsIdx, b.id);
|
|
@@ -584,29 +591,15 @@ export function showLiveBatch(id, layout) {
|
|
|
584
591
|
// 让「子 agent 的工具明细」始终跟在自己的父调用行下——并行派发时才不串行。
|
|
585
592
|
const parent = b.parentId ? batches.get(b.parentId) : undefined;
|
|
586
593
|
if (parent && parent.summaryAbsIdx >= 0 && layout.contentInsertAfter) {
|
|
587
|
-
//
|
|
588
|
-
//
|
|
589
|
-
if (
|
|
594
|
+
// 父批(组容器批或普通子 agent 批)折叠时,子批摘要行先不渲染;
|
|
595
|
+
// 等父批展开时由 expand 统一恢复,避免摘要残留/重复行。
|
|
596
|
+
if (!expandedBatches.has(parent.id)) {
|
|
590
597
|
b.summaryAbsIdx = -1;
|
|
591
598
|
return;
|
|
592
599
|
}
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
// 不能用固定偏移 summaryAbsIdx+1+childIndex——前面兄弟子批的内容会把它后面的
|
|
597
|
-
// entry 行整体下移,固定偏移会错位;用 absLineToEntry 登记的真实绝对索引。
|
|
598
|
-
let entryAbs = parent.summaryAbsIdx + 1 + b.groupChildIndex;
|
|
599
|
-
for (const [idx, target] of absLineToEntry) {
|
|
600
|
-
if (target.batchId === parent.id && target.entryIndex === b.groupChildIndex) {
|
|
601
|
-
entryAbs = idx;
|
|
602
|
-
break;
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
anchor = entryAbs;
|
|
606
|
-
}
|
|
607
|
-
else {
|
|
608
|
-
anchor = parent.summaryAbsIdx + (expandedBatches.has(parent.id) ? parent.renderedCount : 0);
|
|
609
|
-
}
|
|
600
|
+
// 父批已展开:插到本批对应的内部 sub-agent entry 行下方(按真实登记位置,
|
|
601
|
+
// 不用固定偏移——兄弟内容会把后续行整体下移);找不到 entry 时退化到父批块末。
|
|
602
|
+
const anchor = childBatchAnchor(parent, b.groupChildIndex);
|
|
610
603
|
layout.contentInsertAfter(anchor, [sanitizeRow(summary)], false);
|
|
611
604
|
b.summaryAbsIdx = anchor + 1;
|
|
612
605
|
absLineToBatchId.set(b.summaryAbsIdx, b.id); // 运行态同样开放点击(endBatch 幂等重登)
|
|
@@ -687,43 +680,17 @@ export function refreshBatchExpanded(id, layout) {
|
|
|
687
680
|
const newEntries = b.entries.slice(b.renderedCount);
|
|
688
681
|
const lines = buildExpandedLines(b, b.indent ?? '', b.renderedCount);
|
|
689
682
|
// 实时追加:不锚定视口,让新明细行自然出现在屏底。
|
|
690
|
-
//
|
|
691
|
-
//
|
|
692
|
-
//
|
|
693
|
-
//
|
|
683
|
+
// 锚点必须是本批已渲染块的真实最末行——不能简单用 summaryAbsIdx+renderedCount:
|
|
684
|
+
// ①toggleEntry 展开的二层明细行会把后续行下移;
|
|
685
|
+
// ②挂在内部 sub-agent entry 下的嵌套子批(摘要 + 其展开内容)也在本批块内。
|
|
686
|
+
// 组容器批与普通子 agent 批走同一计算。
|
|
694
687
|
let anchor = b.summaryAbsIdx + b.renderedCount;
|
|
695
688
|
for (const j of b.expandedEntries) {
|
|
696
689
|
if (j < b.renderedCount) {
|
|
697
690
|
anchor += buildEntryDetailLines(b.entries[j], entryDetailIndent(b.indent ?? '')).length;
|
|
698
691
|
}
|
|
699
692
|
}
|
|
700
|
-
|
|
701
|
-
let maxIdx = anchor;
|
|
702
|
-
for (const [idx, target] of absLineToEntry) {
|
|
703
|
-
if (target.batchId !== b.id)
|
|
704
|
-
continue;
|
|
705
|
-
// entry 行自身(含其二层明细)的块末位置
|
|
706
|
-
const end = b.expandedEntries.has(target.entryIndex)
|
|
707
|
-
? idx + buildEntryDetailLines(b.entries[target.entryIndex], entryDetailIndent(b.indent ?? '')).length
|
|
708
|
-
: idx;
|
|
709
|
-
if (end > maxIdx)
|
|
710
|
-
maxIdx = end;
|
|
711
|
-
}
|
|
712
|
-
for (const child of batches.values()) {
|
|
713
|
-
if (child.parentId === b.id && child.summaryAbsIdx >= 0) {
|
|
714
|
-
let childEnd = child.summaryAbsIdx;
|
|
715
|
-
if (expandedBatches.has(child.id)) {
|
|
716
|
-
childEnd += child.renderedCount;
|
|
717
|
-
for (const j of child.expandedEntries) {
|
|
718
|
-
childEnd += buildEntryDetailLines(child.entries[j], entryDetailIndent(child.indent ?? '')).length;
|
|
719
|
-
}
|
|
720
|
-
}
|
|
721
|
-
if (childEnd > maxIdx)
|
|
722
|
-
maxIdx = childEnd;
|
|
723
|
-
}
|
|
724
|
-
}
|
|
725
|
-
anchor = maxIdx;
|
|
726
|
-
}
|
|
693
|
+
anchor = blockEndAbsIdx(b, anchor);
|
|
727
694
|
layout.contentInsertAfter(anchor, lines, false);
|
|
728
695
|
// 登记新增明细行的点击命中(按实际插入位置)
|
|
729
696
|
for (let i = 0; i < newEntries.length; i++) {
|
|
@@ -761,15 +728,15 @@ function expand(b, layout, live = false) {
|
|
|
761
728
|
absLineToEntry.set(b.summaryAbsIdx + 1 + i, { batchId: b.id, entryIndex: i });
|
|
762
729
|
b.entries[i].renderedDigest = entryLineDigest(b.entries[i]); // 记指纹,供运行态结果回填原位刷新
|
|
763
730
|
}
|
|
764
|
-
//
|
|
731
|
+
// 展开时:把之前被折叠隐藏的子批摘要行重新插回对应的内部 sub-agent entry 下方,
|
|
765
732
|
// 否则子批摘要行会留在父批摘要行之后、造成明细重复/错位。
|
|
766
|
-
|
|
733
|
+
// 组容器批与普通子 agent 批同一路径(嵌套孙批保持折叠,逐层展开)。
|
|
734
|
+
{
|
|
767
735
|
const children = [...batches.values()]
|
|
768
736
|
.filter((x) => x.parentId === b.id)
|
|
769
|
-
.sort((
|
|
737
|
+
.sort((x, y) => (x.groupChildIndex ?? 0) - (y.groupChildIndex ?? 0));
|
|
770
738
|
for (const child of children) {
|
|
771
|
-
const
|
|
772
|
-
const anchor = entryAbs ?? b.summaryAbsIdx + b.renderedCount;
|
|
739
|
+
const anchor = childBatchAnchor(b, child.groupChildIndex);
|
|
773
740
|
const summary = buildSummaryLine(child, true);
|
|
774
741
|
layout.contentInsertAfter(anchor, [sanitizeRow(summary)], !live);
|
|
775
742
|
child.summaryAbsIdx = anchor + 1;
|
|
@@ -784,6 +751,75 @@ function findParentEntryAbsLine(parentId, entryIndex) {
|
|
|
784
751
|
}
|
|
785
752
|
return null;
|
|
786
753
|
}
|
|
754
|
+
/** 子批挂到父批(组容器批或普通子 agent 批)时的插入锚点:
|
|
755
|
+
* 优先取该子批对应的内部 sub-agent entry 真实行;未登记则退化到父批已渲染块末。 */
|
|
756
|
+
function childBatchAnchor(parent, childIndex) {
|
|
757
|
+
if (childIndex != null) {
|
|
758
|
+
const entryAbs = findParentEntryAbsLine(parent.id, childIndex);
|
|
759
|
+
if (entryAbs != null)
|
|
760
|
+
return entryAbs;
|
|
761
|
+
}
|
|
762
|
+
let end = parent.summaryAbsIdx + parent.renderedCount;
|
|
763
|
+
for (const j of parent.expandedEntries) {
|
|
764
|
+
end += buildEntryDetailLines(parent.entries[j], entryDetailIndent(parent.indent ?? '')).length;
|
|
765
|
+
}
|
|
766
|
+
return blockEndAbsIdx(parent, end);
|
|
767
|
+
}
|
|
768
|
+
/** 批已渲染块的真实最末绝对行:把直接挂在本批下的子批(摘要行;若子批展开,
|
|
769
|
+
* 还含其明细行与再嵌套子批)全部计入,递归得到整棵树的块末。 */
|
|
770
|
+
function blockEndAbsIdx(b, initial) {
|
|
771
|
+
let maxIdx = initial;
|
|
772
|
+
for (const child of batches.values()) {
|
|
773
|
+
if (child.parentId !== b.id || child.summaryAbsIdx < 0)
|
|
774
|
+
continue;
|
|
775
|
+
let childEnd = child.summaryAbsIdx;
|
|
776
|
+
if (expandedBatches.has(child.id)) {
|
|
777
|
+
childEnd += child.renderedCount;
|
|
778
|
+
for (const j of child.expandedEntries) {
|
|
779
|
+
childEnd += buildEntryDetailLines(child.entries[j], entryDetailIndent(child.indent ?? '')).length;
|
|
780
|
+
}
|
|
781
|
+
childEnd = blockEndAbsIdx(child, childEnd);
|
|
782
|
+
}
|
|
783
|
+
if (childEnd > maxIdx)
|
|
784
|
+
maxIdx = childEnd;
|
|
785
|
+
}
|
|
786
|
+
return maxIdx;
|
|
787
|
+
return maxIdx;
|
|
788
|
+
}
|
|
789
|
+
/** 折叠父批的所有直接子批(递归):返回这些子批在 buffer 中占据的总行数。
|
|
790
|
+
* 未落盘子批(父批折叠期间新建)在 buffer 无对应行,只清状态不计数,避免多删相邻正文。 */
|
|
791
|
+
function foldChildren(parentId) {
|
|
792
|
+
let count = 0;
|
|
793
|
+
for (const child of batches.values()) {
|
|
794
|
+
if (child.parentId !== parentId)
|
|
795
|
+
continue;
|
|
796
|
+
if (child.summaryAbsIdx < 0) {
|
|
797
|
+
child.renderedCount = 0;
|
|
798
|
+
child.expandedEntries.clear();
|
|
799
|
+
expandedBatches.delete(child.id);
|
|
800
|
+
foldChildren(child.id);
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
count += 1; // 子批摘要行本身
|
|
804
|
+
if (expandedBatches.has(child.id)) {
|
|
805
|
+
count += child.renderedCount;
|
|
806
|
+
for (const j of child.expandedEntries) {
|
|
807
|
+
count += buildEntryDetailLines(child.entries[j], entryDetailIndent(child.indent ?? '')).length;
|
|
808
|
+
}
|
|
809
|
+
count += foldChildren(child.id);
|
|
810
|
+
child.expandedEntries.clear();
|
|
811
|
+
expandedBatches.delete(child.id);
|
|
812
|
+
}
|
|
813
|
+
child.renderedCount = 0;
|
|
814
|
+
absLineToBatchId.delete(child.summaryAbsIdx);
|
|
815
|
+
child.summaryAbsIdx = -1;
|
|
816
|
+
for (const [idx, target] of absLineToEntry) {
|
|
817
|
+
if (target.batchId === child.id)
|
|
818
|
+
absLineToEntry.delete(idx);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
return count;
|
|
822
|
+
}
|
|
787
823
|
/** mutation 独占 batch 收尾后立即展示其调用概要和 diff。 */
|
|
788
824
|
export function expandSingleEntryFully(id, layout) {
|
|
789
825
|
const b = batches.get(id);
|
|
@@ -815,38 +851,9 @@ function collapse(b, layout) {
|
|
|
815
851
|
for (const i of b.expandedEntries) {
|
|
816
852
|
lineCount += buildEntryDetailLines(b.entries[i], entryDetailIndent(b.indent ?? '')).length;
|
|
817
853
|
}
|
|
818
|
-
//
|
|
819
|
-
//
|
|
820
|
-
|
|
821
|
-
const children = [...batches.values()].filter((x) => x.parentId === b.id);
|
|
822
|
-
for (const child of children) {
|
|
823
|
-
// 未落盘的子批(父批折叠期间新建)在 buffer 里没有对应行,计进 lineCount 会多删相邻正文。
|
|
824
|
-
if (child.summaryAbsIdx < 0) {
|
|
825
|
-
child.renderedCount = 0;
|
|
826
|
-
child.expandedEntries.clear();
|
|
827
|
-
expandedBatches.delete(child.id);
|
|
828
|
-
continue;
|
|
829
|
-
}
|
|
830
|
-
lineCount += 1; // 子批摘要行本身
|
|
831
|
-
if (expandedBatches.has(child.id)) {
|
|
832
|
-
// 子批自身展开时,它的第一层明细行(renderedCount)也在父批块内,必须一并计入,
|
|
833
|
-
// 否则删少了会留下孤儿明细行。
|
|
834
|
-
lineCount += child.renderedCount;
|
|
835
|
-
for (const j of child.expandedEntries) {
|
|
836
|
-
lineCount += buildEntryDetailLines(child.entries[j], entryDetailIndent(child.indent ?? '')).length;
|
|
837
|
-
}
|
|
838
|
-
child.expandedEntries.clear();
|
|
839
|
-
expandedBatches.delete(child.id);
|
|
840
|
-
}
|
|
841
|
-
child.renderedCount = 0;
|
|
842
|
-
absLineToBatchId.delete(child.summaryAbsIdx);
|
|
843
|
-
child.summaryAbsIdx = -1;
|
|
844
|
-
for (const [idx, target] of absLineToEntry) {
|
|
845
|
-
if (target.batchId === child.id)
|
|
846
|
-
absLineToEntry.delete(idx);
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
}
|
|
854
|
+
// 折叠时:递归移除挂在本批下的整棵子批树(子批摘要行 + 已展开明细),
|
|
855
|
+
// 否则父批再次展开后子批摘要/明细仍残留,出现重复行。组容器批与普通子 agent 批同一路径。
|
|
856
|
+
lineCount += foldChildren(b.id);
|
|
850
857
|
layout.contentDeleteFrom(b.summaryAbsIdx + 1, lineCount);
|
|
851
858
|
expandedBatches.delete(b.id);
|
|
852
859
|
b.renderedCount = 0;
|
|
@@ -867,11 +874,10 @@ export function toggleEntry(batchId, entryIndex, layout) {
|
|
|
867
874
|
const b = batches.get(batchId);
|
|
868
875
|
if (!b || !expandedBatches.has(batchId))
|
|
869
876
|
return;
|
|
870
|
-
//
|
|
871
|
-
//
|
|
872
|
-
//
|
|
873
|
-
if (b.
|
|
874
|
-
// 如果该 sub-agent entry 没有可展开的详情,fallback 到 toggle 子批工具列表。
|
|
877
|
+
// sub-agent entry(组容器批的顶层调用,或子 agent 内部嵌套派生)对应一个子批:
|
|
878
|
+
// 点击 entry 行优先展开/折叠该 entry 自身详情(子 agent 返回的完整文本输出);
|
|
879
|
+
// 只有没有自身详情时,才 fallback 到 toggle 对应子批的工具列表。
|
|
880
|
+
if (b.entries[entryIndex]?.name === 'sub-agent') {
|
|
875
881
|
const details = buildEntryDetailLines(b.entries[entryIndex], entryDetailIndent(b.indent ?? ''));
|
|
876
882
|
if (details.length === 0) {
|
|
877
883
|
const child = [...batches.values()].find((x) => x.parentId === batchId && x.groupChildIndex === entryIndex);
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 通用模糊选择器(#模型命令交互)。
|
|
3
|
+
*
|
|
4
|
+
* 把 history-picker 的「输入即过滤 + ↑↓ + Enter」骨架抽成泛型组件:候选是任意对象,
|
|
5
|
+
* 由调用方提供 searchable 文本与渲染行。不绑定「历史/模型」语义,任何「从一大堆项里
|
|
6
|
+
* 挑一个」的场景都能用。
|
|
7
|
+
*
|
|
8
|
+
* 交互契约:
|
|
9
|
+
* - 可打印字符 追加到过滤词,实时模糊过滤(fuzzy.ts)
|
|
10
|
+
* - ↑/↓ 移动高亮(循环;只在可选项间移动)
|
|
11
|
+
* - Enter 确认当前高亮项
|
|
12
|
+
* - Esc / Ctrl+C 取消,返回 null
|
|
13
|
+
* - Backspace 等 常规行编辑;Ctrl+U 清行
|
|
14
|
+
*/
|
|
15
|
+
import { stdin } from 'node:process';
|
|
16
|
+
import { ui } from './theme.js';
|
|
17
|
+
import { displayWidth, truncateDisplay } from './render.js';
|
|
18
|
+
import * as layout from './layout.js';
|
|
19
|
+
import { fuzzyMatch } from './fuzzy.js';
|
|
20
|
+
/** 纯函数:按 query 过滤+排序,禁用项沉底并保留(用于呈现,不参与选择)。导出供单测。 */
|
|
21
|
+
export function filterItems(query, items) {
|
|
22
|
+
const q = query.trim();
|
|
23
|
+
const enabled = [];
|
|
24
|
+
const disabled = [];
|
|
25
|
+
for (const item of items) {
|
|
26
|
+
if (!q) {
|
|
27
|
+
(item.disabled ? disabled : enabled).push({ item, score: 0 });
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const m = fuzzyMatch(q, item.searchText);
|
|
31
|
+
if (!m)
|
|
32
|
+
continue;
|
|
33
|
+
(item.disabled ? disabled : enabled).push({ item, score: m.score });
|
|
34
|
+
}
|
|
35
|
+
if (q)
|
|
36
|
+
enabled.sort((a, b) => b.score - a.score);
|
|
37
|
+
return [...enabled, ...disabled];
|
|
38
|
+
}
|
|
39
|
+
export async function promptFuzzyPicker(opts) {
|
|
40
|
+
if (!layout.isActive())
|
|
41
|
+
return null;
|
|
42
|
+
const maxVisible = opts.maxVisible ?? 10;
|
|
43
|
+
const emitter = stdin;
|
|
44
|
+
let query = opts.initialQuery ?? '';
|
|
45
|
+
let cursor = query.length;
|
|
46
|
+
let matches = [];
|
|
47
|
+
let selected = 0; // 在 matches 中的下标(可能落在禁用项,移动时跳过)
|
|
48
|
+
let menuTop = 0;
|
|
49
|
+
let resolved = false;
|
|
50
|
+
let resolve;
|
|
51
|
+
/** 可选项(非禁用)下标集合。 */
|
|
52
|
+
function enabledIndices() {
|
|
53
|
+
const out = [];
|
|
54
|
+
matches.forEach((r, i) => {
|
|
55
|
+
if (!r.item.disabled)
|
|
56
|
+
out.push(i);
|
|
57
|
+
});
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
function clampSelected() {
|
|
61
|
+
const en = enabledIndices();
|
|
62
|
+
if (en.length === 0) {
|
|
63
|
+
selected = matches.length ? 0 : 0;
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (!en.includes(selected))
|
|
67
|
+
selected = en[0];
|
|
68
|
+
}
|
|
69
|
+
function recompute() {
|
|
70
|
+
matches = filterItems(query, opts.items);
|
|
71
|
+
clampSelected();
|
|
72
|
+
}
|
|
73
|
+
function menuLines() {
|
|
74
|
+
const cols = layout.getGeo().cols;
|
|
75
|
+
const lines = [`${ui.bold}${truncateDisplay(opts.title, cols)}${ui.reset}`];
|
|
76
|
+
if (opts.hint)
|
|
77
|
+
lines.push(`${ui.dim}${truncateDisplay(opts.hint, cols)}${ui.reset}`);
|
|
78
|
+
const en = enabledIndices();
|
|
79
|
+
if (en.length === 0) {
|
|
80
|
+
lines.push(`${ui.dim}${query.trim() ? '无匹配,按 Esc 返回' : '(空列表)'}${ui.reset}`);
|
|
81
|
+
return lines;
|
|
82
|
+
}
|
|
83
|
+
// 开窗:以 selected 为中心。
|
|
84
|
+
const visibleCount = Math.min(maxVisible, en.length);
|
|
85
|
+
const selPos = en.indexOf(selected);
|
|
86
|
+
let page = Math.floor(selPos / visibleCount);
|
|
87
|
+
const winEn = en.slice(page * visibleCount, page * visibleCount + visibleCount);
|
|
88
|
+
menuTop = winEn[0];
|
|
89
|
+
const hasMore = page * visibleCount + visibleCount < en.length;
|
|
90
|
+
const hasAbove = page > 0;
|
|
91
|
+
winEn.forEach((idx, i) => {
|
|
92
|
+
const r = matches[idx];
|
|
93
|
+
const isSel = idx === selected;
|
|
94
|
+
const color = isSel ? `${ui.accent}${ui.bold}` : '';
|
|
95
|
+
const marker = isSel ? `${ui.accent}${ui.bold}▸${ui.reset}` : ' ';
|
|
96
|
+
let scrollHint = '';
|
|
97
|
+
if (i === 0 && hasAbove)
|
|
98
|
+
scrollHint = ' ▲';
|
|
99
|
+
if (i === winEn.length - 1 && hasMore)
|
|
100
|
+
scrollHint = ' ▼';
|
|
101
|
+
const detail = r.item.detail ? ` ${ui.dim}${r.item.detail}${ui.reset}` : '';
|
|
102
|
+
const budget = cols - 4 - scrollHint.length - (r.item.detail ? displayWidth(r.item.detail) + 2 : 0);
|
|
103
|
+
const body = truncateDisplay(r.item.label, Math.max(1, budget));
|
|
104
|
+
lines.push(`${marker} ${color}${body}${ui.reset}${detail}${ui.dim}${scrollHint}${ui.reset}`);
|
|
105
|
+
});
|
|
106
|
+
return lines;
|
|
107
|
+
}
|
|
108
|
+
function redraw() {
|
|
109
|
+
layout.paintInput({
|
|
110
|
+
prompt: '❯ ',
|
|
111
|
+
lines: [query],
|
|
112
|
+
cursorLine: 0,
|
|
113
|
+
cursorCol: displayWidth(query.slice(0, cursor)),
|
|
114
|
+
menu: { lines: menuLines() },
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
function finish(v) {
|
|
118
|
+
if (resolved)
|
|
119
|
+
return;
|
|
120
|
+
resolved = true;
|
|
121
|
+
layout.setMouseEnabled(true); // 恢复输入框态鼠标(框选/右键粘贴)
|
|
122
|
+
try {
|
|
123
|
+
stdin.setRawMode(false);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
/* 忽略 */
|
|
127
|
+
}
|
|
128
|
+
emitter.removeListener('keypress', onKey);
|
|
129
|
+
stdin.pause();
|
|
130
|
+
// 擦菜单区。
|
|
131
|
+
layout.paintInput({ prompt: '❯ ', lines: [''], cursorLine: 0, cursorCol: 0, menu: null });
|
|
132
|
+
resolve(v);
|
|
133
|
+
}
|
|
134
|
+
function move(delta) {
|
|
135
|
+
const en = enabledIndices();
|
|
136
|
+
if (en.length === 0)
|
|
137
|
+
return;
|
|
138
|
+
const pos = en.indexOf(selected);
|
|
139
|
+
const nextPos = (pos + delta + en.length) % en.length;
|
|
140
|
+
selected = en[nextPos];
|
|
141
|
+
redraw();
|
|
142
|
+
}
|
|
143
|
+
function onKey(_str, key) {
|
|
144
|
+
if (key.name === 'escape')
|
|
145
|
+
return finish(null);
|
|
146
|
+
if (key.ctrl && key.name === 'c')
|
|
147
|
+
return finish(null);
|
|
148
|
+
if (key.name === 'return' || key.name === 'enter') {
|
|
149
|
+
const r = matches[selected];
|
|
150
|
+
if (r && !r.item.disabled)
|
|
151
|
+
finish(r.item.value);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
switch (key.name) {
|
|
155
|
+
case 'up':
|
|
156
|
+
move(-1);
|
|
157
|
+
return;
|
|
158
|
+
case 'down':
|
|
159
|
+
move(1);
|
|
160
|
+
return;
|
|
161
|
+
case 'left':
|
|
162
|
+
if (cursor > 0) {
|
|
163
|
+
cursor--;
|
|
164
|
+
redraw();
|
|
165
|
+
}
|
|
166
|
+
return;
|
|
167
|
+
case 'right':
|
|
168
|
+
if (cursor < query.length) {
|
|
169
|
+
cursor++;
|
|
170
|
+
redraw();
|
|
171
|
+
}
|
|
172
|
+
return;
|
|
173
|
+
case 'home':
|
|
174
|
+
cursor = 0;
|
|
175
|
+
redraw();
|
|
176
|
+
return;
|
|
177
|
+
case 'end':
|
|
178
|
+
cursor = query.length;
|
|
179
|
+
redraw();
|
|
180
|
+
return;
|
|
181
|
+
case 'backspace':
|
|
182
|
+
if (cursor > 0) {
|
|
183
|
+
query = query.slice(0, cursor - 1) + query.slice(cursor);
|
|
184
|
+
cursor--;
|
|
185
|
+
recompute();
|
|
186
|
+
redraw();
|
|
187
|
+
}
|
|
188
|
+
return;
|
|
189
|
+
case 'delete':
|
|
190
|
+
if (cursor < query.length) {
|
|
191
|
+
query = query.slice(0, cursor) + query.slice(cursor + 1);
|
|
192
|
+
recompute();
|
|
193
|
+
redraw();
|
|
194
|
+
}
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (key.ctrl && key.name === 'u') {
|
|
198
|
+
query = '';
|
|
199
|
+
cursor = 0;
|
|
200
|
+
recompute();
|
|
201
|
+
redraw();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const s = key.sequence ?? '';
|
|
205
|
+
if (s && s >= ' ' && !key.ctrl && !key.meta) {
|
|
206
|
+
query = query.slice(0, cursor) + s + query.slice(cursor);
|
|
207
|
+
cursor += s.length;
|
|
208
|
+
recompute();
|
|
209
|
+
redraw();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
recompute();
|
|
213
|
+
return new Promise((res) => {
|
|
214
|
+
resolve = res;
|
|
215
|
+
layout.setMouseEnabled(false);
|
|
216
|
+
try {
|
|
217
|
+
stdin.setRawMode(true);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
res(null);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
stdin.resume();
|
|
224
|
+
emitter.on('keypress', onKey);
|
|
225
|
+
redraw();
|
|
226
|
+
});
|
|
227
|
+
}
|