mocode-ai 0.1.3 → 0.1.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 (48) hide show
  1. package/README.md +196 -161
  2. package/dist/agent/core.js +302 -0
  3. package/dist/agent/index.js +85 -202
  4. package/dist/agent/mode.js +46 -0
  5. package/dist/agent/spawn.js +123 -0
  6. package/dist/commands/config.js +8 -26
  7. package/dist/config/file.js +48 -0
  8. package/dist/config/index.js +76 -22
  9. package/dist/llm/index.js +11 -9
  10. package/dist/memory/discover.js +49 -0
  11. package/dist/memory/index.js +45 -0
  12. package/dist/memory/reflect.js +265 -0
  13. package/dist/memory/store.js +339 -0
  14. package/dist/repl/index.js +334 -87
  15. package/dist/session/compact.js +51 -3
  16. package/dist/skills/index.js +2 -2
  17. package/dist/tools/builtins/ask-human.js +50 -0
  18. package/dist/tools/builtins/codegraph.js +113 -0
  19. package/dist/tools/builtins/edit-file.js +3 -3
  20. package/dist/tools/builtins/glob.js +3 -2
  21. package/dist/tools/builtins/grep.js +4 -3
  22. package/dist/tools/builtins/index.js +19 -1
  23. package/dist/tools/builtins/memory-forget.js +32 -0
  24. package/dist/tools/builtins/memory-list.js +34 -0
  25. package/dist/tools/builtins/memory-save.js +51 -0
  26. package/dist/tools/builtins/memory-search.js +42 -0
  27. package/dist/tools/builtins/memory-update.js +41 -0
  28. package/dist/tools/builtins/read-file.js +5 -4
  29. package/dist/tools/builtins/run-command.js +40 -7
  30. package/dist/tools/builtins/switch-mode.js +46 -0
  31. package/dist/tools/builtins/task.js +62 -0
  32. package/dist/tools/builtins/use-skill.js +2 -2
  33. package/dist/tools/builtins/web-fetch.js +16 -3
  34. package/dist/tools/builtins/web-search.js +6 -6
  35. package/dist/tools/builtins/write-file.js +3 -3
  36. package/dist/tools/constants.js +29 -0
  37. package/dist/tools/registry.js +11 -4
  38. package/dist/ui/content.js +25 -17
  39. package/dist/ui/diff.js +28 -26
  40. package/dist/ui/intervention.js +318 -0
  41. package/dist/ui/layout.js +402 -103
  42. package/dist/ui/markdown.js +607 -0
  43. package/dist/ui/mouse.js +93 -0
  44. package/dist/ui/prompt.js +469 -27
  45. package/dist/ui/render.js +19 -1
  46. package/dist/ui/spinner.js +15 -0
  47. package/dist/ui/theme.js +147 -13
  48. package/package.json +2 -2
package/dist/ui/prompt.js CHANGED
@@ -3,6 +3,7 @@ import { stdin, stdout } from 'node:process';
3
3
  import { ui } from './theme.js';
4
4
  import { displayWidth, padEndDisplay, truncateDisplay } from './render.js';
5
5
  import * as layout from './layout.js';
6
+ import * as mouse from './mouse.js';
6
7
  // ── 粘贴检测(块级 + 时间窗)──
7
8
  // 块级:多字节大块(len>8)或含 CR/LF 的小块 = 粘贴(键盘单键 1 字节,Enter=\r 单字节)。
8
9
  // 时间窗:每块重置 50ms 计时器,静默 50ms 即"粘贴结束"→ onPasteEnd。跨多块的大粘贴(块间 <50ms)累积进
@@ -18,12 +19,17 @@ function ensurePasteDetector() {
18
19
  return;
19
20
  pasteDetectorInstalled = true;
20
21
  stdin.on('data', (chunk) => {
21
- const len = chunk.length;
22
- const hasNL = typeof chunk === 'string'
23
- ? chunk.indexOf('\r') >= 0 || chunk.indexOf('\n') >= 0
24
- : chunk.indexOf(0x0d) >= 0 || chunk.indexOf(0x0a) >= 0;
25
- // 大块(>8 字节)或含换行的多字节块 = 粘贴;键盘单键(1 字节,含 Enter=\r)不触发
26
- if (!(len > 8 || (len > 1 && hasNL)))
22
+ const raw = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
23
+ // 剔除 SGR 鼠标报告(\x1B[<…M/m,连半截也匹配):防快速滚轮/触控板动量在一个 chunk 里拼 ≥2 条
24
+ // 报表(22 字符 > 16 阈值)误判粘贴 50ms、把后续真按键泄进 pasteParts。
25
+ const text = raw.replace(/\x1b\[<[0-9;]*[Mm]/g, '');
26
+ const hasNL = text.indexOf('\r') >= 0 || text.indexOf('\n') >= 0;
27
+ // 按字符数(码点)判粘贴,非字节:CJK 汉字占 3 UTF-8 字节,旧阈值(len>8 字节)会把 IME 提交的
28
+ // 3-10 个汉字误判为粘贴 → 进 50ms 缓冲 → finalizePaste→insertText 落字(且旧 insertText 把光标
29
+ // 置插入文本长度而非末尾,致"后续打字插到行中间")。改:含换行(多行粘贴)或 >16 字符(大块单行
30
+ // 粘贴)才算粘贴;普通 IME 提交走正常按键路径(逐字直插、光标随进、无延迟)。
31
+ const charCount = [...text].length;
32
+ if (!(charCount > 16 || (charCount > 1 && hasNL)))
27
33
  return;
28
34
  pasting = true;
29
35
  if (pasteTimer)
@@ -87,11 +93,14 @@ export async function promptWithSlashMenu(opts) {
87
93
  const cols = layout.getGeo().cols;
88
94
  const maxName = Math.max(...filtered.map((c) => displayWidth(c.name)));
89
95
  return filtered.map((c, i) => {
90
- const marker = i === selected ? `${ui.cyan}▸${ui.reset}` : ' ';
96
+ // 选中项:▸ 与文字均 cyan+bold(去 dim),未选中项保持 dim——选中行整体高亮。
97
+ const isSel = i === selected;
98
+ const color = isSel ? `${ui.cyan}${ui.bold}` : ui.dim;
99
+ const marker = isSel ? `${ui.cyan}${ui.bold}▸${ui.reset}` : ' ';
91
100
  const name = padEndDisplay(c.name, maxName);
92
101
  const descW = cols - maxName - 5; // marker + 空格 + 2 间距
93
102
  const desc = descW > 0 ? truncateDisplay(c.desc, descW) : '';
94
- return `${marker} ${ui.dim}${name}${ui.reset} ${ui.dim}${desc}${ui.reset}`;
103
+ return `${marker} ${color}${name}${ui.reset} ${color}${desc}${ui.reset}`;
95
104
  });
96
105
  }
97
106
  /** 当前光标在该行的显示列(供 layout 定位光标)。 */
@@ -124,7 +133,9 @@ export async function promptWithSlashMenu(opts) {
124
133
  newLines[newLines.length - 1] += after;
125
134
  lines.splice(cl, 1, ...newLines);
126
135
  cl = cl + parts.length - 1;
127
- cc = parts[parts.length - 1].length;
136
+ // 光标置于插入文本末尾 = 原 before + 末段长度(指向插入文本之后、after 之前)。
137
+ // 旧值 parts[...].length 漏算 before → 光标落到插入文本内部(行中间)→ 后续打字插到中间(bug)。
138
+ cc = before.length + parts[parts.length - 1].length;
128
139
  }
129
140
  /** 粘贴结束:长粘贴(>8 行或 >400 字符)落/并进 chip(原子,整段封预览),短粘贴落为可编辑文本。 */
130
141
  function finalizePaste() {
@@ -216,16 +227,67 @@ export async function promptWithSlashMenu(opts) {
216
227
  computeFiltered();
217
228
  redraw();
218
229
  }
230
+ /** 清空输入(含 chip / 斜杠菜单 / 粘贴缓冲):Ctrl+C 在有内容时调用——清空而非退出。 */
231
+ function clearInput() {
232
+ if (layout.isScrolled())
233
+ layout.resetScroll(); // 回尾(若滚动回看),再清空
234
+ lines = [''];
235
+ cl = 0;
236
+ cc = 0;
237
+ chip = null;
238
+ menuOpen = false;
239
+ filtered = [];
240
+ selected = 0;
241
+ if (pasteTimer) {
242
+ clearTimeout(pasteTimer);
243
+ pasteTimer = null;
244
+ }
245
+ pasting = false;
246
+ pasteParts = [];
247
+ justSawCR = false;
248
+ computeFiltered();
249
+ redraw();
250
+ }
251
+ /** Ctrl+C:有内容(已打字 / 多行 / chip / 菜单草稿 / 粘贴缓冲 / 粘贴中)则清空,再按一次(空)才退出(仿 fish / Claude Code)。 */
252
+ function onCtrlC() {
253
+ const hasContent = chip != null ||
254
+ lines.length > 1 ||
255
+ lines.some((l) => l !== '') ||
256
+ pasteParts.length > 0 ||
257
+ pasting;
258
+ if (hasContent) {
259
+ clearInput();
260
+ return;
261
+ }
262
+ cleanup();
263
+ reject(new Error('SIGINT'));
264
+ }
219
265
  function onKey(_str, key) {
220
266
  if (resolved || !key)
221
267
  return;
268
+ // SGR 鼠标滚轮:readline 把 \x1B[<btn;col;rowM 拆成多 fragment,经 mouse.consumeMouse 重组;
269
+ // 滚轮 → scrollBy(±5)(只重画内容区,底栏不动)。置于 pasting 之前——鼠标 fragment 永不进 pasteParts。
270
+ const _m = mouse.consumeMouse(key.sequence ?? '');
271
+ if (_m.suppress) {
272
+ if (_m.wheel)
273
+ layout.scrollBy(_m.wheel * 5);
274
+ return;
275
+ }
276
+ // Shift+Tab:循环切换 agent 模式(auto ↔ plan)。不插字符、不提交、不影响输入文本;
277
+ // 回调由 repl 注入(翻 agentMode + 重写 history[0] + 设状态行 modeTag),再 redraw() 经
278
+ // paintInput 重画底栏(状态行 chip 即时刷新 + 光标留输入框)。置于 case 'tab' 之前,故不触发菜单补全。
279
+ if (key.shift && key.name === 'tab') {
280
+ opts.onCycleMode?.();
281
+ redraw();
282
+ return;
283
+ }
284
+ // Ctrl+C:有内容则清空,再按一次(空)才退出——置顶,使粘贴中也能被截到(否则被 pasting 分支吞掉)
285
+ if (key.ctrl && key.name === 'c') {
286
+ onCtrlC();
287
+ return;
288
+ }
222
289
  // 粘贴中:把键累积进 pasteParts(换行 \r\n 合一),不编辑 lines;末尾 finalizePaste 统一落 chip/文本
223
290
  if (pasting) {
224
- if (key.ctrl && key.name === 'c') {
225
- cleanup();
226
- reject(new Error('SIGINT'));
227
- return;
228
- }
229
291
  const s = key.sequence ?? '';
230
292
  const isReturn = key.name === 'return' || key.name === 'enter';
231
293
  if (s === '\n' && justSawCR) {
@@ -242,9 +304,9 @@ export async function promptWithSlashMenu(opts) {
242
304
  pasteParts.push(s);
243
305
  return;
244
306
  }
245
- // 滚动回看键(优先;不触发回尾):PgUp/PgDn 翻页,Ctrl+↑↓ 与 plain ↑/↓ 单行。
307
+ // 滚动回看键(优先;不触发回尾):PgUp/PgDn 翻页,Ctrl+↑↓ 与 plain ↑/↓ 每次 5 行。
246
308
  // plain ↑/↓ 仅在单行输入且菜单关闭时作滚动(多行编辑留给光标移动,菜单打开留给选项);
247
- // 兼鼠标滚轮——WT alt 屏(经 \x1B[?1007h)滚轮转发 ↑/↓。
309
+ // 鼠标滚轮已由 onKey 顶部 SGR 重组守卫处理(不经此分支);此分支仅键盘 plain ↑/↓,放大到 5 行/格。
248
310
  const plainArrowScroll = (key.name === 'up' || key.name === 'down') &&
249
311
  !key.ctrl &&
250
312
  !key.meta &&
@@ -261,20 +323,15 @@ export async function promptWithSlashMenu(opts) {
261
323
  else if (key.name === 'pagedown')
262
324
  layout.scrollBy(-pageH);
263
325
  else if (key.name === 'up')
264
- layout.scrollBy(1);
326
+ layout.scrollBy(5);
265
327
  else
266
- layout.scrollBy(-1);
328
+ layout.scrollBy(-5);
267
329
  return;
268
330
  }
269
331
  // 其他键:若处于滚动回看,先回尾再处理(打字即回底)
270
332
  if (layout.isScrolled())
271
333
  layout.resetScroll();
272
- // raw 模式下 Ctrl+C 不触发 SIGINT,作为按键到达:先恢复终端再 reject
273
- if (key.ctrl && key.name === 'c') {
274
- cleanup();
275
- reject(new Error('SIGINT'));
276
- return;
277
- }
334
+ // Ctrl+C 已在 onKey 顶部统一处理(清空或退出);Ctrl+D:空缓冲退出,否则忽略
278
335
  if (key.ctrl && key.name === 'd') {
279
336
  if (lines.every((l) => l === '') && cl === 0 && cc === 0)
280
337
  finish(null);
@@ -458,10 +515,13 @@ export async function promptTurnPicker(items) {
458
515
  const cols = g.cols;
459
516
  return Array.from({ length: count }, (_, i) => {
460
517
  const idx = start + i;
461
- const marker = idx === selected ? `${ui.cyan}▸${ui.reset}` : ' ';
462
- const num = `${ui.dim}${idx + 1}${ui.reset}`;
518
+ // 选中项:▸/序号/正文均 cyan+bold(去 dim),未选中项保持 dim——选中行整体高亮。
519
+ const isSel = idx === selected;
520
+ const color = isSel ? `${ui.cyan}${ui.bold}` : ui.dim;
521
+ const marker = isSel ? `${ui.cyan}${ui.bold}▸${ui.reset}` : ' ';
522
+ const num = `${color}${idx + 1}${ui.reset}`;
463
523
  const text = truncateDisplay(items[idx].firstLine, cols - 6);
464
- return `${marker} ${num} ${ui.dim}${text}${ui.reset}`;
524
+ return `${marker} ${num} ${color}${text}${ui.reset}`;
465
525
  });
466
526
  }
467
527
  function redraw() {
@@ -471,6 +531,7 @@ export async function promptTurnPicker(items) {
471
531
  cursorLine: 0,
472
532
  cursorCol: displayWidth(hint),
473
533
  menu: { lines: menuLines() },
534
+ caret: false, // 纯导航菜单(非文本输入):不画输入框块状光标,聚焦由菜单 ▸ 标记
474
535
  });
475
536
  }
476
537
  function cleanup() {
@@ -541,3 +602,384 @@ export async function promptTurnPicker(items) {
541
602
  redraw();
542
603
  });
543
604
  }
605
+ export async function promptSessionPicker(items, recentCap = 10) {
606
+ if (!layout.isActive() || items.length === 0)
607
+ return null;
608
+ const emitter = stdin;
609
+ const cap = Math.max(1, recentCap);
610
+ const canToggle = items.length > cap; // 不超过 cap 时无切换意义(本就全显)
611
+ let showAll = !canToggle; // 超过 cap 才默认折叠到最近 N,否则全显
612
+ let selected = 0; // 默认聚焦首项(调用方降序传入 → 最新会话)
613
+ let resolved = false;
614
+ let resolve;
615
+ let reject;
616
+ /** 当前可见项:折叠态取前 cap 条(=最近 N),展开态取全部。 */
617
+ function visible() {
618
+ return showAll ? items : items.slice(0, cap);
619
+ }
620
+ /** 输入框行的操作提示;折叠态显「a 全部(N)」,展开态显「a 仅最近N」。不超 cap 时无 a 项。 */
621
+ function hint() {
622
+ const base = '↑↓ 选择 · Enter 续接 · Esc 取消';
623
+ if (!canToggle)
624
+ return base;
625
+ return showAll
626
+ ? `↑↓ 选择 · Enter 续接 · a 仅最近${cap} · Esc 取消`
627
+ : `↑↓ 选择 · Enter 续接 · a 全部(${items.length}) · Esc 取消`;
628
+ }
629
+ /** 菜单行(带开窗):超屏高时以 selected 为中心取窗,保光标可见。行格式:▸ N title subtitle。 */
630
+ function menuLines() {
631
+ const g = layout.getGeo();
632
+ const cols = g.cols;
633
+ const maxRows = Math.max(1, g.contentBottom);
634
+ const vis = visible();
635
+ let start = 0;
636
+ if (vis.length > maxRows) {
637
+ start = Math.max(0, Math.min(selected - Math.floor(maxRows / 2), vis.length - maxRows));
638
+ }
639
+ const count = Math.min(maxRows, vis.length);
640
+ return Array.from({ length: count }, (_, i) => {
641
+ const idx = start + i;
642
+ // 选中项:▸/序号/正文/副标题均 cyan+bold(去 dim),未选中项保持 dim——选中行整体高亮。
643
+ const isSel = idx === selected;
644
+ const color = isSel ? `${ui.cyan}${ui.bold}` : ui.dim;
645
+ const marker = isSel ? `${ui.cyan}${ui.bold}▸${ui.reset}` : ' ';
646
+ const num = String(idx + 1);
647
+ const it = vis[idx];
648
+ const title = it.title || '(无)';
649
+ const sub = it.subtitle ?? '';
650
+ const leadW = displayWidth(num) + 4; // "▸ " + num + " "
651
+ let subW = sub ? displayWidth(sub) + 2 : 0; // " " + sub
652
+ let titleW = cols - leadW - subW;
653
+ if (titleW < 4 && sub) {
654
+ // 太窄:先丢副标题把空间让给标题
655
+ subW = 0;
656
+ titleW = cols - leadW;
657
+ }
658
+ const titleT = titleW > 0 ? truncateDisplay(title, titleW) : '';
659
+ const subPart = subW > 0 ? ` ${sub}` : '';
660
+ return `${marker} ${color}${num} ${titleT}${subPart}${ui.reset}`;
661
+ });
662
+ }
663
+ function redraw() {
664
+ const h = hint();
665
+ layout.paintInput({
666
+ prompt: '❯ ',
667
+ lines: [h],
668
+ cursorLine: 0,
669
+ cursorCol: displayWidth(h),
670
+ menu: { lines: menuLines() },
671
+ caret: false, // 纯导航菜单(非文本输入):不画输入框块状光标,聚焦由菜单 ▸ 标记
672
+ });
673
+ }
674
+ function cleanup() {
675
+ try {
676
+ stdin.setRawMode(false);
677
+ }
678
+ catch {
679
+ // 忽略
680
+ }
681
+ emitter.removeListener('keypress', onKey);
682
+ stdin.pause();
683
+ }
684
+ function finish(value) {
685
+ if (resolved)
686
+ return;
687
+ resolved = true;
688
+ cleanup();
689
+ resolve(value);
690
+ }
691
+ function onKey(_str, key) {
692
+ if (resolved || !key)
693
+ return;
694
+ if (key.ctrl && key.name === 'c') {
695
+ cleanup();
696
+ reject(new Error('SIGINT'));
697
+ return;
698
+ }
699
+ if (key.ctrl && key.name === 'd') {
700
+ finish(null);
701
+ return;
702
+ }
703
+ // a 切换「仅最近 N / 全部」:纯导航菜单无文本输入,a 自由;key.name 不分大小写,Shift+A 亦触发。
704
+ if (canToggle && key.name === 'a' && !key.ctrl && !key.meta) {
705
+ showAll = !showAll;
706
+ const vis = visible();
707
+ if (selected > vis.length - 1)
708
+ selected = vis.length - 1; // 折回 cap 时选中项越界则钳到末项
709
+ redraw();
710
+ return;
711
+ }
712
+ const n = visible().length;
713
+ switch (key.name) {
714
+ case 'up':
715
+ selected = (selected - 1 + n) % n;
716
+ redraw();
717
+ return;
718
+ case 'down':
719
+ selected = (selected + 1) % n;
720
+ redraw();
721
+ return;
722
+ case 'return':
723
+ case 'enter':
724
+ finish(visible()[selected] ?? null);
725
+ return;
726
+ case 'escape':
727
+ finish(null);
728
+ return;
729
+ }
730
+ }
731
+ return new Promise((res, rej) => {
732
+ resolve = res;
733
+ reject = rej;
734
+ ensurePasteDetector();
735
+ readline.emitKeypressEvents(stdin);
736
+ let rawOk = true;
737
+ try {
738
+ stdin.setRawMode(true);
739
+ }
740
+ catch {
741
+ rawOk = false;
742
+ }
743
+ if (!rawOk) {
744
+ res(null);
745
+ return;
746
+ }
747
+ stdin.resume();
748
+ emitter.on('keypress', onKey);
749
+ redraw();
750
+ });
751
+ }
752
+ /**
753
+ * 主题选择菜单(供 /theme):↑/↓ 导航、Enter 切换、Esc/Ctrl+D 取消、Ctrl+C 抛 SIGINT(调用方 try/catch)。
754
+ * 复用 SessionPickerItem 形状({id,title,subtitle?});选中项 cyan+bold + ▸ 高亮(同 /resume 菜单)。
755
+ * 返回选中的 item(含 id);null=取消 / 非 TTY / 空列表。纯导航(不收文本输入)。
756
+ * 长列表超屏高自动开窗保光标可见;默认聚焦首项。
757
+ */
758
+ export async function promptThemePicker(items) {
759
+ if (!layout.isActive() || items.length === 0)
760
+ return null;
761
+ const emitter = stdin;
762
+ let selected = 0;
763
+ let resolved = false;
764
+ let resolve;
765
+ let reject;
766
+ const hint = '↑↓ 选择 · Enter 切换 · Esc 取消';
767
+ /** 菜单行(带开窗):超屏高时以 selected 为中心取窗,保光标可见。行格式:▸ N title subtitle。 */
768
+ function menuLines() {
769
+ const g = layout.getGeo();
770
+ const cols = g.cols;
771
+ const maxRows = Math.max(1, g.contentBottom);
772
+ let start = 0;
773
+ if (items.length > maxRows) {
774
+ start = Math.max(0, Math.min(selected - Math.floor(maxRows / 2), items.length - maxRows));
775
+ }
776
+ const count = Math.min(maxRows, items.length);
777
+ return Array.from({ length: count }, (_, i) => {
778
+ const idx = start + i;
779
+ const isSel = idx === selected;
780
+ const color = isSel ? `${ui.cyan}${ui.bold}` : ui.dim;
781
+ const marker = isSel ? `${ui.cyan}${ui.bold}▸${ui.reset}` : ' ';
782
+ const num = String(idx + 1);
783
+ const it = items[idx];
784
+ const title = it.title || '(无)';
785
+ const sub = it.subtitle ?? '';
786
+ const leadW = displayWidth(num) + 4; // "▸ " + num + " "
787
+ let subW = sub ? displayWidth(sub) + 2 : 0; // " " + sub
788
+ let titleW = cols - leadW - subW;
789
+ if (titleW < 4 && sub) {
790
+ subW = 0;
791
+ titleW = cols - leadW;
792
+ }
793
+ const titleT = titleW > 0 ? truncateDisplay(title, titleW) : '';
794
+ const subPart = subW > 0 ? ` ${sub}` : '';
795
+ return `${marker} ${color}${num} ${titleT}${subPart}${ui.reset}`;
796
+ });
797
+ }
798
+ function redraw() {
799
+ layout.paintInput({
800
+ prompt: '❯ ',
801
+ lines: [hint],
802
+ cursorLine: 0,
803
+ cursorCol: displayWidth(hint),
804
+ menu: { lines: menuLines() },
805
+ caret: false, // 纯导航菜单:不画输入框块状光标,聚焦由菜单 ▸ 标记
806
+ });
807
+ }
808
+ function cleanup() {
809
+ try {
810
+ stdin.setRawMode(false);
811
+ }
812
+ catch {
813
+ // 忽略
814
+ }
815
+ emitter.removeListener('keypress', onKey);
816
+ stdin.pause();
817
+ }
818
+ function finish(value) {
819
+ if (resolved)
820
+ return;
821
+ resolved = true;
822
+ cleanup();
823
+ resolve(value);
824
+ }
825
+ function onKey(_str, key) {
826
+ if (resolved || !key)
827
+ return;
828
+ if (key.ctrl && key.name === 'c') {
829
+ cleanup();
830
+ reject(new Error('SIGINT'));
831
+ return;
832
+ }
833
+ if (key.ctrl && key.name === 'd') {
834
+ finish(null);
835
+ return;
836
+ }
837
+ const n = items.length;
838
+ switch (key.name) {
839
+ case 'up':
840
+ selected = (selected - 1 + n) % n;
841
+ redraw();
842
+ return;
843
+ case 'down':
844
+ selected = (selected + 1) % n;
845
+ redraw();
846
+ return;
847
+ case 'return':
848
+ case 'enter':
849
+ finish(items[selected] ?? null);
850
+ return;
851
+ case 'escape':
852
+ finish(null);
853
+ return;
854
+ }
855
+ }
856
+ return new Promise((res, rej) => {
857
+ resolve = res;
858
+ reject = rej;
859
+ ensurePasteDetector();
860
+ readline.emitKeypressEvents(stdin);
861
+ let rawOk = true;
862
+ try {
863
+ stdin.setRawMode(true);
864
+ }
865
+ catch {
866
+ rawOk = false;
867
+ }
868
+ if (!rawOk) {
869
+ res(null);
870
+ return;
871
+ }
872
+ stdin.resume();
873
+ emitter.on('keypress', onKey);
874
+ redraw();
875
+ });
876
+ }
877
+ /**
878
+ * 回滚方式二选一菜单(供 /rollback 选完轮次后):↑/↓ 导航、Enter 确认、Esc/Ctrl+D 保留文件。
879
+ * 两个选项:1=撤销文件改动(恢复到回滚前);2=只撤销消息,保留文件改动。
880
+ * 复用轮次/会话菜单的渲染骨架(raw mode + layout.paintInput 的 menu),选中项 cyan+bold + ▸ 高亮。
881
+ * 返回 true=撤销文件 / false=只撤销消息 / null=取消(调用方按 false=保留文件处理)。
882
+ * 默认聚焦首项(撤销文件)。无文件改动时调用方不应调此菜单(直接走只撤销消息)。
883
+ */
884
+ export async function promptRevertChoice(fileCount) {
885
+ if (!layout.isActive())
886
+ return null;
887
+ const emitter = stdin;
888
+ const hint = '↑↓ 选择 · Enter 确认 · Esc 保留文件';
889
+ const items = [
890
+ `撤销文件改动${fileCount > 0 ? `(${fileCount} 个文件恢复到回滚前)` : ''}`,
891
+ '只撤销消息,保留文件改动',
892
+ ];
893
+ let selected = 0; // 默认聚焦首项(撤销文件)
894
+ let resolved = false;
895
+ let resolve;
896
+ let reject;
897
+ function menuLines() {
898
+ const cols = layout.getGeo().cols;
899
+ return items.map((text, idx) => {
900
+ const isSel = idx === selected;
901
+ const color = isSel ? `${ui.cyan}${ui.bold}` : ui.dim;
902
+ const marker = isSel ? `${ui.cyan}${ui.bold}▸${ui.reset}` : ' ';
903
+ const num = `${color}${idx + 1}${ui.reset}`;
904
+ const t = truncateDisplay(text, cols - 6);
905
+ return `${marker} ${num} ${color}${t}${ui.reset}`;
906
+ });
907
+ }
908
+ function redraw() {
909
+ layout.paintInput({
910
+ prompt: '❯ ',
911
+ lines: [hint],
912
+ cursorLine: 0,
913
+ cursorCol: displayWidth(hint),
914
+ menu: { lines: menuLines() },
915
+ caret: false, // 纯导航菜单:不画输入框块状光标,聚焦由菜单 ▸ 标记
916
+ });
917
+ }
918
+ function cleanup() {
919
+ try {
920
+ stdin.setRawMode(false);
921
+ }
922
+ catch {
923
+ // 忽略
924
+ }
925
+ emitter.removeListener('keypress', onKey);
926
+ stdin.pause();
927
+ }
928
+ function finish(value) {
929
+ if (resolved)
930
+ return;
931
+ resolved = true;
932
+ cleanup();
933
+ resolve(value);
934
+ }
935
+ function onKey(_str, key) {
936
+ if (resolved || !key)
937
+ return;
938
+ if (key.ctrl && key.name === 'c') {
939
+ cleanup();
940
+ reject(new Error('SIGINT'));
941
+ return;
942
+ }
943
+ if (key.ctrl && key.name === 'd') {
944
+ finish(null);
945
+ return;
946
+ }
947
+ switch (key.name) {
948
+ case 'up':
949
+ selected = (selected - 1 + items.length) % items.length;
950
+ redraw();
951
+ return;
952
+ case 'down':
953
+ selected = (selected + 1) % items.length;
954
+ redraw();
955
+ return;
956
+ case 'return':
957
+ case 'enter':
958
+ finish(selected === 0); // true=撤销文件, false=只撤销消息
959
+ return;
960
+ case 'escape':
961
+ finish(null); // 取消=保留文件
962
+ return;
963
+ }
964
+ }
965
+ return new Promise((res, rej) => {
966
+ resolve = res;
967
+ reject = rej;
968
+ ensurePasteDetector();
969
+ readline.emitKeypressEvents(stdin);
970
+ let rawOk = true;
971
+ try {
972
+ stdin.setRawMode(true);
973
+ }
974
+ catch {
975
+ rawOk = false;
976
+ }
977
+ if (!rawOk) {
978
+ res(null);
979
+ return;
980
+ }
981
+ stdin.resume();
982
+ emitter.on('keypress', onKey);
983
+ redraw();
984
+ });
985
+ }
package/dist/ui/render.js CHANGED
@@ -60,6 +60,24 @@ export function displayWidth(str) {
60
60
  w += charWidth(ch.codePointAt(0) ?? 0);
61
61
  return w;
62
62
  }
63
+ /**
64
+ * 毫秒 → Claude Code 式耗时串:运行中状态行走时与轮次结束摘要行共用。
65
+ * <10s 显 1 位小数(3.2s);10-59s 整数(12s);≥60s m+s(3m 3s);≥1h h+m(1h 2m)。
66
+ */
67
+ export function fmtElapsed(ms) {
68
+ const s = ms / 1000;
69
+ if (s < 10)
70
+ return `${s.toFixed(1)}s`;
71
+ if (s < 60)
72
+ return `${Math.round(s)}s`;
73
+ const m = Math.floor(s / 60);
74
+ const rs = Math.round(s % 60);
75
+ if (m < 60)
76
+ return `${m}m ${rs}s`;
77
+ const h = Math.floor(m / 60);
78
+ const rm = m % 60;
79
+ return `${h}h ${rm}m`;
80
+ }
63
81
  /** 去除 SGR 颜色转义(\x1B[…m),用于度量带色串的真实可见宽度。 */
64
82
  export function stripAnsi(s) {
65
83
  return s.replace(/\x1b\[[0-9;]*m/g, '');
@@ -176,7 +194,7 @@ function labelContent(label, value) {
176
194
  /** 横幅纯文本(带 ANSI 颜色,不写出)——供 TUI 经 contentWrite 写入内容区以跟踪续写位。
177
195
  * 无边框:左侧实心小熊 + 右侧标题/信息(模型/目录),末尾一行提示。 */
178
196
  export function bannerString(info) {
179
- const title = `${ui.bold}${ui.brightCyan}◆ mocode${ui.reset} ${ui.dim}v${VERSION}${ui.reset}`;
197
+ const title = `${ui.bold}${ui.brightCyan}◆ MoCode${ui.reset} ${ui.dim}v${VERSION}${ui.reset}`;
180
198
  const rows = [
181
199
  logoLine(0) + title,
182
200
  logoLine(1) + labelContent('模型', info.model),
@@ -1,6 +1,12 @@
1
1
  import { stdout } from 'node:process';
2
2
  import { ui } from './theme.js';
3
3
  const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
4
+ /**
5
+ * 当前活跃 spinner:`start()` 的 TTY 路径登记,`stop()` 不清——供 `pauseCurrent()` 在介入面板
6
+ * (ask_human)进入前停转,避免 onFrame 的 `paintLiveAtCursor` 每 80ms 覆盖面板。同进程同时刻至多一个在转;
7
+ * 下次 `start()` 自动替换引用,无泄漏。非 TTY 路径不登记(无 onFrame,无需暂停)。
8
+ */
9
+ let activeSpinner = null;
4
10
  /**
5
11
  * 等待动画:在 await 长操作(chat / 工具执行 / 压缩)时旋转,避免「卡死」错觉。
6
12
  *
@@ -26,6 +32,7 @@ export class Spinner {
26
32
  stdout.write(`${ui.dim}${msg}…${ui.reset}\n`);
27
33
  return;
28
34
  }
35
+ activeSpinner = this; // TTY 路径:登记为当前可暂停 spinner(介入面板进入前 pauseCurrent 停转)
29
36
  if (this.onFrame) {
30
37
  const cb = this.onFrame;
31
38
  this.frame = 0;
@@ -60,4 +67,12 @@ export class Spinner {
60
67
  stdout.write('\r\x1B[K');
61
68
  }
62
69
  }
70
+ /**
71
+ * 暂停当前活跃 spinner(供 ask_human 介入面板进入前停转,避免 onFrame 覆盖面板)。
72
+ * 未转则 no-op(stop 内 `!this.timer` 守护);停转时 onFrame(msg,null) 触发 clearLiveAtCursor 清续写位帧。
73
+ * 不 resume——ask_human 的活儿就是面板本身,退出后 agent 的 spinner.stop() 是 no-op,下一轮 start 自然重启。
74
+ */
75
+ static pauseCurrent() {
76
+ activeSpinner?.stop();
77
+ }
63
78
  }