mocode-ai 0.6.7 → 0.6.9

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.
@@ -3,6 +3,7 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { promptIntervention } from '../ui/intervention.js';
5
5
  import { config } from '../config/index.js';
6
+ import { t } from '../i18n/index.js';
6
7
  /**
7
8
  * 工具权限系统:基于 risk 字段在执行前拦截确认。
8
9
  *
@@ -60,14 +61,14 @@ export function getToolRisk(tool) {
60
61
  function summarizeArgs(tool, args) {
61
62
  const lines = [];
62
63
  if (typeof args.path === 'string')
63
- lines.push(`路径: ${args.path}`);
64
+ lines.push(t('permission.path', { value: args.path }));
64
65
  if (typeof args.command === 'string')
65
- lines.push(`命令: ${args.command}`);
66
+ lines.push(t('permission.command', { value: args.command }));
66
67
  if (typeof args.prompt === 'string') {
67
68
  const preview = String(args.prompt).slice(0, 100);
68
- lines.push(`任务: ${preview}${String(args.prompt).length > 100 ? '…' : ''}`);
69
+ lines.push(t('permission.task', { value: `${preview}${String(args.prompt).length > 100 ? '…' : ''}` }));
69
70
  }
70
- return lines.length > 0 ? lines.join('\n') : '(无参数)';
71
+ return lines.length > 0 ? lines.join('\n') : t('permission.noArgs');
71
72
  }
72
73
  /**
73
74
  * 检查权限:safe 直接放行;confirm/dangerous 弹面板让用户确认。
@@ -90,14 +91,17 @@ export async function checkPermission(tool, args, signal) {
90
91
  return 'allow';
91
92
  // 构建确认面板
92
93
  const isDangerous = risk === 'dangerous';
94
+ const denyOption = t('permission.deny');
95
+ const foreverOption = t('permission.allowForever');
96
+ const sessionOption = t('permission.allowSession');
93
97
  const title = isDangerous
94
- ? `⚠ 高风险操作: ${tool.name}`
95
- : `确认执行: ${tool.name}`;
96
- const detail = summarizeArgs(tool, args) + (isDangerous ? '\n\n⚠ 此操作可能产生不可逆副作用,请谨慎确认。' : '');
98
+ ? t('permission.dangerTitle', { tool: tool.name })
99
+ : t('permission.confirmTitle', { tool: tool.name });
100
+ const detail = summarizeArgs(tool, args) + (isDangerous ? `\n\n${t('permission.dangerWarning')}` : '');
97
101
  // 选项统一结构:dangerous 也提供"以后不再询问"(用户明确授权即尊重,即使 run_command)
98
102
  const options = isDangerous
99
- ? ['确认执行', '以后不再询问此工具', '拒绝']
100
- : ['允许', '本次会话始终允许此工具', '以后不再询问此工具', '拒绝'];
103
+ ? [t('permission.confirmExecute'), foreverOption, denyOption]
104
+ : [t('permission.allow'), sessionOption, foreverOption, denyOption];
101
105
  // 弹面板(阻塞直到用户选择;signal 中断时 promptIntervention 内部处理)
102
106
  const result = await promptIntervention({
103
107
  type: 'choice',
@@ -111,16 +115,16 @@ export async function checkPermission(tool, args, signal) {
111
115
  return 'deny';
112
116
  // 解析选择
113
117
  const value = result.value ?? '';
114
- if (value === '拒绝')
118
+ if (value === denyOption)
115
119
  return 'deny';
116
120
  // 永久允许:写入磁盘 + 加入内存集合(跨会话生效)
117
- if (value === '以后不再询问此工具') {
121
+ if (value === foreverOption) {
118
122
  permanentAllow.add(tool.name);
119
123
  savePermanent();
120
124
  return 'allow';
121
125
  }
122
126
  // 会话允许(confirm 级):加入内存缓存,本次进程内不再弹
123
- if (!isDangerous && value === '本次会话始终允许此工具') {
127
+ if (!isDangerous && value === sessionOption) {
124
128
  approvedTools.add(tool.name);
125
129
  }
126
130
  return 'allow';
package/dist/pet/state.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // 与既有 TUI hooks 并列注入——两组 hooks 各自独立触发,互不干扰,不修改 core.ts 的任何行为。
4
4
  // 子 agent(src/agent/spawn.ts 的 spawnAgent)不引用本模块,故子 agent 永不广播桌宠状态。
5
5
  import * as bridge from './bridge.js';
6
+ import { isToolErrorOutput } from '../tools/result.js';
6
7
  /**
7
8
  * 纯函数:给定当前 hook 事件与其参数,推导下一个 PetState。
8
9
  * 前置条件:event 是 AgentHooks 定义的方法名之一。
@@ -19,7 +20,7 @@ export function deriveState(event, args) {
19
20
  case 'onToolStart':
20
21
  return 'tool_call';
21
22
  case 'onToolResult':
22
- if (args?.toolOutput && args.toolOutput.startsWith('错误'))
23
+ if (args?.toolOutput && isToolErrorOutput(args.toolOutput))
23
24
  return 'error';
24
25
  return 'tool_call';
25
26
  case 'onDone':
@@ -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,66 @@ 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];
1012
+ // 语言命令把视觉分隔放在确认文案之后;避免回显后先空一行、下一条命令却紧贴确认。
1013
+ echoInput(input, cmd !== '/language');
939
1014
  const { status, placeholder } = runningStateFor(cmd);
940
1015
  refreshStatusBase(history);
941
1016
  layout.enterRunningMode(status, placeholder);
1017
+ if (line === '/help') {
1018
+ layout.contentWrite(`${ui.bold}${t('help.title')}${ui.reset}\n`);
1019
+ layout.contentWrite(`${ui.dim}${t('help.hint')}${ui.reset}\n`);
1020
+ layout.contentWrite(`${slashHelpLines().join('\n')}\n`);
1021
+ continue;
1022
+ }
1023
+ if (line === '/language' || line.startsWith('/language ')) {
1024
+ const arg = line === '/language' ? '' : line.slice('/language '.length).trim();
1025
+ if (!arg) {
1026
+ const currentName = getLanguage() === 'zh-CN' ? t('language.zh') : t('language.en');
1027
+ layout.contentWrite(`${ui.dim}${t('language.current', { language: currentName })}${ui.reset}\n`);
1028
+ layout.contentWrite(`${ui.dim}${t('language.usage')}${ui.reset}\n\n`);
1029
+ continue;
1030
+ }
1031
+ const next = normalizeLanguage(arg);
1032
+ if (!next) {
1033
+ layout.contentWrite(`${ui.yellow}${t('language.invalid', { value: arg })}${ui.reset}\n\n`);
1034
+ continue;
1035
+ }
1036
+ updateLanguageConfig(next);
1037
+ updateConfigKey('MOCODE_LANGUAGE', next);
1038
+ history[0] = { role: 'system', content: buildSystemMessage(getAgentMode() === 'plan') };
1039
+ refreshStatusBase(history);
1040
+ // 横幅是内容缓冲顶部的固定区域;语言切换后等长原地替换,不移动后续对话。
1041
+ layout.rewriteBanner(bannerLines(banner()));
1042
+ layout.contentWrite(`${ui.cyan}${t('language.changed')}${ui.reset}\n`);
1043
+ if (languageFromShell) {
1044
+ layout.contentWrite(`${ui.dim}${t('language.shellOverride')}${ui.reset}\n`);
1045
+ }
1046
+ layout.contentWrite('\n');
1047
+ continue;
1048
+ }
942
1049
  if (line === '/init') {
943
1050
  // /init:把 init 指令当 user 输入发给 agent(扫描项目 + 生成 MOCODE.md),fall through 走 runAgent
944
1051
  joined = INIT_PROMPT;
945
1052
  }
946
1053
  if (line === '/plan') {
947
- // /plan:切到 plan 模式(只读探查 + 产出计划)。Shift+Tab 的可靠后备——
948
- // 中文 IME(微软拼音用 Shift 切中英)与部分终端会吞掉 Shift+Tab 的 \x1b[Z,命令不受影响。
1054
+ // /plan:切到 plan 模式(只读探查 + 产出计划)。
949
1055
  if (getAgentMode() === 'plan') {
950
- layout.contentWrite(`${ui.dim}(已在 plan 模式)${ui.reset}\n`);
1056
+ layout.contentWrite(`${ui.dim}${t('repl.planAlready')}${ui.reset}\n`);
951
1057
  }
952
1058
  else {
953
- setAgentMode('plan'); // listener 接手 applyMode + refreshStatusBase
954
- layout.contentWrite(`${ui.dim}(已切到 plan 模式:只读探查 + 产出计划,审批后切 auto 执行。/auto 或 Shift+Tab 切回)${ui.reset}\n`);
1059
+ setAgentMode('plan');
1060
+ layout.contentWrite(`${ui.dim}${t('repl.planChanged')}${ui.reset}\n`);
955
1061
  }
956
1062
  continue;
957
1063
  }
958
1064
  if (line === '/auto') {
959
1065
  if (getAgentMode() === 'auto') {
960
- layout.contentWrite(`${ui.dim}(已在 auto 模式)${ui.reset}\n`);
1066
+ layout.contentWrite(`${ui.dim}${t('repl.autoAlready')}${ui.reset}\n`);
961
1067
  }
962
1068
  else {
963
- setAgentMode('auto'); // listener 接手 applyMode + refreshStatusBase
964
- layout.contentWrite(`${ui.dim}(已切回 auto 模式:全工具执行)${ui.reset}\n`);
1069
+ setAgentMode('auto');
1070
+ layout.contentWrite(`${ui.dim}${t('repl.autoChanged')}${ui.reset}\n`);
965
1071
  }
966
1072
  continue;
967
1073
  }
@@ -1028,7 +1134,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
1028
1134
  pendingAttachments = []; // 一并清空待发图片
1029
1135
  layout.clearContent();
1030
1136
  layout.writeBanner(bannerLines(banner()));
1031
- layout.contentWrite(`${ui.dim}(历史已清空,保留系统提示)${ui.reset}\n`);
1137
+ layout.contentWrite(`${ui.dim}${t('repl.historyCleared')}${ui.reset}\n`);
1032
1138
  continue;
1033
1139
  }
1034
1140
  // /image:附加本地图片到下一条 user 消息(支持 /image <path> · /image list · /image clear)。
@@ -1290,7 +1396,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
1290
1396
  const items = listThemes().map((t) => ({
1291
1397
  id: t,
1292
1398
  title: t,
1293
- subtitle: THEME_DESCRIPTIONS[t] ?? '',
1399
+ subtitle: themeDescription(t),
1294
1400
  }));
1295
1401
  let pick;
1296
1402
  try {
@@ -1302,11 +1408,11 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
1302
1408
  name = pick?.id ?? null;
1303
1409
  }
1304
1410
  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`);
1411
+ layout.contentWrite(`${ui.dim}${t('repl.themeList')}${ui.reset}\n`);
1412
+ for (const theme of listThemes()) {
1413
+ layout.contentWrite(` ${ui.accent}${theme}${ui.reset} ${ui.dim}${themeDescription(theme)}${ui.reset}\n`);
1308
1414
  }
1309
- layout.contentWrite(`${ui.dim}(当前:${getTheme()};用 /theme <名称> 切换)${ui.reset}\n`);
1415
+ layout.contentWrite(`${ui.dim}${t('repl.themeCurrent', { theme: getTheme() })}${ui.reset}\n`);
1310
1416
  continue;
1311
1417
  }
1312
1418
  else {
@@ -1326,7 +1432,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
1326
1432
  else {
1327
1433
  layout.writeBanner(bannerLines(banner()));
1328
1434
  }
1329
- layout.contentWrite(`${ui.dim}(已切换主题 ${name})${ui.reset}\n`);
1435
+ layout.contentWrite(`${ui.dim}${t('repl.themeChanged', { theme: name })}${ui.reset}\n`);
1330
1436
  updateConfigKey('MOCODE_THEME', name);
1331
1437
  if (config.themeFromShell) {
1332
1438
  layout.contentWrite(`${ui.dim}(shell 环境变量 MOCODE_THEME 已设,文件写入下次启动被其覆盖;取消该 shell 设置后生效)${ui.reset}\n`);
@@ -1988,7 +2094,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
1988
2094
  // (runTurn 入口的 pendingAttachments.flush 仍按现有逻辑把附件塞进 userInput)。
1989
2095
  layout.rewindContent(bubbleRows);
1990
2096
  pendingPrefill = input;
1991
- layout.enterInputMode('空闲');
2097
+ layout.enterInputMode(t('repl.idle'));
1992
2098
  continue;
1993
2099
  }
1994
2100
  const initialPlan = getAgentMode() === 'plan'; // 轮首模式(在 runTurn 之前读)
@@ -2000,21 +2106,23 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
2000
2106
  // 桌宠:计划审批面板弹出期间广播 waiting_human(红灯闪烁);面板不在 runAgent/hooks 体系内,
2001
2107
  // 需在此单独广播——用户响应后由下一次 /pet 状态事件(或 idle 兜底)覆盖。
2002
2108
  sendState('waiting_human');
2109
+ const executePlanOption = t('plan.execute');
2003
2110
  const res = await promptIntervention({
2004
2111
  type: 'choice',
2005
- title: '计划已就绪',
2006
- detail: '切换到 auto 模式按上述计划执行?(plan 模式只读探查,执行需切 auto)',
2007
- options: ['切 auto 执行', '留 plan 细化'],
2112
+ title: t('plan.ready'),
2113
+ detail: t('plan.approvalDetail'),
2114
+ options: [executePlanOption, t('plan.refine')],
2008
2115
  });
2009
2116
  sendState('idle');
2010
- if (res.action === 'selected' && res.value === '切 auto 执行') {
2117
+ if (res.action === 'selected' && res.value === executePlanOption) {
2011
2118
  // setAgentMode('auto') 由 runTurn 入口做(listener 重写 history[0] 回 auto);这里只切运行态 + 合成执行轮。
2012
- layout.enterRunningMode('执行', '按计划执行…');
2013
- await runTurn('请按上述计划执行。', false, '按计划执行…');
2119
+ layout.enterRunningMode(t('plan.running'), t('plan.executing'));
2120
+ await runTurn(t('plan.executePrompt'), false, t('plan.executing'));
2014
2121
  }
2015
2122
  }
2016
2123
  }
2017
2124
  // 退出前等在飞反思收尾(Ctrl+C 走 SIGINT 直退不等;fire-and-forget 不承诺中断时完成)。
2018
2125
  await drainMemoryBackground();
2126
+ await closeAllMcp();
2019
2127
  layout.exitAltScreen();
2020
2128
  }