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.
Files changed (64) hide show
  1. package/README.md +376 -365
  2. package/README.zh-CN.md +13 -2
  3. package/dist/agent/model-turn.js +14 -1
  4. package/dist/agent/run-coordinator.js +5 -0
  5. package/dist/agent/spawn.js +38 -5
  6. package/dist/agent/stages/model-runner.js +1 -1
  7. package/dist/agent/stages/tool-dispatcher.js +1 -0
  8. package/dist/agent/work-discipline.js +3 -1
  9. package/dist/config/index.js +52 -2
  10. package/dist/config/presets.js +40 -1
  11. package/dist/config/profiles.js +9 -1
  12. package/dist/context/clearing.js +53 -0
  13. package/dist/context/relevance.js +6 -0
  14. package/dist/context/text-search.js +113 -0
  15. package/dist/llm/index.js +35 -8
  16. package/dist/llm/providers/anthropic.js +14 -4
  17. package/dist/llm/reasoning.js +141 -0
  18. package/dist/memory/reflect.js +1 -1
  19. package/dist/memory/store.js +31 -28
  20. package/dist/models/budgets.js +11 -0
  21. package/dist/models/catalog.js +130 -0
  22. package/dist/models/map-preset.js +50 -0
  23. package/dist/models/model-panel.js +227 -0
  24. package/dist/models/protocol.js +44 -0
  25. package/dist/models/reasoning-cap.js +88 -0
  26. package/dist/models/search.js +58 -0
  27. package/dist/models/types.js +8 -0
  28. package/dist/repl/commands/effort.js +81 -0
  29. package/dist/repl/commands/model-actions.js +138 -0
  30. package/dist/repl/commands/model-catalog.js +67 -0
  31. package/dist/repl/commands/model-panel-ui.js +107 -0
  32. package/dist/repl/commands/model.js +17 -0
  33. package/dist/repl/commands/registry.js +6 -0
  34. package/dist/repl/commands/session.js +54 -0
  35. package/dist/repl/commands/stats.js +28 -0
  36. package/dist/repl/commands.js +3 -0
  37. package/dist/repl/runtime.js +5 -1
  38. package/dist/repl/status-bar.js +4 -2
  39. package/dist/rollback/store.js +17 -0
  40. package/dist/runtime/runtime.js +31 -1
  41. package/dist/session/compact.js +84 -3
  42. package/dist/session/index.js +2 -0
  43. package/dist/session/retention.js +297 -0
  44. package/dist/session/scheduler.js +30 -3
  45. package/dist/session/store.js +108 -6
  46. package/dist/session/usage-stats.js +90 -0
  47. package/dist/skills/activation.js +2 -0
  48. package/dist/skills/builtin-skills.js +153 -0
  49. package/dist/skills/discover.js +3 -1
  50. package/dist/skills/runner.js +4 -1
  51. package/dist/tools/builtins/index.js +4 -0
  52. package/dist/tools/builtins/read-file.js +19 -1
  53. package/dist/tools/builtins/session-search.js +33 -0
  54. package/dist/tools/builtins/task.js +2 -0
  55. package/dist/tools/constants.js +8 -0
  56. package/dist/tools/read-dedup.js +62 -0
  57. package/dist/tools/tool-runtime.js +1 -0
  58. package/dist/ui/batch.js +109 -103
  59. package/dist/ui/fuzzy-picker.js +227 -0
  60. package/dist/ui/hierarchical-picker.js +302 -0
  61. package/dist/ui/intervention.js +11 -1
  62. package/dist/ui/layout-internal/statusbar.js +18 -7
  63. package/dist/ui/render.js +2 -0
  64. 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: opts?.indent,
94
- parentId: opts?.parentId && batches.has(opts.parentId) ? opts.parentId : undefined,
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
- if (callId && b.groupParent)
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
- // 组容器批:优先按 callId 定位 entry,防止多个同名 sub-agent 结果互相填错位置。
153
- if (callId && b.groupParent) {
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
- // 子批尚未落盘(组容器折叠期间被隐藏 / 折叠后才新建)。绝不能 contentWrite 到 buffer 末尾:
548
- // 那会在正文区留下一条游离的子 agent 摘要行,父批再展开时就变成「多出来的第三条」。
554
+ // 子批尚未落盘(父批折叠期间被隐藏 / 折叠后才新建)。绝不能 contentWrite 到 buffer 末尾:
555
+ // 那会在正文区留下一条游离的子批摘要行,父批再展开时就变成「多出来的第三条」。
549
556
  if (b.parentId) {
550
557
  const parent = batches.get(b.parentId);
551
- if (parent?.groupParent) {
558
+ if (parent) {
552
559
  // 父批折叠中:不渲染,等 expand() 统一恢复(那时会用最新状态重建摘要行)。
553
560
  if (!expandedBatches.has(parent.id) || parent.summaryAbsIdx < 0)
554
561
  return;
555
- // 父批已展开:插到自己的 └─ sub-agent 行下方。
556
- const anchor = findParentEntryAbsLine(parent.id, b.groupChildIndex ?? 0) ?? parent.summaryAbsIdx;
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
- // 组容器父批处于折叠态时,子批摘要行先不渲染;等父批展开时由 expand 统一恢复,
588
- // 避免子批摘要残留在父批明细区、再次展开后出现重复行。
589
- if (parent.groupParent && !expandedBatches.has(parent.id)) {
594
+ // 父批(组容器批或普通子 agent 批)折叠时,子批摘要行先不渲染;
595
+ // 等父批展开时由 expand 统一恢复,避免摘要残留/重复行。
596
+ if (!expandedBatches.has(parent.id)) {
590
597
  b.summaryAbsIdx = -1;
591
598
  return;
592
599
  }
593
- let anchor;
594
- if (parent.groupParent && b.groupChildIndex != null && expandedBatches.has(parent.id)) {
595
- // 组容器已展开:子 agent 工具批插到第 groupChildIndex 个 └─ sub-agent 行下方。
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
- // 组容器批的 entry 与子批摘要行交错,新 entry 必须插在当前块末尾,
691
- // 不能简单用 summaryAbsIdx+renderedCount(否则 entry 会插到前一个子批摘要行之前)。
692
- // 块末还必须计入已渲染 entry 的二层展开明细行(toggleEntry 把明细插在 entry 行下方,
693
- // 会把后续 entry 行整体下移;运行态可交互后这条路径高频,漏算会插错位)。
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
- if (b.groupParent) {
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
- // 组容器批展开时:把之前被折叠隐藏的子批摘要行重新插回对应 entry 下方,
731
+ // 展开时:把之前被折叠隐藏的子批摘要行重新插回对应的内部 sub-agent entry 下方,
765
732
  // 否则子批摘要行会留在父批摘要行之后、造成明细重复/错位。
766
- if (b.groupParent) {
733
+ // 组容器批与普通子 agent 批同一路径(嵌套孙批保持折叠,逐层展开)。
734
+ {
767
735
  const children = [...batches.values()]
768
736
  .filter((x) => x.parentId === b.id)
769
- .sort((a, b) => (a.groupChildIndex ?? 0) - (b.groupChildIndex ?? 0));
737
+ .sort((x, y) => (x.groupChildIndex ?? 0) - (y.groupChildIndex ?? 0));
770
738
  for (const child of children) {
771
- const entryAbs = findParentEntryAbsLine(b.id, child.groupChildIndex ?? 0);
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
- if (b.groupParent) {
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
- // 组容器批的 entry 对应一个子 Agent 批;点击 entry 行应展开/折叠该 entry
871
- // 自身的详情(即子 agent 返回的完整文本输出)。子 agent 的工具调用列表由点击
872
- // 子批自己的摘要行(● 子 Agent 完成 ...)来控制。
873
- if (b.groupParent) {
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
+ }