mocode-ai 1.3.9 → 1.4.0

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 (134) hide show
  1. package/README.md +78 -70
  2. package/README.zh-CN.md +81 -73
  3. package/bin/mocode-agent-host.js +1 -1
  4. package/dist/agent/core.js +226 -293
  5. package/dist/agent/index.js +14 -10
  6. package/dist/agent/mode.js +3 -3
  7. package/dist/agent/runtime-context.js +45 -0
  8. package/dist/agent/spawn.js +101 -86
  9. package/dist/agent/tool-helpers.js +134 -0
  10. package/dist/agent/trace-state.js +118 -0
  11. package/dist/agents/coordinator.js +6 -1
  12. package/dist/changeset/index.js +9 -8
  13. package/dist/config/index.js +109 -77
  14. package/dist/config/presets.js +3 -1
  15. package/dist/config/profiles.js +125 -0
  16. package/dist/context/age-aware.js +2 -4
  17. package/dist/context/artifacts.js +6 -13
  18. package/dist/context/budget.js +6 -8
  19. package/dist/context/classifier.js +1 -2
  20. package/dist/context/encoders/code.js +2 -6
  21. package/dist/context/encoders/command.js +1 -3
  22. package/dist/context/encoders/search.js +1 -3
  23. package/dist/context/index.js +1 -1
  24. package/dist/context/lifecycle.js +5 -3
  25. package/dist/context/pipeline.js +1 -1
  26. package/dist/context/relevance.js +2 -4
  27. package/dist/context/token-calibration.js +13 -18
  28. package/dist/host/stdio.js +73 -12
  29. package/dist/i18n/index.js +96 -44
  30. package/dist/index.js +1 -1
  31. package/dist/llm/index.js +39 -23
  32. package/dist/llm/provider.js +23 -0
  33. package/dist/llm/providers/anthropic.js +12 -18
  34. package/dist/llm/tool-schema.js +48 -0
  35. package/dist/mcp/client.js +70 -25
  36. package/dist/mcp/config.js +1 -3
  37. package/dist/mcp/index.js +3 -1
  38. package/dist/memory/reflect.js +3 -6
  39. package/dist/memory/store.js +10 -12
  40. package/dist/permissions/index.js +66 -22
  41. package/dist/pet/bridge.js +2 -2
  42. package/dist/repl/commands/appearance.js +72 -0
  43. package/dist/repl/commands/compact.js +114 -0
  44. package/dist/repl/commands/context.js +17 -0
  45. package/dist/repl/commands/image.js +71 -0
  46. package/dist/repl/commands/memory.js +51 -0
  47. package/dist/repl/commands/mode.js +47 -0
  48. package/dist/repl/commands/model.js +435 -0
  49. package/dist/repl/commands/pet.js +70 -0
  50. package/dist/repl/commands/registry.js +46 -0
  51. package/dist/repl/commands/session.js +102 -0
  52. package/dist/repl/commands/skill.js +95 -0
  53. package/dist/repl/commands/system.js +202 -0
  54. package/dist/repl/commands/tool-group.js +233 -0
  55. package/dist/repl/commands/types.js +4 -0
  56. package/dist/repl/commands.js +324 -0
  57. package/dist/repl/index.js +5 -2577
  58. package/dist/repl/message-format.js +151 -0
  59. package/dist/repl/running-input.js +147 -0
  60. package/dist/repl/runtime.js +816 -0
  61. package/dist/repl/status-bar.js +161 -0
  62. package/dist/rollback/index.js +44 -14
  63. package/dist/runtime/browser-manager.js +1 -1
  64. package/dist/runtime/dev-server-manager.js +1 -1
  65. package/dist/runtime/input-injector.js +313 -0
  66. package/dist/runtime/screen-capture.js +89 -0
  67. package/dist/runtime/screen-pipeline.js +223 -0
  68. package/dist/sandbox/index.js +1 -1
  69. package/dist/sandbox/policy.js +8 -2
  70. package/dist/session/compact.js +22 -34
  71. package/dist/session/index.js +2 -2
  72. package/dist/session/notes.js +39 -5
  73. package/dist/session/persist.js +14 -8
  74. package/dist/session/scheduler.js +3 -4
  75. package/dist/session/trace-metrics.js +6 -3
  76. package/dist/skills/activation.js +10 -5
  77. package/dist/skills/discover.js +5 -17
  78. package/dist/skills/index.js +1 -1
  79. package/dist/skills/runner.js +28 -44
  80. package/dist/skills/toolmap.js +5 -2
  81. package/dist/tools/builtins/browser.js +17 -3
  82. package/dist/tools/builtins/computer.js +322 -0
  83. package/dist/tools/builtins/dev-server.js +1 -3
  84. package/dist/tools/builtins/edit-file.js +21 -7
  85. package/dist/tools/builtins/grep.js +1 -1
  86. package/dist/tools/builtins/index.js +28 -25
  87. package/dist/tools/builtins/memory-forget.js +1 -1
  88. package/dist/tools/builtins/memory-graph.js +4 -7
  89. package/dist/tools/builtins/memory-list.js +2 -6
  90. package/dist/tools/builtins/memory-save.js +8 -2
  91. package/dist/tools/builtins/memory-search.js +4 -4
  92. package/dist/tools/builtins/note-append.js +14 -3
  93. package/dist/tools/builtins/plan-update.js +40 -9
  94. package/dist/tools/builtins/run-command.js +8 -8
  95. package/dist/tools/builtins/screenshot.js +11 -87
  96. package/dist/tools/builtins/task.js +29 -40
  97. package/dist/tools/builtins/use-skill.js +1 -1
  98. package/dist/tools/builtins/view-image.js +5 -3
  99. package/dist/tools/builtins/web-fetch.js +1 -3
  100. package/dist/tools/builtins/web-search.js +3 -7
  101. package/dist/tools/builtins/write-file.js +4 -2
  102. package/dist/tools/constants.js +50 -37
  103. package/dist/tools/policy.js +221 -0
  104. package/dist/tools/registry.js +40 -19
  105. package/dist/tools/resource-lock.js +4 -2
  106. package/dist/tools/router.js +143 -0
  107. package/dist/tools/validation.js +6 -5
  108. package/dist/ui/batch.js +63 -23
  109. package/dist/ui/clipboard.js +17 -5
  110. package/dist/ui/content.js +166 -13
  111. package/dist/ui/diff.js +61 -19
  112. package/dist/ui/fuzzy.js +35 -2
  113. package/dist/ui/intervention.js +2 -4
  114. package/dist/ui/layout-internal/content-write.js +587 -0
  115. package/dist/ui/layout-internal/core.js +590 -0
  116. package/dist/ui/layout-internal/input-paint.js +365 -0
  117. package/dist/ui/layout-internal/screen.js +128 -0
  118. package/dist/ui/layout-internal/scroll.js +152 -0
  119. package/dist/ui/layout-internal/selection.js +229 -0
  120. package/dist/ui/layout-internal/state.js +82 -0
  121. package/dist/ui/layout-internal/statusbar.js +351 -0
  122. package/dist/ui/layout-types.js +1 -0
  123. package/dist/ui/layout.js +3 -2288
  124. package/dist/ui/markdown.js +10 -2
  125. package/dist/ui/prompt-internal/editor.js +945 -0
  126. package/dist/ui/prompt-internal/paste.js +52 -0
  127. package/dist/ui/prompt-internal/pickers.js +519 -0
  128. package/dist/ui/prompt-internal/types.js +1 -0
  129. package/dist/ui/prompt.js +4 -1506
  130. package/dist/ui/render.js +87 -2
  131. package/dist/verification/affected.js +11 -5
  132. package/dist/verification/discovery.js +1 -3
  133. package/dist/verification/profile.js +11 -3
  134. package/package.json +11 -2
@@ -0,0 +1,816 @@
1
+ import { emitKeypressEvents } from 'node:readline';
2
+ import { stdin } from 'node:process';
3
+ import { config, isModelConfigured, isMemoryEnabled, buildBasePrompt, getPlanModeSuffix, pinSessionModel, } from '../config/index.js';
4
+ import { t } from '../i18n/index.js';
5
+ import { listPresets, migrateCurrentToPreset } from '../config/presets.js';
6
+ import { runAgent } from '../agent/index.js';
7
+ import { getAgentMode, setAgentMode, onModeChange } from '../agent/mode.js';
8
+ import { sendState } from '../pet/bridge.js';
9
+ import { setSandboxRoot } from '../sandbox/root.js';
10
+ import { ui, setTheme } from '../ui/theme.js';
11
+ import { bannerLines, displayWidth } from '../ui/render.js';
12
+ import * as layout from '../ui/layout.js';
13
+ import * as mouse from '../ui/mouse.js';
14
+ import { promptWithSlashMenu, promptTurnPicker, promptRevertChoice } from '../ui/prompt.js';
15
+ import { promptIntervention } from '../ui/intervention.js';
16
+ import { registerToolsExtension } from '../tools/registry.js';
17
+ // 装配官方默认工具包:registry 不再顶层 import builtins(破模块循环),REPL 入口须显式装配。
18
+ import '../tools/builtins/index.js';
19
+ import { initializeAllMcp, getMcpTools, closeAllMcp } from '../mcp/index.js';
20
+ import { refreshChatTools, classifyChatError } from '../llm/index.js';
21
+ import { renderChip } from '../attachments/image.js';
22
+ import { contextState, newSessionId, saveSession, loadSession, appendCurrentSessionRuntimeEvent, } from '../session/index.js';
23
+ import { listTurns, planRollback, applyRollback, persistSnapshots, loadSnapshots, rebuildFromHistory, getCurrentTurnId, } from '../rollback/index.js';
24
+ import { effectiveSystemPrompt } from '../skills/index.js';
25
+ import { clearSkillActivation } from '../skills/activation.js';
26
+ import { buildMemoryIndexSection, kickoffReflection, drainMemoryBackground, getLastReflectResult, clearLastReflectResult, snapshotTranscript, formatReflectResult, } from '../memory/index.js';
27
+ import { setCurrentSessionId } from '../session/state.js';
28
+ import { collectQueryHistory } from '../session/query-history.js';
29
+ import { ToolPolicyController } from '../tools/policy.js';
30
+ import { routeToolGroups } from '../tools/router.js';
31
+ // ── 已拆分的子模块 ──
32
+ import { PROMPT, buildSlashCommands, LLM_ERROR_HINT_KEYS, isCommandShape, suggestCommand } from './commands.js';
33
+ import { dispatchCommand } from './commands/registry.js';
34
+ import { settlePlanStatus, refreshStatusBase, runningStateFor } from './status-bar.js';
35
+ import { messageAttachments, formatUserMessage, textOf, queryHistoryFromMessages, renderHistory, } from './message-format.js';
36
+ import { startRunningListener, stopRunningListener, getRunningInput, clearRunningInput } from './running-input.js';
37
+ const emitter = stdin;
38
+ // ── 运行态交互已移到 ./running-input.ts ──
39
+ // ── pendingPrefill / pendingTimer / pendingAttachments 保留在此(awaitPendingRecall / echoInput 用)
40
+ let pendingPrefill = null; // /rollback 选中后预填的 user 输入(下轮 INPUT 态消费)
41
+ // ── pending send 撤回窗口(用户按 Enter 后、agent 真发请求前)──
42
+ // 500ms 内 Ctrl+C / Esc → 整条用户气泡从内容区擦掉 + 原行 prefilled 回输入框(可改可再发);
43
+ // 期间再按 Enter 立即推进 / 时间到自然推进 → 走原流程 enterRunningMode + runTurn。
44
+ // attachmentsCount 记 pendingAttachments 当时长度——撤回时 attachments 保留(用户意图未变,只是改字)。
45
+ // 短单行:500ms(不拖慢日常)。长文本:MOCODE_RECALL_MS,默认 2000ms —— 人发现"误发了"通常要 1-3 秒,
46
+ // 500ms 根本来不及反应,只能事后 /rollback。
47
+ const PENDING_RECALL_MS = 500;
48
+ const PENDING_RECALL_LONG_MS = 2000;
49
+ /** 长输入阈值:与 prompt.ts 的二次确认阈值保持一致(≥2 行 或 ≥120 码点)。 */
50
+ const LONG_INPUT_CHARS = 120;
51
+ /** 按输入长度决定撤回窗口:长 prompt 给更宽的补救时间,短单行仍走 500ms。 */
52
+ function recallWindowMs(input) {
53
+ const joined = input.join('\n');
54
+ const long = input.length >= 2 || [...joined].length >= LONG_INPUT_CHARS;
55
+ if (!long)
56
+ return PENDING_RECALL_MS;
57
+ const env = Number(process.env.MOCODE_RECALL_MS);
58
+ return Number.isFinite(env) && env >= 0 ? env : PENDING_RECALL_LONG_MS;
59
+ }
60
+ let pendingTimer = null;
61
+ // agent 模式状态已提到 src/agent/mode.ts(共享叶子;仅用户面可切换,agent 每步读取)。
62
+ /** 多模态 user 输入的附件状态。pending = 本轮尚未提交的待发图片;messageAttachments = 已 push 进 history 的图片元数据
63
+ * (供 renderHistory 复显文件名——base64 不可逆地塞进 history 后,只能从侧 channel 拿原文件名)。
64
+ * messageAttachments 已移到 ./message-format.ts (共享 Map,renderHistory 与 runTurn 都用)。 */
65
+ let pendingAttachments = [];
66
+ // ── onRunningKey / onRunningMousePaste / startRunningListener / stopRunningListener 已移到 ./running-input.ts ──
67
+ // formatUserMessage 已移到 ./message-format.ts
68
+ /** 把多行提交输入回显进内容区(❯ 首行,续行按 prompt 宽度缩进)。仅 TUI 态回显(非 TTY 由 readline 自带回显)。 */
69
+ function echoInput(lines, trailingBlank = true) {
70
+ if (!layout.isActive())
71
+ return;
72
+ layout.contentWrite(formatUserMessage(lines, trailingBlank));
73
+ // 多模态附件:每张一行 chip 跟在 user bubble 后(原 /rollback /resume 复显一致)
74
+ for (const a of pendingAttachments) {
75
+ layout.contentWrite(` ${ui.dim}${renderChip(a)}${ui.reset}\n`);
76
+ }
77
+ // 用户气泡会填满终端整列宽;Windows Terminal 在末列进入 pending-wrap 后,紧随的 LF
78
+ // 偶尔只更新内部滚屏状态,导致上一轮耗时行与新气泡暂时黏连。缓冲中的物理行始终正确,
79
+ // 提交后立即按缓冲重绘,避免必须等滚动或 Agent 结束时的 repaint 才显示正确边界。
80
+ layout.repaintViewport();
81
+ }
82
+ /**
83
+ * 等待 pending 撤回窗口(用户 Enter 后、agent 真发请求前的 500ms 兜底)。
84
+ * 返 true=应 commit(走原 enterRunningMode + runTurn);false=应 recall(主循环 rewindContent 擦气泡 + prefill 回输入框)。
85
+ *
86
+ * 监听:
87
+ * - Esc / Ctrl+C → recall(shouldCommit=false)
88
+ * - Enter / Return → 立即 commit(shouldCommit=true)
89
+ * - 其它键忽略(不进 paste 路径、不挂 timer)
90
+ * - 500ms 定时器到 → 自动 commit(shouldCommit=true)
91
+ *
92
+ * 视觉:状态行 spinner 位临时改 '发送中… (Esc / Ctrl+C 撤回)';commit 后
93
+ * runAgent.onStepStart 会 setStatus('思考中') 接管,无需手动还原。
94
+ *
95
+ * 降级:非 TTY(setRawMode 抛错)直接 commit,window=0 —— CI / 管道回放路径不退化。
96
+ */
97
+ function awaitPendingRecall(input) {
98
+ layout.setStatus(t('agent.sending'), '●');
99
+ // 非 TTY:setRawMode 抛错 → window=0 直返 true(向后退化,不走 raw + 不挂监听)。
100
+ let ttyReady = false;
101
+ try {
102
+ stdin.setRawMode(true);
103
+ ttyReady = true;
104
+ }
105
+ catch {
106
+ ttyReady = false;
107
+ }
108
+ if (!ttyReady) {
109
+ return Promise.resolve(true);
110
+ }
111
+ stdin.resume();
112
+ emitKeypressEvents(stdin);
113
+ return new Promise((resolve) => {
114
+ let done = false;
115
+ const finalize = (shouldCommit) => {
116
+ if (done)
117
+ return;
118
+ done = true;
119
+ if (pendingTimer) {
120
+ clearTimeout(pendingTimer);
121
+ pendingTimer = null;
122
+ }
123
+ emitter.off('keypress', onPendingKey);
124
+ resolve(shouldCommit);
125
+ };
126
+ const onPendingKey = (_str, key) => {
127
+ if (!key || done)
128
+ return;
129
+ // 鼠标报表:吞(与 prompt.ts / onRunningKey 风格一致)
130
+ if (mouse.swallow(key.sequence ?? ''))
131
+ return;
132
+ // 撤回
133
+ if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
134
+ finalize(false);
135
+ return;
136
+ }
137
+ // 确认立即发送:Enter / Return
138
+ if (key.name === 'enter' || key.name === 'return') {
139
+ finalize(true);
140
+ return;
141
+ }
142
+ // 其它任何键 → 撤回。
143
+ // 旧实现把非 Enter 键"忽略"掉:误按 Enter 后人的第一反应是狂敲键盘 / 按空格 / 按退格,
144
+ // 这一串输入被静默丢弃,白白错过补救窗口。撤回窗口期间输入框尚未接管键盘,
145
+ // 这些键本来也不会进输入框,吞掉它们没有任何收益 —— 全部当作撤回信号更有用。
146
+ finalize(false);
147
+ };
148
+ emitter.on('keypress', onPendingKey);
149
+ pendingTimer = setTimeout(() => finalize(true), recallWindowMs(input));
150
+ pendingTimer.unref?.();
151
+ });
152
+ }
153
+ // ── textOf / queryHistoryFromMessages / renderHistory 已移到 ./message-format.ts ──
154
+ /**
155
+ * 交互式 REPL:全屏 TUI(alt screen + 固定底栏)。INPUT 态底栏=状态行+输入框(raw mode 等按键);
156
+ * 提交后 enterRunningMode(底栏改 dim 占位、光标回内容续写位),命令分发与 runAgent 的流式输出经
157
+ * contentWrite 落入内容区(滚动区域内自动滚动,底栏不动)。history 由本模块持有,在轮次间持久;
158
+ * agent 只读取并追加(+ 经 session/ 压缩)。每轮成功结束后自动落盘,退出后可用 --resume / /resume 续接。
159
+ */
160
+ export async function startRepl(initialHistory, sessionId, sandboxRootOverride, initialQueryHistory, initialLastToolGroups) {
161
+ // 模式重置:agentMode 不落盘,每个 REPL 会话从 auto 开始(/resume / --resume 亦重置)。
162
+ setAgentMode('auto');
163
+ // 钉死本会话模型:之后运行中 agent 一律用此值,其它窗口的 /model switch 不会影响本窗口。
164
+ pinSessionModel();
165
+ // 沙箱根:文件操作边界。优先级 --sandbox-root > SANDBOX_ROOT env > process.cwd()。
166
+ // 纯边界记录(不 chdir),jail.ts 内部 resolve。子 agent 同进程继承全局 root。
167
+ setSandboxRoot(sandboxRootOverride ?? config.sandboxRoot ?? process.cwd());
168
+ // MCP 在工具表和 LLM schema 创建前连接;失败的单个 server 只给提示,不阻断 REPL。
169
+ const mcpReport = await initializeAllMcp();
170
+ registerToolsExtension('mcp', getMcpTools());
171
+ refreshChatTools();
172
+ // --resume:读回该会话的轮次/快照;无文件则从 history 重建 turns(无快照→旧轮次文件改动不可撤销)
173
+ let currentSessionId = sessionId;
174
+ if (!currentSessionId)
175
+ currentSessionId = newSessionId(); // 新会话立即分配 ID,确保 prompt 里有正确路径
176
+ setCurrentSessionId(currentSessionId, process.cwd()); // 必须在 buildBasePrompt() 之前,让 prompt 能读到 sessionId
177
+ // 构造系统提示:auto 用 base;plan 在 base 后追加按当前开关现拼的 plan suffix。
178
+ // 切模式时 applyMode 重算 history[0](history[0] 恒 system,compaction 保它,不破坏)。
179
+ // 活跃 plan 摘要拼在 memory 段后(systemPrompt 的尾段),todo 工具变更后 listener 重写 history[0]。
180
+ //
181
+ // 与开关联动:① base 用 buildBasePrompt() 取代 config.systemPrompt(后者是启动时一次性
182
+ // 求值的常量,运行时 /memory_switch 不会刷新);② plan suffix 走 getPlanModeSuffix() 现拼;
183
+ // ③ AGENTS.md 存在工作区根时由 base 无条件自动导入正文(超长截断,与 memory 开关无关);④ Memory Index 按开关注入。
184
+ const buildSystemMessage = (planMode) => effectiveSystemPrompt(buildBasePrompt(currentSessionId) +
185
+ (planMode ? getPlanModeSuffix() : '') +
186
+ buildMemoryIndexSection(isMemoryEnabled()));
187
+ // 有预加载(--resume)则用它,并把 history[0] 刷成当前 system prompt(config 可能已变);
188
+ // 否则新会话只塞 system 提示(默认 auto)。
189
+ const history = initialHistory && initialHistory.length ? initialHistory : [{ role: 'system', content: buildSystemMessage(false) }];
190
+ // 新 session 使用独立输入历史;旧 session 没有该字段时从 user 消息兼容回填一次。
191
+ let queryHistory = initialQueryHistory ? [...initialQueryHistory] : queryHistoryFromMessages(history);
192
+ /** 上一真实用户 turn 的最终工具簇;仅作为下一次 LLM 路由失败/续接时的回退。 */
193
+ let lastToolGroups = [...(initialLastToolGroups ?? [])];
194
+ if (initialHistory && initialHistory.length && history[0]?.role === 'system') {
195
+ history[0] = { role: 'system', content: buildSystemMessage(false) };
196
+ }
197
+ if (sessionId && initialHistory && initialHistory.length) {
198
+ if (!loadSnapshots(sessionId))
199
+ rebuildFromHistory(history);
200
+ }
201
+ // 反思 cadence 计数:每 reflectEveryN 轮 fire-and-forget 一次后台反思 pass。
202
+ let turnCount = 0;
203
+ // 本轮 token 累计:runAgent 返回后写入,供底栏模式 chip 右边显示。undefined=无实测
204
+ // (后端不开 include_usage / 后端失败时)。
205
+ let lastTurnUsage;
206
+ const banner = () => ({
207
+ model: config.model,
208
+ baseURL: config.baseURL,
209
+ cwd: process.cwd(),
210
+ tools: new ToolPolicyController({ groups: lastToolGroups, maxExpansions: 0 })
211
+ .snapshot(getAgentMode() === 'plan')
212
+ .tools.map((tool) => tool.function.name)
213
+ .join(' · '),
214
+ memoryEnabled: isMemoryEnabled(),
215
+ });
216
+ /**
217
+ * 欢迎引导块:新会话开场写在内容区(banner 之下),教用户怎么开始 / 能做什么。
218
+ * 首次提交任何输入(消息或斜杠命令)前由 layout.dismissWelcomeBlock 整块撤掉——
219
+ * 「一打开就能看见,开始干活就消失」。/clear 清空后重新写一次(回到空会话状态)。
220
+ */
221
+ // 欢迎块整块按终端宽度居中:先按纯文本(displayWidth 不剥 ANSI)算左缩进,再套色;
222
+ // 余数列归左,与输入框提示行的居中策略一致;超宽行由 contentWrite 兜底折行。
223
+ const welcomeLines = () => {
224
+ const cols = layout.getGeo().cols;
225
+ const pad = (plain) => ' '.repeat(Math.max(0, Math.floor((cols - displayWidth(plain)) / 2)));
226
+ const ideNotice = `⚠ ${t('welcome.ideNotice')}`;
227
+ return [
228
+ '',
229
+ `${pad(ideNotice)}${ui.yellow}${ui.bold}${ideNotice}${ui.reset}`,
230
+ '',
231
+ `${pad(t('welcome.gettingStarted'))}${ui.accent}${ui.bold}${t('welcome.gettingStarted')}${ui.reset}`,
232
+ `${pad(`· ${t('welcome.start1')}`)}${ui.dim}· ${t('welcome.start1')}${ui.reset}`,
233
+ `${pad(`· ${t('welcome.start2')}`)}${ui.dim}· ${t('welcome.start2')}${ui.reset}`,
234
+ `${pad(`· ${t('welcome.start3')}`)}${ui.dim}· ${t('welcome.start3')}${ui.reset}`,
235
+ '',
236
+ `${pad(t('welcome.capabilities'))}${ui.accent}${ui.bold}${t('welcome.capabilities')}${ui.reset}`,
237
+ `${pad(`· ${t('welcome.cap1')}`)}${ui.dim}· ${t('welcome.cap1')}${ui.reset}`,
238
+ `${pad(`· ${t('welcome.cap2')}`)}${ui.dim}· ${t('welcome.cap2')}${ui.reset}`,
239
+ `${pad(`· ${t('welcome.cap3')}`)}${ui.dim}· ${t('welcome.cap3')}${ui.reset}`,
240
+ '',
241
+ `${pad(t('welcome.try'))}${ui.dim}${t('welcome.try')}${ui.reset}`,
242
+ '',
243
+ ];
244
+ };
245
+ // 开场:按 config.theme 切主题(横幅 / 状态行 / 后续渲染皆用新色),再进 alt screen + 状态基线 + 清内容区。
246
+ // --resume 有历史则渲染对话,否则横幅。
247
+ setTheme(config.theme);
248
+ layout.enterAltScreen();
249
+ refreshStatusBase(history);
250
+ layout.clearContent();
251
+ layout.contentMode();
252
+ if (history.some((m) => m.role === 'user')) {
253
+ // --resume 锚点:先给一行会话摘要(id/消息数/当前模型),新手对恢复状态有定位感,
254
+ // 再铺历史对话。renderHistory 之后不再追加,锚点恒为恢复内容的第一行。
255
+ const anchorCount = history.filter((m) => m.role !== 'system').length;
256
+ layout.contentWrite(`${ui.dim}${t('repl.resumeAnchor', { id: currentSessionId, count: anchorCount, model: config.model })}${ui.reset}\n`);
257
+ renderHistory(history);
258
+ // 强制回尾:同 /resume 命令,renderHistory 展开详情会设 scrollOffset>0,需复位避免闪烁。
259
+ layout.resetScroll();
260
+ layout.repaintViewport();
261
+ }
262
+ else {
263
+ layout.writeBanner(bannerLines(banner()));
264
+ }
265
+ if (mcpReport.connected.length > 0) {
266
+ layout.contentWrite(`${ui.dim} ↳ 已连接 MCP: ${mcpReport.connected.join(', ')} (${getMcpTools().length} 个工具;外部工具每次均需授权)${ui.reset}\n`);
267
+ }
268
+ for (const warning of mcpReport.warnings) {
269
+ layout.contentWrite(`${ui.yellow} ⚠ ${warning}${ui.reset}\n`);
270
+ }
271
+ if (!isModelConfigured()) {
272
+ // 未配置 baseURL/apiKey:醒目提示引导 /model(不退出,REPL 仍可用;发消息会失败但不崩)。
273
+ layout.contentWrite(`${ui.yellow} ⚠ 未配置大模型。输入 ${ui.cyan}/model${ui.yellow} 配置 baseURL / apiKey / model(即时生效),或退出后运行 ${ui.cyan}mocode config${ui.yellow} 走向导。${ui.reset}\n`);
274
+ }
275
+ else {
276
+ // 老用户兜底:若 ~/.mocode/models/ 空,自动把 ~/.mocode/config 的当前 LLM 四键迁成 'default' 预设。
277
+ // 这样 /model list / switch 立即可见,无需手动 /model 重存一遍。幂等:重启只生效一次。
278
+ if (listPresets().length === 0) {
279
+ try {
280
+ const migrated = migrateCurrentToPreset({
281
+ provider: config.provider,
282
+ baseURL: config.baseURL,
283
+ apiKey: config.apiKey,
284
+ model: config.model,
285
+ contextWindow: config.contextWindowTokens,
286
+ anthropicPromptCache: config.anthropicPromptCache,
287
+ });
288
+ if (migrated) {
289
+ layout.contentWrite(`${ui.dim} ↳ 检测到老配置,已自动迁为预设 “${migrated}”(${ui.cyan}/model list${ui.dim} 查看 · /model switch 切换)${ui.reset}\n`);
290
+ }
291
+ }
292
+ catch {
293
+ // 迁移失败不阻塞启动;用户后续 /model 时仍能手动配。
294
+ }
295
+ }
296
+ }
297
+ // 新会话开场:banner 之后写欢迎引导块(--resume 有历史时不写,历史本身就是上下文)。
298
+ if (!history.some((m) => m.role === 'user')) {
299
+ layout.writeWelcomeBlock(welcomeLines());
300
+ }
301
+ /**
302
+ * 切换 agent 模式(Shift+Tab 触发,经 prompt.ts 的 onCycleMode 回调)。
303
+ * cycleMode 翻 agentMode + applyMode 重写 history[0] + refreshStatusBase 设状态行 modeTag;
304
+ * 随后 prompt.ts 自调 redraw() 经 paintInput 把 chip 画出来(光标留输入框,不调 drawStatusBar)。
305
+ */
306
+ const applyMode = (planMode) => {
307
+ history[0] = { role: 'system', content: buildSystemMessage(planMode) };
308
+ };
309
+ const cycleMode = () => {
310
+ const prev = getAgentMode();
311
+ setAgentMode(prev === 'plan' ? 'auto' : 'plan'); // listener 接手 applyMode + refreshStatusBase
312
+ // Shift+Tab 只有 chip 变化,新手可能没感知:内容区补一行确认(与 /plan / /auto 同文案,复用不新造键)。
313
+ layout.contentWrite(`${ui.dim}${t(prev === 'plan' ? 'repl.autoChanged' : 'repl.planChanged')}${ui.reset}\n`);
314
+ };
315
+ // 注册模式变更监听器:cycleMode、/plan、/auto 与 runTurn 调 setAgentMode 时同步触发,
316
+ // 重写 history[0] 系统提示(切 plan 追加 PLAN_MODE_SUFFIX)并刷新状态行 modeTag。
317
+ // 不调 drawStatusBar:INPUT 态由 prompt.ts redraw 画 chip,RUNNING 态由 200ms turnTimer 兜底。
318
+ onModeChange((m) => {
319
+ applyMode(m === 'plan');
320
+ refreshStatusBase(history);
321
+ });
322
+ /**
323
+ * 回滚子流程(由 /rollback 触发):菜单(↑/↓)选轮次 → 选中第 X 轮 = 删第 X 轮及之后 + 预填第 X 轮 user 输入
324
+ * (Enter 重新跑该轮);被删轮次的文件改动走二选一菜单(promptRevertChoice:
325
+ * 撤销文件 / 只撤销消息)。选轮 + 方式菜单均走 raw mode。预填经 pendingPrefill 注入下轮 INPUT。
326
+ */
327
+ const rollbackFlow = async () => {
328
+ const turnList = listTurns();
329
+ if (turnList.length < 1) {
330
+ layout.contentWrite(`${ui.dim}(没有可回滚的轮次)${ui.reset}\n`);
331
+ return;
332
+ }
333
+ // 各轮 user 全文(预填用;按 user 消息顺序与 turnList 对齐)
334
+ const userTexts = [];
335
+ for (const m of history) {
336
+ if (m.role === 'user')
337
+ userTexts.push(textOf(m.content));
338
+ }
339
+ const items = turnList.map((t) => ({ firstLine: t.firstLine }));
340
+ let picked;
341
+ try {
342
+ picked = await promptTurnPicker(items);
343
+ }
344
+ catch {
345
+ return; // Ctrl+C(SIGINT)→ 取消回滚
346
+ }
347
+ if (picked === null)
348
+ return; // Esc 取消
349
+ // picked(0-based)= 第 (picked+1) 轮:删该轮及之后(planRollback(picked) 保 1..picked),预填该轮 user 输入
350
+ const prefillText = userTexts[picked] ?? '';
351
+ const plan = planRollback(picked, history);
352
+ // 清屏(擦选轮菜单 + /rollback 回显)+ 复位 lastView(dim 空),给文件询问一个干净、resize 安全的画面
353
+ layout.clearContent();
354
+ layout.paintInput({
355
+ prompt: '❯ ',
356
+ lines: [''],
357
+ cursorLine: 0,
358
+ cursorCol: 0,
359
+ menu: null,
360
+ dim: true,
361
+ });
362
+ const revertPaths = new Set();
363
+ // 文件撤销:二选一菜单(1=撤销文件改动 / 2=只撤销消息保留文件)
364
+ // 返回 true=撤销文件(把 changes 里所有可撤销的路径加入 revertPaths);
365
+ // false/null(取消/非 TTY/Ctrl+C)=只撤销消息,保留文件改动。
366
+ const changes = plan.changes;
367
+ const revertable = changes.filter((c) => c.snapshotAvailable);
368
+ let revertFiles = false;
369
+ if (changes.length > 0) {
370
+ try {
371
+ const choice = await promptRevertChoice(revertable.length);
372
+ revertFiles = choice === true;
373
+ }
374
+ catch {
375
+ revertFiles = false; // Ctrl+C(SIGINT)→ 保留文件,只撤销消息
376
+ }
377
+ }
378
+ if (revertFiles) {
379
+ for (const c of revertable)
380
+ revertPaths.add(c.path);
381
+ }
382
+ const rolledBackFromTurnId = getCurrentTurnId();
383
+ const turnCountBeforeRollback = listTurns().length;
384
+ const rollbackResult = applyRollback(plan, history, revertPaths);
385
+ // 被撤销轮次的最终 route 无法再作为可靠 continuation 基线;下一真实 turn 重新由 LLM 选择。
386
+ lastToolGroups = [];
387
+ if (!currentSessionId)
388
+ currentSessionId = newSessionId();
389
+ setCurrentSessionId(currentSessionId, process.cwd()); // 同步到 session/state,确保 notes.md 存在
390
+ appendCurrentSessionRuntimeEvent('rollback', {
391
+ status: 'applied',
392
+ rolledBackFromTurnId,
393
+ cutoffTurnId: plan.cutoffTurnId,
394
+ retainedTurns: plan.n,
395
+ rolledBackTurns: Math.max(0, turnCountBeforeRollback - plan.n),
396
+ deletedMessages: rollbackResult.deletedMsgs,
397
+ revertedFiles: rollbackResult.revertedFiles,
398
+ conflictedFiles: rollbackResult.conflictedFiles,
399
+ requestedFileCount: revertPaths.size,
400
+ }, plan.cutoffTurnId);
401
+ try {
402
+ saveSession(history, currentSessionId, queryHistory);
403
+ }
404
+ catch {
405
+ // 落盘失败不阻断
406
+ }
407
+ persistSnapshots(currentSessionId);
408
+ // 复显剩余对话(无提示行),输入框预填该轮 user 输入 → 下轮 Enter 重新跑
409
+ layout.clearContent();
410
+ renderHistory(history);
411
+ if (rollbackResult.conflictedFiles.length > 0) {
412
+ layout.contentWrite(`${ui.yellow} ⚠ rollback conflict: ${rollbackResult.conflictedFiles.join(', ')} 已在 Agent 提交后变化,未覆盖。${ui.reset}\n`);
413
+ }
414
+ // 末尾补空行:与后续用户消息(❯ bubble)之间分隔。runTurn 在每个 agent 轮结束后
415
+ // contentWrite('\n') 做轮次分隔,/resume 后接 \n\n,/theme·/model 后接 \n;
416
+ // rollbackFlow 原本漏了这一行,renderHistory 末尾的 batch 摘要行 / assistant 文本
417
+ // 收口后,续写位未稳到新空行,下一次 echoInput 的 ❯ 气泡会黏在最后一条输出后面。
418
+ layout.contentWrite('\n');
419
+ // 回滚后强制回尾:确保 scrollOffset=0,避免后续 showLiveBatch 在冻结视口下
420
+ // 调用 repaintViewport 导致工具信息闪烁/滚动消失(rollback 后用户继续输入触发新 agent
421
+ // 运行时,若 scrollOffset 意外 >0,contentWrite 只喂缓冲不物理写,showLiveBatch 的
422
+ // repaintViewport 画出不含新摘要的冻结窗口 → 工具信息"闪现后消失")。
423
+ layout.resetScroll();
424
+ pendingPrefill = prefillText.split('\n');
425
+ };
426
+ /**
427
+ * 跑一轮 agent(enterRunningMode 已由调用方完成):startRunningListener → runAgent → autosave / reflect。
428
+ * plan 模式传 planMode=true(runAgent 用 planChatTools 只读子集)。返 ok=正常结束(未中断 / 未抛错),
429
+ * 供调用方决定是否弹审批面板。execute 轮的合成输入也走这里。
430
+ *
431
+ * 多模态:把 pendingAttachments flush 进 userInput:有图时构造 ContentPart[] 数组;
432
+ * 无图时保持 string(向后兼容,且 messageTokens 走 estimateTokens 不走 IMAGE_TOKEN_COST)。
433
+ * side channel 记录本轮 msg 在 history 的 index → attachments,供 renderHistory 复显文件名。
434
+ */
435
+ const runTurn = async (input, planMode, placeholder, inheritedToolPolicy) => {
436
+ const imgs = pendingAttachments;
437
+ pendingAttachments = []; // 入口即清,即使后续抛错也不留陈旧附件
438
+ const userInput = imgs.length === 0
439
+ ? input
440
+ : [
441
+ { type: 'text', text: input },
442
+ // detail 故意不设(留 undefined,JSON.stringify 时被丢弃):OpenAI 认 'auto'/'low'/'high',
443
+ // 但 MiniMax 只认 'low'/'default'/'high'——'auto' 不是合法枚举值,某些后端会 400。
444
+ // 不传 detail 让各 provider 用自己的默认值(OpenAI 默认视为 auto,MiniMax 默认 default),
445
+ // 是唯一在两边都不出错的写法。
446
+ ...imgs.map((a) => ({
447
+ type: 'image_url',
448
+ image_url: { url: a.dataUrl },
449
+ })),
450
+ ];
451
+ const msgIndex = history.length; // runAgent push 后 = 这个 index
452
+ if (imgs.length)
453
+ messageAttachments.set(msgIndex, imgs);
454
+ let ok = false;
455
+ let signal;
456
+ let toolPolicy = inheritedToolPolicy;
457
+ let initialToolRoute;
458
+ try {
459
+ signal = startRunningListener(placeholder);
460
+ // 入口设定本轮初始模式(合成执行轮传 false→auto;用户轮传当前 mode)。
461
+ // setAgentMode 触发 listener 重写 history[0];模式只由用户面切换,runAgent 每步读取当前值。
462
+ setAgentMode(planMode ? 'plan' : 'auto');
463
+ // 仅真实用户轮清除上一轮 inline skill 激活态。plan 审批后的合成执行轮继承
464
+ // 同一 controller 与 skill deny,避免审批边界意外扩权。
465
+ if (!inheritedToolPolicy)
466
+ clearSkillActivation();
467
+ // 每个真实用户 turn 强制由轻量 LLM 选择最小工具簇。plan 审批后的合成执行轮
468
+ // 传 inheritedToolPolicy,复用同一个 controller/version,不做第二次路由。
469
+ if (!toolPolicy) {
470
+ const previousGroups = [...lastToolGroups];
471
+ const decision = await routeToolGroups({
472
+ input,
473
+ previousGroups,
474
+ planMode,
475
+ attachmentNames: imgs.map((image) => image.name),
476
+ signal,
477
+ });
478
+ toolPolicy = new ToolPolicyController({
479
+ groups: decision.groups,
480
+ reason: decision.reason,
481
+ confidence: decision.confidence,
482
+ });
483
+ lastToolGroups = toolPolicy.groupNames;
484
+ initialToolRoute = {
485
+ policyId: toolPolicy.id,
486
+ groups: toolPolicy.groupNames,
487
+ previousGroups,
488
+ inheritPrevious: decision.inheritPrevious,
489
+ confidence: decision.confidence,
490
+ reason: decision.reason,
491
+ latencyMs: decision.latencyMs,
492
+ fallback: decision.fallback,
493
+ planMode,
494
+ };
495
+ }
496
+ // 运行中每步 chat() 返回后刷新状态行 context 用量条(用 fresh lastUsage / 估算),
497
+ // 否则整轮冻结在轮首 refreshStatusBase 的值,「执行 grep」时 2k/1000k 不动。
498
+ const result = await runAgent(history, userInput, signal, () => {
499
+ refreshStatusBase(history);
500
+ layout.drawStatusBar();
501
+ }, toolPolicy, initialToolRoute);
502
+ if (toolPolicy)
503
+ lastToolGroups = toolPolicy.groupNames;
504
+ // 本轮 token 累计(底栏模式 chip 右边显示)。undefined = 后端不开 include_usage。
505
+ // 状态栏统一在 finally 刷新,确保正常、中断、异常都经过同一 plan 收尾路径。
506
+ lastTurnUsage = result.usage;
507
+ ok = !signal.aborted; // 中断(Ctrl+C)→ runAgent 已还原 history,ok=false 不弹审批
508
+ // 成功轮次自动落盘(崩溃也保住上一轮);新会话首轮分配 id
509
+ if (!currentSessionId)
510
+ currentSessionId = newSessionId();
511
+ setCurrentSessionId(currentSessionId, process.cwd()); // 同步到 session/state,确保 notes.md 存在
512
+ try {
513
+ saveSession(history, currentSessionId, queryHistory, lastToolGroups);
514
+ }
515
+ catch {
516
+ // 落盘失败不阻断 REPL
517
+ }
518
+ persistSnapshots(currentSessionId); // 随会话落盘回滚快照(/resume 后仍可撤销)
519
+ // 后台反思:每 reflectEveryN 轮 fire-and-forget 一次(与下一轮 agent 并发,不阻塞)。
520
+ // 已有在飞任务 / autoReflect 关 → kickoff 内部自守卫。快照同步取(避免下一轮 mutate history 竞态)。
521
+ turnCount++;
522
+ if (turnCount % config.reflectEveryN === 0) {
523
+ kickoffReflection(snapshotTranscript(history, 20));
524
+ }
525
+ }
526
+ catch (e) {
527
+ ok = false;
528
+ if (toolPolicy)
529
+ lastToolGroups = toolPolicy.groupNames;
530
+ // 请求失败也保存已确认提交的 query,确保立即退出后仍可通过 ↑ 或 resume 找回。
531
+ if (!currentSessionId)
532
+ currentSessionId = newSessionId();
533
+ setCurrentSessionId(currentSessionId, process.cwd());
534
+ try {
535
+ saveSession(history, currentSessionId, queryHistory, lastToolGroups);
536
+ }
537
+ catch {
538
+ // 落盘失败不覆盖原始请求错误
539
+ }
540
+ // 多模态相关错误友好提示:OpenAI/Anthropic 等会报 "does not support image" / "vision" / "multimodal" 等关键词,
541
+ // 直接给原文对中文用户不友好。这里翻译成中文 + 提示 /model 换视觉模型。
542
+ const msg = e instanceof Error ? e.message : String(e);
543
+ const lower = msg.toLowerCase();
544
+ // 只有明确的「能力不支持」才显示换模型提示。不能仅因错误里出现 image/vision
545
+ // 就下结论:例如 MiniMax 会因 `image_url.detail=auto` 返 400 invalid params,
546
+ // 模型本身仍支持视觉。参数/格式错误应保留原始 provider 诊断,方便准确修复。
547
+ const isImageParameterError = /\b(?:invalid|unsupported)\s+(?:image\s+)?(?:detail|parameter|param|format|url)\b/i.test(lower);
548
+ const isVisionUnsupportedError = !isImageParameterError &&
549
+ (/\b(?:does not support|doesn't support|not supported|unsupported)\b.{0,48}\b(?:image|vision|multimodal|media)\b/i.test(lower) ||
550
+ /\b(?:image|vision|multimodal|media)\b.{0,48}\b(?:is not supported|not supported|unsupported)\b/i.test(lower) ||
551
+ /(?:不支持|不具备).{0,12}(?:视觉|图片|图像|多模态)|(?:视觉|图片|图像|多模态).{0,12}(?:不支持|不可用)/.test(msg));
552
+ if (isVisionUnsupportedError) {
553
+ 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`);
554
+ layout.contentWrite(`${ui.dim}${t('repl.visionHint')}${ui.reset}\n`);
555
+ }
556
+ else {
557
+ // 常见错误翻译:认证 / 限流 / 超时 / 网络 / 上下文超长 → 中文引导(新手第一堵墙);
558
+ // 认不出的错误保留原始 provider 诊断,不瞎猜。
559
+ const kind = classifyChatError(msg);
560
+ if (kind) {
561
+ const key = LLM_ERROR_HINT_KEYS[kind];
562
+ layout.contentWrite(`${ui.red}${t('repl.errorLabel')}${ui.reset} ${t(key, { base: config.baseURL, model: config.model })}\n`);
563
+ layout.contentWrite(`${ui.dim}${t('repl.originalError', { message: msg })}${ui.reset}\n`);
564
+ }
565
+ else {
566
+ layout.contentWrite(`${ui.red}${t('repl.errorLabel')}${ui.reset} ${msg}\n`);
567
+ }
568
+ }
569
+ }
570
+ finally {
571
+ stopRunningListener();
572
+ // 纯 plan 轮正常结束后仍需等待审批/细化,继续展示;其余终态统一结算。
573
+ // 结算只隐藏当前 fingerprint,不修改 notes;后续内容或 mtime 变化会自动重新显示。
574
+ const waitingForPlanApproval = ok && planMode && getAgentMode() === 'plan';
575
+ if (!waitingForPlanApproval)
576
+ settlePlanStatus();
577
+ layout.setLiveUsage(undefined); // 轮末清实时 chip,回 INPUT 态不再显示
578
+ refreshStatusBase(history, lastTurnUsage);
579
+ layout.drawStatusBar();
580
+ }
581
+ layout.contentWrite('\n'); // 轮次之间空行
582
+ return { ok, toolPolicy };
583
+ };
584
+ /** 把 picker 选中的会话加载进 REPL(刷 history + 重建 snapshots + 重画)。/resume / /sessions 共用。 */
585
+ async function resumeFromPick(pick) {
586
+ if (!pick)
587
+ return; // Esc / Ctrl+D 取消
588
+ const loaded = loadSession(pick.id);
589
+ if (!loaded || !loaded.history.length) {
590
+ layout.contentWrite(`${ui.yellow}${t('repl.loadFailed')}${ui.reset}\n`);
591
+ return;
592
+ }
593
+ // Bind before rebuilding the prompt, or it can retain the previous session's notes path.
594
+ currentSessionId = loaded.id;
595
+ setCurrentSessionId(loaded.id, process.cwd());
596
+ if (loaded.history[0]?.role === 'system') {
597
+ loaded.history[0] = { role: 'system', content: buildSystemMessage(false) };
598
+ }
599
+ history.length = 0;
600
+ history.push(...loaded.history);
601
+ queryHistory = loaded.queryHistory ? [...loaded.queryHistory] : queryHistoryFromMessages(loaded.history);
602
+ lastToolGroups = [...(loaded.lastToolGroups ?? [])];
603
+ setAgentMode('auto'); // 续接重置为 auto(mode 不落盘;listener 重写 history[0] 回 auto,与 loaded 幂等)
604
+ // 读回该会话的轮次/快照;无文件则从 history 重建 turns(无快照→旧轮次文件改动不可撤销)
605
+ if (!loadSnapshots(loaded.id))
606
+ rebuildFromHistory(history);
607
+ contextState.lastUsage = undefined;
608
+ contextState.lifecycleStats = undefined;
609
+ contextState.ephemeralText = undefined;
610
+ lastTurnUsage = undefined; // 续接:旧会话的 token 累计已无意义,清空等下轮覆写
611
+ layout.clearContent();
612
+ // 锚点行:会话 id / 消息数 / 当前模型,给续接的会话一个定位起点(与 --resume 启动路径同文案)。
613
+ const anchorCount = history.filter((m) => m.role !== 'system').length;
614
+ layout.contentWrite(`${ui.dim}${t('repl.resumeAnchor', { id: loaded.id, count: anchorCount, model: config.model })}${ui.reset}\n\n`);
615
+ renderHistory(history);
616
+ // 强制回尾:renderHistory 展开 mutation 工具详情会经 contentInsertAfter 设置 scrollOffset>0;
617
+ // 先把"已续接会话"提示写入缓冲,再统一回尾重画,避免切换后视口停留在历史顶部。
618
+ layout.resetScroll();
619
+ layout.repaintViewport();
620
+ }
621
+ let hasSubmittedInput = false;
622
+ while (true) {
623
+ // INPUT 态:画底栏输入框 + 状态行,光标入输入框
624
+ refreshStatusBase(history);
625
+ // 后台反思若已完成(上轮 fire-and-forget),在安全点 flush 一行 dim 摘要:
626
+ // 不在 RUNNING 态写——防与正在跑的 agent 争屏(contentWrite / 状态行)。
627
+ const reflectRes = getLastReflectResult();
628
+ if (reflectRes) {
629
+ layout.contentWrite(` ${ui.gray}↳ ${formatReflectResult(reflectRes)}${ui.reset}\n`);
630
+ clearLastReflectResult();
631
+ }
632
+ // 所有斜杠命令和 Agent 轮次共用同一个输出→输入边界,避免某条命令漏写第二个 \n
633
+ // 后下一条 ❯ 气泡紧贴确认文案;已有多余空行也会收敛为恰好一行。
634
+ if (hasSubmittedInput)
635
+ layout.normalizeInputBoundary();
636
+ layout.enterInputMode(t('repl.idle'));
637
+ let input = null;
638
+ try {
639
+ input = await promptWithSlashMenu({
640
+ prompt: PROMPT,
641
+ commands: buildSlashCommands(),
642
+ onCycleMode: cycleMode,
643
+ // Ctrl+R / Ctrl+P 历史搜索候选源:惰性工厂——当前会话内存 queryHistory + 最近落盘会话,
644
+ // 聚合要读盘,只有面板真正打开(Ctrl+R/P)才求值,平常轮次零开销
645
+ history: () => collectQueryHistory(queryHistory).map((e) => e.text),
646
+ // /rollback 预填优先;否则上一轮运行中 typeahead 打的字 → 预填进输入框,用户可改可发
647
+ ...(pendingPrefill
648
+ ? { initialLines: pendingPrefill }
649
+ : getRunningInput()
650
+ ? { initialLines: [getRunningInput()] }
651
+ : {}),
652
+ });
653
+ }
654
+ catch {
655
+ break; // Ctrl+C(SIGINT)/ 异常 → 退出
656
+ }
657
+ pendingPrefill = null; // 预填已消费,清空
658
+ clearRunningInput(); // 预填已消费,清空(下轮运行态从空开始)
659
+ if (input === null)
660
+ break; // 空 prompt Ctrl+D
661
+ let joined = input.join('\n');
662
+ const line = joined.trim();
663
+ if (!line)
664
+ continue;
665
+ hasSubmittedInput = true;
666
+ if (line === '/exit' || line === '/quit')
667
+ break;
668
+ // 首次提交任何输入(消息或斜杠命令)→ 撤掉欢迎引导块,不让它出现在工作画面里。
669
+ layout.dismissWelcomeBlock();
670
+ // RUNNING 态:回显输入 → 底栏改 dim 占位、光标回内容续写位
671
+ const cmd = line.split(/\s+/)[0];
672
+ // 命令被刻意改写成别的内容后转发给 agent(如 /init)。这类不算"未知命令"。
673
+ let forwardToAgent = false;
674
+ // 语言命令把视觉分隔放在确认文案之后;避免回显后先空一行、下一条命令却紧贴确认。
675
+ echoInput(input, cmd !== '/language');
676
+ const state = runningStateFor(cmd);
677
+ const placeholder = state.placeholder;
678
+ refreshStatusBase(history);
679
+ layout.enterRunningMode(state.status, placeholder);
680
+ // ── 斜杠命令分发(repl/commands/)──
681
+ // dispatchCommand 按 registry 顺序问每个 handler;handler 自判 line 是否归它管,
682
+ // 不归返回 unhandled 继续往下问。全部 unhandled = 没有这条命令,落到下方未知命令兜底。
683
+ // /exit /quit 例外:它们在 dispatch 之前 break(见上),不进 registry。
684
+ {
685
+ const outcome = await dispatchCommand({
686
+ line,
687
+ cmd,
688
+ inputLines: input,
689
+ history,
690
+ contextState,
691
+ // 访问器暴露闭包 `let`:命令会写它们(/clear 换 session id、/resume 换整个会话),传值会丢写回。
692
+ state: {
693
+ get currentSessionId() {
694
+ return currentSessionId;
695
+ },
696
+ set currentSessionId(v) {
697
+ currentSessionId = v;
698
+ },
699
+ get lastTurnUsage() {
700
+ return lastTurnUsage;
701
+ },
702
+ set lastTurnUsage(v) {
703
+ lastTurnUsage = v;
704
+ },
705
+ get turnCount() {
706
+ return turnCount;
707
+ },
708
+ set turnCount(v) {
709
+ turnCount = v;
710
+ },
711
+ get queryHistory() {
712
+ return queryHistory;
713
+ },
714
+ set queryHistory(v) {
715
+ queryHistory = v;
716
+ },
717
+ get lastToolGroups() {
718
+ return lastToolGroups;
719
+ },
720
+ set lastToolGroups(v) {
721
+ lastToolGroups = v;
722
+ },
723
+ },
724
+ attachments: {
725
+ list: () => pendingAttachments,
726
+ clear: () => {
727
+ pendingAttachments = [];
728
+ },
729
+ push: (att) => {
730
+ pendingAttachments.push(att);
731
+ },
732
+ },
733
+ buildSystemMessage,
734
+ banner,
735
+ welcomeLines,
736
+ refreshStatusBase,
737
+ applyMode,
738
+ cycleMode,
739
+ runTurn,
740
+ resumeFromPick,
741
+ rollbackFlow,
742
+ });
743
+ switch (outcome.kind) {
744
+ case 'next':
745
+ continue;
746
+ case 'exit':
747
+ break;
748
+ case 'forward':
749
+ // 改写输入后当普通消息发给 agent(/init)。不 continue:fall through 到发送路径。
750
+ joined = outcome.input;
751
+ forwardToAgent = true; // 不是"没匹配上",别被下方未知命令兜底拦掉
752
+ break;
753
+ case 'unhandled':
754
+ break; // 没有这条命令:落到下方未知命令兜底
755
+ }
756
+ // switch 的 break 只跳出 switch;'exit' 需要跳出 while,用显式二次判断。
757
+ if (outcome.kind === 'exit')
758
+ break;
759
+ }
760
+ // ── 斜杠命令分发到此为止:除 /exit /quit(dispatch 之前 break)外,全部命令已搬到
761
+ // repl/commands/ 下,由上方 dispatchCommand 按 registry 顺序处理。
762
+ // unhandled 落到下面的未知命令兜底。
763
+ // 未知斜杠命令兜底:拼错的 /hepl 会一路走到这里——只拦「长得像命令」的输入
764
+ // (单个前导 / + 词字符),像 /tmp/foo/bar、/src/index.ts:12:5 这类路径
765
+ // (在编码 agent 里很常见,且同样以 / 开头)必须放行,不能当成拼错的命令。
766
+ if (isCommandShape(cmd) && !forwardToAgent) {
767
+ const suggestion = suggestCommand(cmd);
768
+ layout.contentWrite(`${ui.yellow}${t('repl.unknownCommand', { value: cmd })}${ui.reset}\n`);
769
+ if (suggestion) {
770
+ layout.contentWrite(`${ui.dim}${t('repl.didYouMean', { value: suggestion })}${ui.reset}\n`);
771
+ }
772
+ layout.contentWrite(`${ui.dim}${t('repl.unknownCommandHint')}${ui.reset}\n`);
773
+ continue; // 与其他命令一致:回 INPUT 态由循环顶部的 normalizeInputBoundary + enterInputMode 负责
774
+ }
775
+ const bubbleRows = input.length + 2 + pendingAttachments.length; // N 行 message + 2 行尾随空(含 \n\n 留下的 open current 行) + 每附件 1 行
776
+ const shouldCommit = await awaitPendingRecall(input);
777
+ if (!shouldCommit) {
778
+ // 撤回:气泡从内容区擦掉 + 行放回输入框(下轮 promptWithSlashMenu 经 initialLines 消费)+ 切回 INPUT 视觉。
779
+ // pendingAttachments **保留** —— 撤回的是输入文本不是意图,再发时随消息一起带走
780
+ // (runTurn 入口的 pendingAttachments.flush 仍按现有逻辑把附件塞进 userInput)。
781
+ layout.rewindContent(bubbleRows);
782
+ pendingPrefill = input;
783
+ layout.enterInputMode(t('repl.idle'));
784
+ continue;
785
+ }
786
+ // 只记录已过撤回窗口的真实用户 query;slash 命令和合成执行轮不会走到这里。
787
+ queryHistory.push(joined);
788
+ const initialPlan = getAgentMode() === 'plan'; // 轮首模式(在 runTurn 之前读)
789
+ const turn = await runTurn(joined, initialPlan, placeholder);
790
+ // plan 轮正常结束(未中断 / 未抛错)→ 看轮末模式决定:
791
+ // - 仍 plan:模型只产计划就 STOP(模型已无 switch_mode 工具)→ 弹审批面板(原行为)。
792
+ // - 已 auto:本轮被切到 auto 模式(用户用 /auto 触发的合成执行轮)→ 跳过审批,不重复打扰。
793
+ if (initialPlan && turn.ok && getAgentMode() === 'plan') {
794
+ // 桌宠:计划审批面板弹出期间广播 waiting_human(红灯闪烁);面板不在 runAgent/hooks 体系内,
795
+ // 需在此单独广播——用户响应后由下一次 /pet 状态事件(或 idle 兜底)覆盖。
796
+ sendState('waiting_human');
797
+ const executePlanOption = t('plan.execute');
798
+ const res = await promptIntervention({
799
+ type: 'choice',
800
+ title: t('plan.ready'),
801
+ detail: t('plan.approvalDetail'),
802
+ options: [executePlanOption, t('plan.refine')],
803
+ });
804
+ sendState('idle');
805
+ if (res.action === 'selected' && res.value === executePlanOption) {
806
+ // setAgentMode('auto') 由 runTurn 入口做(listener 重写 history[0] 回 auto);这里只切运行态 + 合成执行轮。
807
+ layout.enterRunningMode(t('plan.running'), t('plan.executing'));
808
+ await runTurn(t('plan.executePrompt'), false, t('plan.executing'), turn.toolPolicy);
809
+ }
810
+ }
811
+ }
812
+ // 退出前等在飞反思收尾(Ctrl+C 走 SIGINT 直退不等;fire-and-forget 不承诺中断时完成)。
813
+ await drainMemoryBackground();
814
+ await closeAllMcp();
815
+ layout.exitAltScreen();
816
+ }