mocode-ai 0.6.8 → 0.6.10

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.
@@ -1,7 +1,8 @@
1
1
  import readline from 'node:readline/promises';
2
2
  import { emitKeypressEvents } from 'node:readline';
3
3
  import { stdin, stdout } from 'node:process';
4
- import { config, updateModelConfig, isModelConfigured, updateMemoryConfig, isMemoryEnabled, isProjectSkillEnabled, isProjectSnapshotEnabled, updateProjectSkillConfig, updateSnapshotConfig, buildBasePrompt, getPlanModeSuffix, } from '../config/index.js';
4
+ import { config, updateModelConfig, isModelConfigured, updateMemoryConfig, isMemoryEnabled, isProjectSkillEnabled, isProjectSnapshotEnabled, updateProjectSkillConfig, updateSnapshotConfig, updateLanguageConfig, languageFromShell, buildBasePrompt, getPlanModeSuffix, } from '../config/index.js';
5
+ import { getLanguage, normalizeLanguage, t, } from '../i18n/index.js';
5
6
  import { updateConfigKey, writeConfigKeys, CONFIG_PATH } from '../config/file.js';
6
7
  import { deletePreset, getPreset, isValidPresetName, listPresets, migrateCurrentToPreset, savePreset, } from '../config/presets.js';
7
8
  import { runAgent } from '../agent/index.js';
@@ -15,8 +16,9 @@ import * as mouse from '../ui/mouse.js';
15
16
  import * as batch from '../ui/batch.js';
16
17
  import { promptWithSlashMenu, promptTurnPicker, promptSessionPicker, promptThemePicker, promptRevertChoice, } from '../ui/prompt.js';
17
18
  import { promptIntervention } from '../ui/intervention.js';
18
- import { tools } from '../tools/registry.js';
19
- import { estimateMessagesTokens, reconfigureClient, } from '../llm/index.js';
19
+ import { tools, registerToolsExtension } from '../tools/registry.js';
20
+ import { initializeAllMcp, getMcpTools, closeAllMcp } from '../mcp/index.js';
21
+ import { estimateMessagesTokens, reconfigureClient, refreshChatTools, } from '../llm/index.js';
20
22
  import { loadImageAttachment, renderChip, MAX_INLINE_BYTES_DEFAULT, } from '../attachments/image.js';
21
23
  import { modelSupportsVision } from '../llm/capabilities.js';
22
24
  import { computePruneStats } from '../context/relevance.js';
@@ -34,50 +36,113 @@ import { setCurrentSessionId, getCurrentSessionId } from '../session/state.js';
34
36
  * 颜色码会让光标错位、编辑时漂移。颜色只用在直接 stdout.write 的横幅 / 工具行 / 回复。
35
37
  */
36
38
  const PROMPT = '❯ ';
37
- /** 斜杠命令菜单(仅用于输入时下拉显示与过滤;分发仍走下方 if 链)。 */
38
- const SLASH_COMMANDS = [
39
- { name: '/exit', desc: '退出 mocode(同 /quit)' },
40
- { name: '/clear', desc: '清空历史(保留系统提示)' },
41
- { name: '/context', desc: '显示上下文用量条' },
42
- { name: '/skills', desc: '列出已发现的 skill' },
43
- { name: '/compact', desc: '压缩历史(可带焦点 /compact …)' },
44
- { name: '/resume', desc: '续接最近 10 个已保存会话(快速)' },
45
- { name: '/sessions', desc: '浏览全部已保存会话(慢,翻历史用)' },
46
- { name: '/rollback', desc: '菜单选轮次回滚(↑↓·Enter)' },
47
- { name: '/memory', desc: '记忆库:条目计数与近期索引(关闭时提示先开 /memory_switch)' },
48
- { name: '/memory_switch', desc: '切换记忆子系统开关(无参=切换;/on 或 /off 显式;持久化 MEMORY_ENABLED)' },
49
- { name: '/project_skill', desc: '项目专属 Skill 开关 + 查看/初始化(无参=切换;/on·/off·/view·/init)' },
50
- { name: '/snapshot', desc: '切换项目快照开关(无参=切换;/on·/off·/status;持久化 MOCODE_PROJECT_SNAPSHOT)' },
51
- { name: '/memory_status', desc: '查看记忆子系统当前开关与原理' },
52
- { name: '/reflect', desc: '手动触发后台记忆反思 pass(需先开启记忆)' },
53
- { name: '/init', desc: '扫描项目生成 MOCODE.md 项目记忆(需先开启记忆)' },
54
- { name: '/theme', desc: '切换颜色主题(↑↓·Enter)' },
55
- { name: '/model', desc: '配置新模型(向导);/model switch·list·delete 管理已配置预设' },
56
- { name: '/model switch', desc: '↑↓·Enter 在已配置预设间切换' },
57
- { name: '/model list', desc: '列出已经配置的模型' },
58
- { name: '/model delete <name>', desc: '删除已配置的模型' },
59
- { name: '/snapshot_refresh', desc: '刷新项目快照(重新扫描静态文件,重新生成 LLM 摘要)' },
60
- { name: '/plan', desc: '切到 plan 模式(只读探查+产出计划)' },
61
- { name: '/auto', desc: '切回 auto 模式(全工具执行)' },
62
- { name: '/pet', desc: '开关桌宠(独立悬浮窗,展示 agent 状态动画)' },
63
- { name: '/pet skin', desc: '选择桌宠皮肤(↑↓·Enter)' },
64
- { name: '/pet quit', desc: '完全关闭桌宠进程(而非仅断开本连接)' },
65
- { name: '/image', desc: '附加本地图片到下一条消息(/image <path> · list · clear)' },
66
- ];
67
- /** 主题名 → 一句描述(供 /theme 菜单 / 列表显示)。新增主题时在 src/ui/theme.ts THEMES 加键后于此补一句。 */
68
- const THEME_DESCRIPTIONS = {
69
- default: '16 色原版(深底)',
70
- light: '浅底终端',
71
- solarized: 'Solarized 强调色',
72
- gruvbox: 'Gruvbox 暖色',
73
- nord: 'Nord 冷色',
74
- orange: '南瓜橙(暖)',
75
- rose: '玫红(暖紫)',
76
- emerald: '翡翠绿(冷)',
77
- amber: '琥珀金黄(暖)',
78
- lavender: '薰衣草淡紫(冷)',
79
- sunset: '日落珊瑚红(暖)',
80
- };
39
+ /**
40
+ * 斜杠命令树(仅用于输入菜单;分发仍走下方 if 链)。
41
+ * 分支节点只负责导航,叶子的 value 保持现有命令文本,因此不破坏命令兼容性。
42
+ */
43
+ function buildSlashCommands() {
44
+ const d = (key) => t(key);
45
+ return [
46
+ { name: '/help', desc: d('commands.help') },
47
+ { name: '/exit', desc: d('commands.exit') },
48
+ { name: '/clear', desc: d('commands.clear') },
49
+ { name: '/context', desc: d('commands.context') },
50
+ { name: '/skills', desc: d('commands.skills') },
51
+ { name: '/compact', desc: d('commands.compact') },
52
+ { name: '/resume', desc: d('commands.sessionResume') },
53
+ { name: '/sessions', desc: d('commands.sessionBrowse') },
54
+ { name: '/rollback', desc: d('commands.sessionRollback') },
55
+ {
56
+ name: '/memory', desc: d('commands.memory'), children: [
57
+ { name: 'overview', value: '/memory', desc: d('commands.memoryOverview') },
58
+ { name: 'toggle', value: '/memory_switch', desc: d('commands.memoryToggle') },
59
+ { name: 'on', value: '/memory_switch on', desc: d('commands.memoryOn') },
60
+ { name: 'off', value: '/memory_switch off', desc: d('commands.memoryOff') },
61
+ { name: 'status', value: '/memory_status', desc: d('commands.memoryStatus') },
62
+ { name: 'reflect', value: '/reflect', desc: d('commands.memoryReflect') },
63
+ { name: 'init', value: '/init', desc: d('commands.memoryInit') },
64
+ ],
65
+ },
66
+ {
67
+ name: '/project_skill', desc: d('commands.skill'), children: [
68
+ { name: 'toggle', value: '/project_skill', desc: d('commands.toggle') },
69
+ { name: 'on', value: '/project_skill on', desc: d('commands.skillOn') },
70
+ { name: 'off', value: '/project_skill off', desc: d('commands.skillOff') },
71
+ { name: 'view', value: '/project_skill view', desc: d('commands.skillView') },
72
+ { name: 'init', value: '/project_skill init', desc: d('commands.skillInit') },
73
+ ],
74
+ },
75
+ {
76
+ name: '/snapshot', desc: d('commands.snapshot'), children: [
77
+ { name: 'toggle', value: '/snapshot', desc: d('commands.snapshotToggle') },
78
+ { name: 'on', value: '/snapshot on', desc: d('commands.snapshotOn') },
79
+ { name: 'off', value: '/snapshot off', desc: d('commands.snapshotOff') },
80
+ { name: 'status', value: '/snapshot status', desc: d('commands.snapshotStatus') },
81
+ { name: 'refresh', value: '/snapshot_refresh', desc: d('commands.snapshotRefresh') },
82
+ ],
83
+ },
84
+ { name: '/theme', desc: d('commands.theme') },
85
+ {
86
+ name: '/model', desc: d('commands.model'), children: [
87
+ { name: 'configure', value: '/model', desc: d('commands.modelConfigure') },
88
+ { name: 'switch', value: '/model switch', desc: d('commands.modelSwitch') },
89
+ { name: 'list', value: '/model list', desc: d('commands.modelList') },
90
+ { name: 'show', value: '/model show', desc: d('commands.modelShow') },
91
+ { name: 'use <name>', value: '/model use ', submit: false, desc: d('commands.modelUse') },
92
+ { name: 'delete <name>', value: '/model delete ', submit: false, desc: d('commands.modelDelete') },
93
+ ],
94
+ },
95
+ {
96
+ name: '/mode', desc: d('commands.mode'), children: [
97
+ { name: 'plan', value: '/plan', desc: d('commands.modePlan') },
98
+ { name: 'auto', value: '/auto', desc: d('commands.modeAuto') },
99
+ ],
100
+ },
101
+ {
102
+ name: '/pet', desc: d('commands.pet'), children: [
103
+ { name: 'toggle', value: '/pet', desc: d('commands.petToggle') },
104
+ { name: 'skin', value: '/pet skin', desc: d('commands.petSkin') },
105
+ { name: 'quit', value: '/pet quit', desc: d('commands.petQuit') },
106
+ ],
107
+ },
108
+ {
109
+ name: '/image', desc: d('commands.image'), children: [
110
+ { name: 'attach <path>', value: '/image ', submit: false, desc: d('commands.imageAttach') },
111
+ { name: 'list', value: '/image list', desc: d('commands.imageList') },
112
+ { name: 'clear', value: '/image clear', desc: d('commands.imageClear') },
113
+ ],
114
+ },
115
+ {
116
+ name: '/language', desc: d('commands.language'), children: [
117
+ { name: 'zh-CN', value: '/language zh-CN', desc: d('commands.languageZh') },
118
+ { name: 'en', value: '/language en', desc: d('commands.languageEn') },
119
+ ],
120
+ },
121
+ ];
122
+ }
123
+ /** 从菜单树递归生成 /help 内容;叶子 value 与菜单路径不同则同时展示真实命令。 */
124
+ function slashHelpLines(nodes = buildSlashCommands(), parentPath = '', depth = 0) {
125
+ const lines = [];
126
+ for (const node of nodes) {
127
+ const menuPath = parentPath ? `${parentPath} ${node.name}` : node.name;
128
+ const isBranch = Boolean(node.children?.length);
129
+ const actual = node.value?.trimEnd();
130
+ const mapping = actual && actual !== menuPath ? ` ${ui.dim}→ ${actual}${ui.reset}` : '';
131
+ const marker = isBranch ? ` ${ui.dim}›${ui.reset}` : '';
132
+ lines.push(`${' '.repeat(depth)}${ui.accent}${menuPath}${ui.reset}${marker}${mapping} ${ui.dim}${node.desc}${ui.reset}`);
133
+ if (node.children?.length)
134
+ lines.push(...slashHelpLines(node.children, menuPath, depth + 1));
135
+ }
136
+ return lines;
137
+ }
138
+ /** 主题名 → 本地化描述。 */
139
+ function themeDescription(name) {
140
+ const key = `theme.${name}`;
141
+ return name in {
142
+ default: 1, light: 1, solarized: 1, gruvbox: 1, nord: 1, orange: 1,
143
+ rose: 1, emerald: 1, amber: 1, lavender: 1, sunset: 1,
144
+ } ? t(key) : '';
145
+ }
81
146
  /** /model 预设后端:选一个预填 baseURL,仍可逐项改。base_url 取自 README 常见表。 */
82
147
  const MODEL_PRESETS = [
83
148
  { label: 'GLM(智谱)', baseURL: 'https://open.bigmodel.cn/api/v3', model: 'glm-4.6', window: 128000 },
@@ -146,7 +211,7 @@ function renderContextBar(history) {
146
211
  const W = 10;
147
212
  const filled = Math.round(pct * W);
148
213
  const bar = '█'.repeat(filled) + '░'.repeat(W - filled);
149
- const src = contextState.lastUsage ? '实测' : '估算';
214
+ const src = contextState.lastUsage ? t('status.measured') : t('status.estimated');
150
215
  const k = (n) => `${Math.round(n / 1000)}k`;
151
216
  const pctCol = pct >= config.compactThreshold ? ui.yellow : ui.accent;
152
217
  const lifecycle = contextState.lifecycleStats;
@@ -155,7 +220,7 @@ function renderContextBar(history) {
155
220
  ? `\n lifecycle · live ${lifecycle.live} · referenced ${lifecycle.referenced} · digested ${lifecycle.digested} · stubbed ${lifecycle.stubbed}`
156
221
  : '\n lifecycle · no active snapshot (run a tool-enabled turn first)';
157
222
  const archiveLine = `\n archived tool results · ${archived.stubbed}`;
158
- return `${ui.gray}[${pctCol}${bar}${ui.reset}] ${Math.round(pct * 100)}% ${k(est)}/${k(win)} tokens · ${history.length} 条消息 (${src})${ui.reset}${lifecycleLine}${archiveLine}`;
223
+ return `${ui.gray}[${pctCol}${bar}${ui.reset}] ${Math.round(pct * 100)}% ${k(est)}/${k(win)} tokens · ${t('status.messages', { count: history.length })} (${src})${ui.reset}${lifecycleLine}${archiveLine}`;
159
224
  }
160
225
  /** 状态行用量条(精简版,进底栏):[bar] pct% k/k。
161
226
  * 只计算对话内容(不含 system prompt),让用户感知"我发了多少、agent 回复了多少"占用 context。 */
@@ -209,39 +274,38 @@ function refreshStatusBase(history, lastTurnUsage) {
209
274
  function runningStateFor(cmd) {
210
275
  switch (cmd) {
211
276
  case '/compact':
212
- return { status: '压缩', placeholder: '压缩中…' };
277
+ return { status: t('running.compact'), placeholder: t('running.compacting') };
213
278
  case '/resume':
214
- return { status: '续接', placeholder: '选择会话…' };
279
+ return { status: t('running.resume'), placeholder: t('running.chooseSession') };
215
280
  case '/rollback':
216
- return { status: '回滚', placeholder: '选择轮次…' };
281
+ return { status: t('running.rollback'), placeholder: t('running.chooseTurn') };
217
282
  case '/init':
218
- return { status: '初始化', placeholder: '生成 MOCODE.md…' };
283
+ return { status: t('running.init'), placeholder: t('running.generateMemory') };
219
284
  case '/snapshot_refresh':
220
- return { status: '刷新快照', placeholder: '扫描项目文件中…' };
285
+ return { status: t('running.snapshot'), placeholder: t('running.scanning') };
221
286
  case '/plan':
222
- return { status: '切 plan', placeholder: '…' };
287
+ return { status: t('running.plan'), placeholder: '…' };
223
288
  case '/auto':
224
- return { status: '切 auto', placeholder: '…' };
289
+ return { status: t('running.auto'), placeholder: '…' };
225
290
  case '/clear':
226
- return { status: '清空', placeholder: '…' };
291
+ return { status: t('running.clear'), placeholder: '…' };
227
292
  case '/theme':
228
- return { status: '切主题', placeholder: '选择主题…' };
293
+ return { status: t('running.theme'), placeholder: t('running.chooseTheme') };
229
294
  case '/model':
230
- return { status: '配模型', placeholder: '配置中…' };
295
+ return { status: t('running.model'), placeholder: t('running.configuring') };
231
296
  case '/pet':
232
- return { status: '桌宠', placeholder: '处理中…' };
297
+ return { status: t('running.pet'), placeholder: t('running.processing') };
233
298
  case '/memory_switch':
234
- return { status: '切记忆开关', placeholder: '切换中…' };
299
+ return { status: t('running.memory'), placeholder: t('running.switching') };
235
300
  case '/memory_status':
236
- return { status: '查记忆状态', placeholder: '…' };
301
+ return { status: t('running.memoryStatus'), placeholder: '…' };
237
302
  case '/project_skill':
238
- return { status: '项目 Skill', placeholder: '处理中…' };
303
+ return { status: t('running.skill'), placeholder: t('running.processing') };
239
304
  case '/snapshot':
240
- return { status: '切快照开关', placeholder: '切换中…' };
305
+ return { status: t('running.snapshot'), placeholder: t('running.switching') };
306
+ case '/language':
307
+ return { status: t('running.language'), placeholder: t('running.chooseLanguage') };
241
308
  default:
242
- // 输入框留空(运行中可 typeahead 打字,dim 回显);运行状态由内联 spinner 承载(思考中/执行…),
243
- // 状态行只显走时——故常态 status 留空,不塞「处理」这种与内联重复的泛标签。
244
- // 不把「思考中… Ctrl+C 中断」塞进输入框占位——避免看起来像已有输入、妨碍正常输入。
245
309
  return { status: '', placeholder: '' };
246
310
  }
247
311
  }
@@ -365,7 +429,7 @@ function stopRunningListener() {
365
429
  * 满宽 pad 使终端背景色覆盖整行(含行尾空单元格),上滑滚动时用户消息呈连续色块、与 assistant 正文区分。
366
430
  * 末尾多留一空行(\n\n 收尾):用户消息与后续(agent 流式输出 / 下条消息)之间空一行。
367
431
  */
368
- function formatUserMessage(lines) {
432
+ function formatUserMessage(lines, trailingBlank = true) {
369
433
  const cols = layout.getGeo().cols;
370
434
  const promptW = displayWidth(PROMPT);
371
435
  const indent = ' '.repeat(promptW);
@@ -377,13 +441,13 @@ function formatUserMessage(lines) {
377
441
  const padded = padEndDisplay(full, cols);
378
442
  return `${userBg}${padded}${reset}`;
379
443
  })
380
- .join('\n') + '\n\n');
444
+ .join('\n') + (trailingBlank ? '\n\n' : '\n'));
381
445
  }
382
446
  /** 把多行提交输入回显进内容区(❯ 首行,续行按 prompt 宽度缩进)。仅 TUI 态回显(非 TTY 由 readline 自带回显)。 */
383
- function echoInput(lines) {
447
+ function echoInput(lines, trailingBlank = true) {
384
448
  if (!layout.isActive())
385
449
  return;
386
- layout.contentWrite(formatUserMessage(lines));
450
+ layout.contentWrite(formatUserMessage(lines, trailingBlank));
387
451
  // 多模态附件:每张一行 chip 跟在 user bubble 后(原 /rollback /resume 复显一致)
388
452
  for (const a of pendingAttachments) {
389
453
  layout.contentWrite(` ${ui.dim}${renderChip(a)}${ui.reset}\n`);
@@ -406,7 +470,7 @@ function echoInput(lines) {
406
470
  */
407
471
  function awaitPendingRecall(input, attachmentsCount, placeholder) {
408
472
  pendingRecall = { lines: input, attachmentsCount, placeholder };
409
- layout.setStatus('发送中… (Esc / Ctrl+C 撤回)', '●');
473
+ layout.setStatus(t('agent.sending'), '●');
410
474
  // 非 TTY:setRawMode 抛错 → window=0 直返 true(向后退化,不走 raw + 不挂监听)。
411
475
  let ttyReady = false;
412
476
  try {
@@ -591,6 +655,10 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
591
655
  // 沙箱根:文件操作边界。优先级 --sandbox-root > SANDBOX_ROOT env > process.cwd()。
592
656
  // 纯边界记录(不 chdir),jail.ts 内部 resolve。子 agent 同进程继承全局 root。
593
657
  setSandboxRoot(sandboxRootOverride ?? config.sandboxRoot ?? process.cwd());
658
+ // MCP 在工具表和 LLM schema 创建前连接;失败的单个 server 只给提示,不阻断 REPL。
659
+ const mcpReport = await initializeAllMcp();
660
+ registerToolsExtension('mcp', getMcpTools());
661
+ refreshChatTools();
594
662
  // 项目快照:sandboxRoot 设定后异步构建(完全由 LLM 生成)。
595
663
  // 构建失败不阻断 REPL:snapshot 内部 catch 所有异常。
596
664
  if (config.projectSnapshotEnabled) {
@@ -653,6 +721,12 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
653
721
  else {
654
722
  layout.writeBanner(bannerLines(banner()));
655
723
  }
724
+ if (mcpReport.connected.length > 0) {
725
+ layout.contentWrite(`${ui.dim} ↳ 已连接 MCP: ${mcpReport.connected.join(', ')} (${getMcpTools().length} 个工具;外部工具每次均需授权)${ui.reset}\n`);
726
+ }
727
+ for (const warning of mcpReport.warnings) {
728
+ layout.contentWrite(`${ui.yellow} ⚠ ${warning}${ui.reset}\n`);
729
+ }
656
730
  if (!isModelConfigured()) {
657
731
  // 未配置 baseURL/apiKey:醒目提示引导 /model(不退出,REPL 仍可用;发消息会失败但不崩)。
658
732
  layout.contentWrite(`${ui.yellow} ⚠ 未配置大模型。输入 ${ui.cyan}/model${ui.yellow} 配置 baseURL / apiKey / model(即时生效),或退出后运行 ${ui.cyan}mocode config${ui.yellow} 走向导。${ui.reset}\n`);
@@ -854,11 +928,11 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
854
928
  /不支持(视觉|图片|图像|多模态)/.test(msg) ||
855
929
  (/图片|图像|视觉|多模态/.test(msg) && /不支持|invalid|reject|fail/i.test(lower));
856
930
  if (looksLikeImageError) {
857
- layout.contentWrite(`${ui.red}[错误]${ui.reset} 当前模型 ${ui.accent}${config.model}${ui.reset} 不支持视觉输入。${ui.dim}原始:${msg}${ui.reset}\n`);
858
- layout.contentWrite(`${ui.dim}提示:运行 /model 切换到支持视觉的模型(如 gpt-4o / claude-3.5-sonnet / gemini-1.5-pro)。${ui.reset}\n`);
931
+ layout.contentWrite(`${ui.red}${t('repl.errorLabel')}${ui.reset} ${t('repl.visionUnsupported', { model: `${ui.accent}${config.model}${ui.reset}` })}${ui.dim}${t('repl.originalError', { message: msg })}${ui.reset}\n`);
932
+ layout.contentWrite(`${ui.dim}${t('repl.visionHint')}${ui.reset}\n`);
859
933
  }
860
934
  else {
861
- layout.contentWrite(`${ui.red}[错误]${ui.reset} ${msg}\n`);
935
+ layout.contentWrite(`${ui.red}${t('repl.errorLabel')}${ui.reset} ${msg}\n`);
862
936
  }
863
937
  }
864
938
  finally {
@@ -873,7 +947,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
873
947
  return; // Esc / Ctrl+D 取消
874
948
  const loaded = loadSession(pick.id);
875
949
  if (!loaded || !loaded.history.length) {
876
- layout.contentWrite(`${ui.yellow}(加载失败)${ui.reset}\n`);
950
+ layout.contentWrite(`${ui.yellow}${t('repl.loadFailed')}${ui.reset}\n`);
877
951
  return;
878
952
  }
879
953
  if (loaded.history[0]?.role === 'system') {
@@ -893,7 +967,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
893
967
  layout.clearContent();
894
968
  renderHistory(history);
895
969
  // 末尾 \n\n:与后续用户消息(❯ bubble)之间空一行。
896
- layout.contentWrite(`${ui.dim}(已续接会话 ${loaded.id})${ui.reset}\n\n`);
970
+ layout.contentWrite(`${ui.dim}${t('repl.resumed', { id: loaded.id })}${ui.reset}\n\n`);
897
971
  }
898
972
  while (true) {
899
973
  // INPUT 态:画底栏输入框 + 状态行,光标入输入框
@@ -905,12 +979,12 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
905
979
  layout.contentWrite(` ${ui.gray}↳ ${formatReflectResult(reflectRes)}${ui.reset}\n`);
906
980
  clearLastReflectResult();
907
981
  }
908
- layout.enterInputMode('空闲');
982
+ layout.enterInputMode(t('repl.idle'));
909
983
  let input = null;
910
984
  try {
911
985
  input = await promptWithSlashMenu({
912
986
  prompt: PROMPT,
913
- commands: SLASH_COMMANDS,
987
+ commands: buildSlashCommands(),
914
988
  onCycleMode: cycleMode,
915
989
  // /rollback 预填优先;否则上一轮运行中 typeahead 打的字 → 预填进输入框,用户可改可发
916
990
  ...(pendingPrefill
@@ -934,34 +1008,69 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
934
1008
  if (line === '/exit' || line === '/quit')
935
1009
  break;
936
1010
  // RUNNING 态:回显输入 → 底栏改 dim 占位、光标回内容续写位
937
- echoInput(input);
938
1011
  const cmd = line.split(/\s+/)[0];
939
- const { status, placeholder } = runningStateFor(cmd);
1012
+ // 语言命令把视觉分隔放在确认文案之后;避免回显后先空一行、下一条命令却紧贴确认。
1013
+ echoInput(input, cmd !== '/language');
1014
+ const state = runningStateFor(cmd);
1015
+ const placeholder = line === '/snapshot_refresh' || line === '/project_skill init'
1016
+ ? ''
1017
+ : state.placeholder;
940
1018
  refreshStatusBase(history);
941
- layout.enterRunningMode(status, placeholder);
1019
+ layout.enterRunningMode(state.status, placeholder);
1020
+ if (line === '/help') {
1021
+ layout.contentWrite(`${ui.bold}${t('help.title')}${ui.reset}\n`);
1022
+ layout.contentWrite(`${ui.dim}${t('help.hint')}${ui.reset}\n`);
1023
+ layout.contentWrite(`${slashHelpLines().join('\n')}\n`);
1024
+ continue;
1025
+ }
1026
+ if (line === '/language' || line.startsWith('/language ')) {
1027
+ const arg = line === '/language' ? '' : line.slice('/language '.length).trim();
1028
+ if (!arg) {
1029
+ const currentName = getLanguage() === 'zh-CN' ? t('language.zh') : t('language.en');
1030
+ layout.contentWrite(`${ui.dim}${t('language.current', { language: currentName })}${ui.reset}\n`);
1031
+ layout.contentWrite(`${ui.dim}${t('language.usage')}${ui.reset}\n\n`);
1032
+ continue;
1033
+ }
1034
+ const next = normalizeLanguage(arg);
1035
+ if (!next) {
1036
+ layout.contentWrite(`${ui.yellow}${t('language.invalid', { value: arg })}${ui.reset}\n\n`);
1037
+ continue;
1038
+ }
1039
+ updateLanguageConfig(next);
1040
+ updateConfigKey('MOCODE_LANGUAGE', next);
1041
+ history[0] = { role: 'system', content: buildSystemMessage(getAgentMode() === 'plan') };
1042
+ refreshStatusBase(history);
1043
+ // 横幅是内容缓冲顶部的固定区域;语言切换后等长原地替换,不移动后续对话。
1044
+ layout.rewriteBanner(bannerLines(banner()));
1045
+ layout.contentWrite(`${ui.cyan}${t('language.changed')}${ui.reset}\n`);
1046
+ if (languageFromShell) {
1047
+ layout.contentWrite(`${ui.dim}${t('language.shellOverride')}${ui.reset}\n`);
1048
+ }
1049
+ layout.contentWrite('\n');
1050
+ continue;
1051
+ }
942
1052
  if (line === '/init') {
943
1053
  // /init:把 init 指令当 user 输入发给 agent(扫描项目 + 生成 MOCODE.md),fall through 走 runAgent
944
1054
  joined = INIT_PROMPT;
945
1055
  }
946
1056
  if (line === '/plan') {
947
- // /plan:切到 plan 模式(只读探查 + 产出计划)。Shift+Tab 的可靠后备——
948
- // 中文 IME(微软拼音用 Shift 切中英)与部分终端会吞掉 Shift+Tab 的 \x1b[Z,命令不受影响。
1057
+ // /plan:切到 plan 模式(只读探查 + 产出计划)。
949
1058
  if (getAgentMode() === 'plan') {
950
- layout.contentWrite(`${ui.dim}(已在 plan 模式)${ui.reset}\n`);
1059
+ layout.contentWrite(`${ui.dim}${t('repl.planAlready')}${ui.reset}\n`);
951
1060
  }
952
1061
  else {
953
- setAgentMode('plan'); // listener 接手 applyMode + refreshStatusBase
954
- layout.contentWrite(`${ui.dim}(已切到 plan 模式:只读探查 + 产出计划,审批后切 auto 执行。/auto 或 Shift+Tab 切回)${ui.reset}\n`);
1062
+ setAgentMode('plan');
1063
+ layout.contentWrite(`${ui.dim}${t('repl.planChanged')}${ui.reset}\n`);
955
1064
  }
956
1065
  continue;
957
1066
  }
958
1067
  if (line === '/auto') {
959
1068
  if (getAgentMode() === 'auto') {
960
- layout.contentWrite(`${ui.dim}(已在 auto 模式)${ui.reset}\n`);
1069
+ layout.contentWrite(`${ui.dim}${t('repl.autoAlready')}${ui.reset}\n`);
961
1070
  }
962
1071
  else {
963
- setAgentMode('auto'); // listener 接手 applyMode + refreshStatusBase
964
- layout.contentWrite(`${ui.dim}(已切回 auto 模式:全工具执行)${ui.reset}\n`);
1072
+ setAgentMode('auto');
1073
+ layout.contentWrite(`${ui.dim}${t('repl.autoChanged')}${ui.reset}\n`);
965
1074
  }
966
1075
  continue;
967
1076
  }
@@ -1028,7 +1137,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
1028
1137
  pendingAttachments = []; // 一并清空待发图片
1029
1138
  layout.clearContent();
1030
1139
  layout.writeBanner(bannerLines(banner()));
1031
- layout.contentWrite(`${ui.dim}(历史已清空,保留系统提示)${ui.reset}\n`);
1140
+ layout.contentWrite(`${ui.dim}${t('repl.historyCleared')}${ui.reset}\n`);
1032
1141
  continue;
1033
1142
  }
1034
1143
  // /image:附加本地图片到下一条 user 消息(支持 /image <path> · /image list · /image clear)。
@@ -1205,10 +1314,11 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
1205
1314
  }
1206
1315
  // buildSnapshot 是异步操作(完全由 LLM 生成),显示进度提示
1207
1316
  layout.contentWrite(`${ui.dim}正在重新生成项目快照 (LLM 分析中)...${ui.reset}\n`);
1317
+ const signal = startRunningListener('');
1208
1318
  try {
1209
1319
  // 清除缓存,强制重新生成
1210
1320
  clearSnapshotCache();
1211
- const result = await buildSnapshot(undefined, true);
1321
+ const result = await buildSnapshot(signal, true);
1212
1322
  if (result.snapshot) {
1213
1323
  layout.contentWrite(`${ui.cyan}✓ 项目快照已刷新${ui.reset} (${result.snapshot.builtAt})\n`);
1214
1324
  layout.contentWrite(`${ui.dim}已生成 markdown 快照,注入到系统提示词中${ui.reset}\n`);
@@ -1230,6 +1340,9 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
1230
1340
  const msg = e instanceof Error ? e.message : String(e);
1231
1341
  layout.contentWrite(`${ui.red}[错误]${ui.reset} 刷新快照失败: ${msg}\n`);
1232
1342
  }
1343
+ finally {
1344
+ stopRunningListener();
1345
+ }
1233
1346
  continue;
1234
1347
  }
1235
1348
  if (line === '/sessions') {
@@ -1290,7 +1403,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
1290
1403
  const items = listThemes().map((t) => ({
1291
1404
  id: t,
1292
1405
  title: t,
1293
- subtitle: THEME_DESCRIPTIONS[t] ?? '',
1406
+ subtitle: themeDescription(t),
1294
1407
  }));
1295
1408
  let pick;
1296
1409
  try {
@@ -1302,11 +1415,11 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
1302
1415
  name = pick?.id ?? null;
1303
1416
  }
1304
1417
  else if (arg === 'list' || !themeExists(arg)) {
1305
- layout.contentWrite(`${ui.dim}可用主题:${ui.reset}\n`);
1306
- for (const t of listThemes()) {
1307
- layout.contentWrite(` ${ui.accent}${t}${ui.reset} ${ui.dim}${THEME_DESCRIPTIONS[t] ?? ''}${ui.reset}\n`);
1418
+ layout.contentWrite(`${ui.dim}${t('repl.themeList')}${ui.reset}\n`);
1419
+ for (const theme of listThemes()) {
1420
+ layout.contentWrite(` ${ui.accent}${theme}${ui.reset} ${ui.dim}${themeDescription(theme)}${ui.reset}\n`);
1308
1421
  }
1309
- layout.contentWrite(`${ui.dim}(当前:${getTheme()};用 /theme <名称> 切换)${ui.reset}\n`);
1422
+ layout.contentWrite(`${ui.dim}${t('repl.themeCurrent', { theme: getTheme() })}${ui.reset}\n`);
1310
1423
  continue;
1311
1424
  }
1312
1425
  else {
@@ -1326,7 +1439,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
1326
1439
  else {
1327
1440
  layout.writeBanner(bannerLines(banner()));
1328
1441
  }
1329
- layout.contentWrite(`${ui.dim}(已切换主题 ${name})${ui.reset}\n`);
1442
+ layout.contentWrite(`${ui.dim}${t('repl.themeChanged', { theme: name })}${ui.reset}\n`);
1330
1443
  updateConfigKey('MOCODE_THEME', name);
1331
1444
  if (config.themeFromShell) {
1332
1445
  layout.contentWrite(`${ui.dim}(shell 环境变量 MOCODE_THEME 已设,文件写入下次启动被其覆盖;取消该 shell 设置后生效)${ui.reset}\n`);
@@ -1797,48 +1910,29 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
1797
1910
  }
1798
1911
  continue;
1799
1912
  }
1800
- // /project_skill init — 扫描项目并生成/优化 skill
1913
+ // /project_skill init — 作为一条普通主 Agent 请求生成/优化 skill,不再派生高消耗子 agent。
1801
1914
  if (arg === 'init') {
1802
1915
  if (!isProjectSkillEnabled()) {
1803
1916
  layout.contentWrite(`${ui.yellow}⚠ 项目专属 Skill 尚未开启${ui.reset},请先运行 ${ui.cyan}/project_skill on${ui.reset}\n`);
1804
1917
  continue;
1805
1918
  }
1806
- const { readProjectSkill, writeProjectSkill } = await import('../project-skill/index.js');
1807
- const existing = readProjectSkill();
1808
- if (existing) {
1809
- layout.contentWrite(`${ui.cyan}⏳ 正在深度探索项目并优化现有 skill...${ui.reset}\n`);
1810
- }
1811
- else {
1812
- layout.contentWrite(`${ui.cyan}⏳ 正在深度探索项目(子 agent 将使用工具扫描代码、架构、配置等)...${ui.reset}\n`);
1813
- }
1814
- layout.contentWrite(`${ui.dim}提示: 按 Ctrl+C 可中断${ui.reset}\n\n`);
1815
- // 派生子 agent 深度探索项目
1816
- const { generateInitialSkill } = await import('../project-skill/initializer.js');
1817
- const result = await generateInitialSkill(existing ?? undefined, currentAbort?.signal);
1818
- // 显示探索过程日志
1819
- if (result.transcript) {
1820
- layout.contentWrite(`${ui.dim}--- 探索过程 ---${ui.reset}\n`);
1821
- layout.contentWrite(result.transcript);
1822
- layout.contentWrite(`${ui.dim}--- 探索结束 ---${ui.reset}\n\n`);
1823
- }
1824
- if (result.ok && result.content) {
1825
- const writeResult = writeProjectSkill(result.content);
1826
- if (writeResult.ok) {
1827
- layout.contentWrite(`${ui.green}✓ 项目 Skill 已生成${ui.reset}\n`);
1828
- layout.contentWrite(`${ui.dim}内容预览:${ui.reset}\n${result.content.slice(0, 500)}${result.content.length > 500 ? '...' : ''}\n`);
1829
- layout.contentWrite(`\n${ui.dim}文件: ${ui.accent}.mocode/project-skill.md${ui.reset} (${result.content.length} 字符)\n`);
1830
- layout.contentWrite(`${ui.dim}下次启动时会自动注入到系统提示词。Agent 也会在开发过程中持续更新。${ui.reset}\n`);
1831
- layout.contentWrite(`${ui.dim}提示: 可用 ${ui.cyan}/project_skill view${ui.reset} 查看完整内容,或手动编辑文件完善。${ui.dim}${ui.reset}\n`);
1832
- }
1833
- else {
1834
- layout.contentWrite(`${ui.red}✗ 写入失败:${ui.reset} ${writeResult.error}\n`);
1835
- }
1919
+ const initPrompt = `请直接初始化或优化当前项目的 Project Skill,并完成写入。
1920
+
1921
+ 要求:
1922
+ 1. 直接由你完成,禁止调用 task 工具或派生任何子 agent。
1923
+ 2. 优先利用系统提示中已有的 Project Snapshot 和 Project Skill;不要重复扫描其中已有的目录、依赖、命令和模块清单。
1924
+ 3. 最多进行 1 次 codegraph 探索;只有缺少关键依据时,才额外进行少量定点 read_file/grep。禁止全仓 glob 和逐文件扫描。
1925
+ 4. Skill 只记录 Snapshot 无法提供的 WHY/HOW/GOTCHAS/CONVENTIONS:设计取舍、关键调用链、非直觉边界、项目约定和可操作坑点。使用具体路径和例子,删除重复或过时内容。
1926
+ 5. 最终内容应完整、结构清晰。调用 project_skill_update,使用 action=write 一次性写入完整内容。
1927
+ 6. 写入成功后只简短说明更新了哪些关键洞察,不要输出完整 Skill。`;
1928
+ const previousMode = getAgentMode();
1929
+ try {
1930
+ await runTurn(initPrompt, false, '');
1836
1931
  }
1837
- else {
1838
- layout.contentWrite(`${ui.red}✗ 探索失败:${ui.reset} ${result.error}\n`);
1839
- if (result.transcript) {
1840
- layout.contentWrite(`${ui.dim}子 agent 输出了部分内容(见上方),但未能生成完整的 skill 文档。${ui.reset}\n`);
1841
- }
1932
+ finally {
1933
+ // init 是一次明确写操作,临时使用 auto;完成后恢复用户原来的模式。
1934
+ if (previousMode === 'plan')
1935
+ setAgentMode('plan');
1842
1936
  }
1843
1937
  continue;
1844
1938
  }
@@ -1988,7 +2082,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
1988
2082
  // (runTurn 入口的 pendingAttachments.flush 仍按现有逻辑把附件塞进 userInput)。
1989
2083
  layout.rewindContent(bubbleRows);
1990
2084
  pendingPrefill = input;
1991
- layout.enterInputMode('空闲');
2085
+ layout.enterInputMode(t('repl.idle'));
1992
2086
  continue;
1993
2087
  }
1994
2088
  const initialPlan = getAgentMode() === 'plan'; // 轮首模式(在 runTurn 之前读)
@@ -2000,21 +2094,23 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
2000
2094
  // 桌宠:计划审批面板弹出期间广播 waiting_human(红灯闪烁);面板不在 runAgent/hooks 体系内,
2001
2095
  // 需在此单独广播——用户响应后由下一次 /pet 状态事件(或 idle 兜底)覆盖。
2002
2096
  sendState('waiting_human');
2097
+ const executePlanOption = t('plan.execute');
2003
2098
  const res = await promptIntervention({
2004
2099
  type: 'choice',
2005
- title: '计划已就绪',
2006
- detail: '切换到 auto 模式按上述计划执行?(plan 模式只读探查,执行需切 auto)',
2007
- options: ['切 auto 执行', '留 plan 细化'],
2100
+ title: t('plan.ready'),
2101
+ detail: t('plan.approvalDetail'),
2102
+ options: [executePlanOption, t('plan.refine')],
2008
2103
  });
2009
2104
  sendState('idle');
2010
- if (res.action === 'selected' && res.value === '切 auto 执行') {
2105
+ if (res.action === 'selected' && res.value === executePlanOption) {
2011
2106
  // setAgentMode('auto') 由 runTurn 入口做(listener 重写 history[0] 回 auto);这里只切运行态 + 合成执行轮。
2012
- layout.enterRunningMode('执行', '按计划执行…');
2013
- await runTurn('请按上述计划执行。', false, '按计划执行…');
2107
+ layout.enterRunningMode(t('plan.running'), t('plan.executing'));
2108
+ await runTurn(t('plan.executePrompt'), false, t('plan.executing'));
2014
2109
  }
2015
2110
  }
2016
2111
  }
2017
2112
  // 退出前等在飞反思收尾(Ctrl+C 走 SIGINT 直退不等;fire-and-forget 不承诺中断时完成)。
2018
2113
  await drainMemoryBackground();
2114
+ await closeAllMcp();
2019
2115
  layout.exitAltScreen();
2020
2116
  }