mocode-ai 1.3.0 → 1.3.2

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.
package/README.md CHANGED
@@ -82,6 +82,7 @@ MoCode isn't a chat box with a coat of paint — it's an agent that actually get
82
82
  - **Project context (`AGENTS.md`)** — A single project-level memory file at `AGENTS.md` captures both static facts (project description, commands, module list, directory tree) and human/AI-written insights (conventions, architectural decisions, pitfalls). Generate it once with `/init`, then keep it up to date by hand or by asking the agent to refresh it. Loaded automatically into the system prompt on every turn.
83
83
  - **Session notepad (notes.md)** — For complex multi-step tasks (≥3 file changes / ≥5 tool calls), the agent maintains a working notepad at `.mocode/sessions/<sessionId>/notes.md` (file-based, survives context compression). It records the execution plan with the dedicated `plan_update` tool — a three-state step machine (`pending`/`in_progress`/`completed`, at most one `in_progress`) that auto-settles to `## Done:` when finished. The active plan is re-injected into the system prompt after compaction and re-synced into context whenever notes.md changes, and a gentle reminder nudges the agent if it goes several tool-steps without updating the plan. A live progress chip in the TUI status bar shows `plan: [title] (3/7) ▸ [current step]`.
84
84
  - **Interruptible and reversible** — Ctrl+C interrupts the current turn at any time (kills child processes recursively, rolls history back to before the turn started, leaves no half-finished tool calls). `/rollback` restores file changes from per-turn snapshots, with a per-file keep/undo choice — no git dependency required.
85
+ - **Input safety net** — Long prompts no longer fear a stray Enter: `Ctrl+G` opens an in-TUI composer popup (notepad-style editing — Enter inserts a newline, with soft wrap, selection, copy/cut/paste and undo; Ctrl+S fills the text back into the input box without sending). `Ctrl+R`/`Ctrl+P` fuzzy-search your input history (Enter only fills it back), and the post-send recall window widens to 2 seconds with any-key recall for long inputs.
85
86
  - **Sandbox protection** — File reads/writes go through a sandbox that blocks out-of-bounds paths (`../../`, absolute paths outside the root, symlink escapes, etc.), so the agent never touches files outside your working directory.
86
87
 
87
88
  ## Features
package/README.zh-CN.md CHANGED
@@ -81,6 +81,7 @@ mocode 不是一个套壳聊天框,而是一个能真正动手干活的 agent:
81
81
  - **跨会话长期记忆** — agent 能把项目架构、约定、踩过的坑存成长期记忆,下次会话自动加载;后台还会定期从对话里反思挖掘值得记住的事。记忆可增删改、带召回衰减。
82
82
  - **会话记事本(notes.md)** — 复杂多步任务(≥3 处文件改动 / ≥5 步工具调用)时,agent 在 `.mocode/sessions/<sessionId>/notes.md` 维护一个工作记事本(落盘抗压缩),可记录中间发现、设计决策、待验证问题和结构化计划。执行计划由专用 `plan_update` 工具维护——三态步骤机(`pending`/`in_progress`/`completed`,同一时刻至多一个 `in_progress`),全部完成自动结算为 `## Done:`。活跃 plan 在压缩后重注入系统提示、notes.md 一变就重同步进上下文,若连续多步未更新还会有温和提醒。TUI 状态栏实时显示进度 chip:`plan: [标题] (3/7) ▸ [当前步]`。
83
83
  - **可中断、可回滚** — Ctrl+C 随时打断当前轮次(树杀子进程,历史还原到本轮开始前,不留残半的工具调用);`/rollback` 按轮次快照恢复文件改动,逐个文件「保留/撤销」,不依赖 git。
84
+ - **输入安全网** — 长 prompt 不再怕误按 Enter:`Ctrl+G` 弹出 TUI 内「输入面板」(记事本式编辑,Enter=换行、软换行、选区、复制/剪切/粘贴、撤销,Ctrl+S 填回输入框不自动发送);`Ctrl+R`/`Ctrl+P` 模糊搜索历史输入(Enter 只回填不发送);长文本误发后撤回窗口自动放宽到 2 秒且任意键可撤回。
84
85
  - **沙箱防护** — 文件读写经沙箱拦截,挡掉越界路径(`../../`、绝对外圈、软链出圈等),不碰工作目录之外的文件。
85
86
 
86
87
  ## 特性
@@ -55,6 +55,10 @@ const zhCN = {
55
55
  'commands.upgradeStatus': '显示当前本地版本',
56
56
  'help.title': '可用斜杠命令',
57
57
  'help.hint': '(› 表示可进入子菜单;→ 后为实际执行命令)',
58
+ 'help.groupFrequent': '常用',
59
+ 'help.groupSession': '会话与记忆',
60
+ 'help.groupConfig': '配置与外观',
61
+ 'help.groupOther': '其他',
58
62
  'language.current': '当前语言:{language}',
59
63
  'language.changed': '语言已切换为中文。',
60
64
  'language.usage': '用法:/language zh-CN|en',
@@ -102,13 +106,29 @@ const zhCN = {
102
106
  'prompt.revertFiles': '撤销文件改动{detail}',
103
107
  'prompt.revertDetail': '({count} 个文件恢复到回滚前)',
104
108
  'prompt.messagesOnly': '只撤销消息,保留文件改动',
109
+ 'prompt.emptyPlaceholder': '输入消息,按 Enter 开始对话;输入 /help 查看命令…',
110
+ 'prompt.confirmSendHint': '再按一次 Enter 发送 · 其它键继续编辑',
111
+ 'composer.title': '输入面板',
112
+ 'composer.pos': '{row}/{total} 行 · {col} 列 · {chars} 字',
113
+ 'composer.hint': 'Enter 换行 · Ctrl+S 回填 · Esc 取消 · Ctrl+Z 撤销 · Ctrl+C/X/V 复制/剪切/粘贴 · Ctrl+A 全选',
114
+ 'history.hint': '输入过滤 · ↑↓ 选择 · Enter 填入输入框 · Ctrl+Enter 直接发送 · Esc 取消',
115
+ 'history.empty': '(无匹配的历史输入)',
116
+ 'welcome.gettingStarted': '快速上手',
117
+ 'welcome.start1': '直接输入消息,按 Enter 发送;Shift+Tab 切换 auto / plan 模式',
118
+ 'welcome.start2': '输入 / 打开命令菜单;输入 /help 查看全部命令',
119
+ 'welcome.start3': '长文本直接粘贴,会折叠成粘贴块',
120
+ 'welcome.capabilities': '我能做什么',
121
+ 'welcome.cap1': '读懂并修改代码:修 bug、重构、写测试、跨文件检索',
122
+ 'welcome.cap2': '运行命令与脚本,根据输出继续排查',
123
+ 'welcome.cap3': '生成文档与图片;/memory 管理记忆,/skills 管理技能',
124
+ 'welcome.try': '试试:「修复 <某个 bug>」或「解释这个仓库的结构」',
105
125
  'intervention.custom': '其他(自定义输入)',
106
126
  'intervention.defaultChoice': '自动选择默认项',
107
127
  'intervention.emptyInput': '自动返回空输入',
108
128
  'intervention.nonInteractive': '[介入] {title}(非交互环境,{kind})',
109
129
  'repl.idle': '空闲',
110
130
  'repl.loadFailed': '(加载失败)',
111
- 'repl.resumed': '(已续接会话 {id}',
131
+ 'repl.resumeAnchor': '已恢复会话 {id} · {count} 条消息 · 当前模型 {model}(/model switch 可切换)',
112
132
  'repl.noSessions': '(没有已保存的会话)',
113
133
  'repl.noValue': '(无)',
114
134
  'repl.historyCleared': '(历史已清空,保留系统提示)',
@@ -127,7 +147,7 @@ const zhCN = {
127
147
  'repl.didYouMean': '你是不是想输入 {value}?',
128
148
  'repl.unknownCommandHint': '输入 /help 查看全部命令(普通文本直接打字发送,不必加 /)',
129
149
  'theme.default': '16 色原版(深底)',
130
- 'theme.light': '浅底终端',
150
+ 'theme.light': 'Solarized 浅色系',
131
151
  'theme.solarized': 'Solarized 强调色',
132
152
  'theme.gruvbox': 'Gruvbox 暖色',
133
153
  'theme.nord': 'Nord 冷色',
@@ -154,10 +174,11 @@ const zhCN = {
154
174
  'banner.disabled': '关闭',
155
175
  'status.modeSwitch': 'Shift+Tab 切换',
156
176
  'status.history': '历史 ↑{count}(PgDn 回底)',
177
+ 'status.interruptHint': 'Esc / Ctrl+C 中断',
157
178
  'status.measured': '实测',
158
179
  'status.estimated': '估算',
159
180
  'status.messages': '{count} 条消息',
160
- 'agent.sending': '发送中… (Esc / Ctrl+C 撤回)',
181
+ 'agent.sending': '发送中… (任意键 / Esc / Ctrl+C 撤回)',
161
182
  'agent.thinking': '思考中',
162
183
  'agent.generating': '生成 {tool}',
163
184
  'agent.executing': '执行 {tool}',
@@ -191,6 +212,11 @@ const zhCN = {
191
212
  'repl.visionUnsupported': '当前模型 {model} 不支持视觉输入。',
192
213
  'repl.originalError': '原始:{message}',
193
214
  'repl.visionHint': '提示:运行 /model 切换到支持视觉的模型(如 gpt-4o / claude-3.5-sonnet / gemini-1.5-pro)。',
215
+ 'repl.llmAuthError': '认证失败:API key 无效或未配置。输入 /model 配置 baseURL / apiKey,或退出后在 .env、~/.mocode/config 设置 MOCODE_LLM_API_KEY(参考 .env.example)。',
216
+ 'repl.llmQuotaError': '请求被限流或配额不足(429)。稍等片刻重试,或输入 /model switch 切换其他模型。',
217
+ 'repl.llmTimeoutError': '请求超时:模型服务响应过慢。可重试,或输入 /model switch 换一个模型。',
218
+ 'repl.llmNetworkError': '网络错误:无法连接 {base}。检查网络连接,或输入 /model 核对 baseURL 配置。',
219
+ 'repl.llmContextError': '上下文超长:输入 /compact 压缩会话后重试。',
194
220
  'permission.path': '路径: {value}',
195
221
  'permission.command': '命令: {value}',
196
222
  'permission.task': '任务: {value}',
@@ -198,6 +224,7 @@ const zhCN = {
198
224
  'permission.dangerTitle': '⚠ 高风险操作: {tool}',
199
225
  'permission.confirmTitle': '确认执行: {tool}',
200
226
  'permission.dangerWarning': '⚠ 此操作可能产生不可逆副作用,请谨慎确认。',
227
+ 'permission.firstTimeHint': '说明:面板按工具风险等级弹出。「允许」仅放行本次操作;选择会话级/项目级选项后,同类命令不再重复询问。',
201
228
  'permission.confirmExecute': '确认执行',
202
229
  'permission.allowForever': '以后不再询问此工具',
203
230
  'permission.deny': '拒绝',
@@ -318,6 +345,10 @@ const en = {
318
345
  'commands.upgradeStatus': 'Show current local version',
319
346
  'help.title': 'Available slash commands',
320
347
  'help.hint': '(› opens a submenu; → shows the command that will run)',
348
+ 'help.groupFrequent': 'Frequently used',
349
+ 'help.groupSession': 'Session & memory',
350
+ 'help.groupConfig': 'Configuration',
351
+ 'help.groupOther': 'Other',
321
352
  'language.current': 'Current language: {language}',
322
353
  'language.changed': 'Language switched to English.',
323
354
  'language.usage': 'Usage: /language zh-CN|en',
@@ -365,13 +396,29 @@ const en = {
365
396
  'prompt.revertFiles': 'Revert file changes{detail}',
366
397
  'prompt.revertDetail': ' ({count} files restored to before the rollback)',
367
398
  'prompt.messagesOnly': 'Roll back messages only; keep file changes',
399
+ 'prompt.emptyPlaceholder': 'Type a message, press Enter to send; /help for commands…',
400
+ 'prompt.confirmSendHint': 'Press Enter again to send · any other key keeps editing',
401
+ 'composer.title': 'Composer',
402
+ 'composer.pos': '{row}/{total} lines · col {col} · {chars} chars',
403
+ 'composer.hint': 'Enter Newline · Ctrl+S Fill input · Esc Cancel · Ctrl+Z Undo · Ctrl+C/X/V Copy/Cut/Paste · Ctrl+A Select all · Shift+Arrows Select',
404
+ 'history.hint': 'Type to filter · ↑↓ Select · Enter Fill input · Ctrl+Enter Send · Esc Cancel',
405
+ 'history.empty': '(no matching history)',
406
+ 'welcome.gettingStarted': 'Getting started',
407
+ 'welcome.start1': 'Type a message and press Enter; Shift+Tab toggles auto / plan mode',
408
+ 'welcome.start2': 'Type / for the command menu; /help lists all commands',
409
+ 'welcome.start3': 'Paste long text directly — it folds into a paste block',
410
+ 'welcome.capabilities': 'What I can do',
411
+ 'welcome.cap1': 'Read and modify code: fix bugs, refactor, write tests, search across files',
412
+ 'welcome.cap2': 'Run commands and scripts, then keep going based on the output',
413
+ 'welcome.cap3': 'Generate docs and images; /memory manages memory, /skills manages skills',
414
+ 'welcome.try': 'Try: "fix <a bug>" or "explain the structure of this repository"',
368
415
  'intervention.custom': 'Other (custom input)',
369
416
  'intervention.defaultChoice': 'automatically selected the default',
370
417
  'intervention.emptyInput': 'automatically returned empty input',
371
418
  'intervention.nonInteractive': '[intervention] {title} (non-interactive; {kind})',
372
419
  'repl.idle': 'Idle',
373
420
  'repl.loadFailed': '(load failed)',
374
- 'repl.resumed': '(resumed session {id})',
421
+ 'repl.resumeAnchor': 'Resumed session {id} · {count} messages · model {model} (/model switch to change)',
375
422
  'repl.noSessions': '(no saved sessions)',
376
423
  'repl.noValue': '(none)',
377
424
  'repl.historyCleared': '(history cleared; system prompt kept)',
@@ -390,7 +437,7 @@ const en = {
390
437
  'repl.didYouMean': 'Did you mean {value}?',
391
438
  'repl.unknownCommandHint': 'Run /help for all commands (plain text is sent as-is, no slash needed)',
392
439
  'theme.default': 'Original 16-color theme (dark)',
393
- 'theme.light': 'Light terminal',
440
+ 'theme.light': 'Light Solarized palette',
394
441
  'theme.solarized': 'Solarized accents',
395
442
  'theme.gruvbox': 'Warm Gruvbox colors',
396
443
  'theme.nord': 'Cool Nord colors',
@@ -417,10 +464,11 @@ const en = {
417
464
  'banner.disabled': 'Disabled',
418
465
  'status.modeSwitch': 'Shift+Tab Switch',
419
466
  'status.history': 'History ↑{count} (PgDn to bottom)',
467
+ 'status.interruptHint': 'Esc / Ctrl+C to interrupt',
420
468
  'status.measured': 'measured',
421
469
  'status.estimated': 'estimated',
422
470
  'status.messages': '{count} messages',
423
- 'agent.sending': 'Sending… (Esc / Ctrl+C to recall)',
471
+ 'agent.sending': 'Sending… (any key / Esc / Ctrl+C to recall)',
424
472
  'agent.thinking': 'Thinking',
425
473
  'agent.generating': 'Generating {tool}',
426
474
  'agent.executing': 'Running {tool}',
@@ -454,6 +502,11 @@ const en = {
454
502
  'repl.visionUnsupported': 'The current model, {model}, does not support vision input.',
455
503
  'repl.originalError': 'Original: {message}',
456
504
  'repl.visionHint': 'Tip: run /model and switch to a vision-capable model (for example, gpt-4o, claude-3.5-sonnet, or gemini-1.5-pro).',
505
+ 'repl.llmAuthError': 'Authentication failed: the API key is invalid or not configured. Run /model to set baseURL / apiKey, or set MOCODE_LLM_API_KEY in .env or ~/.mocode/config (see .env.example).',
506
+ 'repl.llmQuotaError': 'Rate limited or quota exhausted (429). Retry in a moment, or run /model switch to use another model.',
507
+ 'repl.llmTimeoutError': 'Request timed out: the model service responded too slowly. Retry, or run /model switch to use another model.',
508
+ 'repl.llmNetworkError': 'Network error: cannot reach {base}. Check your connection, or run /model to verify the baseURL.',
509
+ 'repl.llmContextError': 'Context too long: run /compact to compress the session, then retry.',
457
510
  'permission.path': 'Path: {value}',
458
511
  'permission.command': 'Command: {value}',
459
512
  'permission.task': 'Task: {value}',
@@ -461,6 +514,7 @@ const en = {
461
514
  'permission.dangerTitle': '⚠ High-risk operation: {tool}',
462
515
  'permission.confirmTitle': 'Confirm execution: {tool}',
463
516
  'permission.dangerWarning': '⚠ This operation may have irreversible side effects. Confirm carefully.',
517
+ 'permission.firstTimeHint': "Note: this panel appears based on each tool's risk level. 'Allow' approves only this action; session/project options stop repeated prompts for the same command.",
464
518
  'permission.confirmExecute': 'Confirm and run',
465
519
  'permission.allowForever': 'Always allow this tool',
466
520
  'permission.deny': 'Deny',
package/dist/llm/index.js CHANGED
@@ -144,6 +144,32 @@ export function isContextLengthError(err) {
144
144
  /(input|prompt|context|request).{0,20}too (long|large)|exceeds? the (model|maximum|context)/.test(msg) ||
145
145
  /reduce the length|上下文(长度)?(超|过)|超出.*上下文|请求过长|长度超过|token 数?超过/.test(msg));
146
146
  }
147
+ export function classifyChatError(msg) {
148
+ const m = (msg || '').toLowerCase();
149
+ if (!m)
150
+ return null;
151
+ // 上下文超长(复用 transport 层判定,提示 /compact)
152
+ if (isContextLengthError({ message: msg }))
153
+ return 'context';
154
+ // 认证:401 / key 无效缺失
155
+ if (/\b401\b|unauthorized|forbidden[^\n]*(?:api[ _-]?key|token)/.test(m) ||
156
+ /(?:invalid|incorrect|missing|no|not[ _-]?(?:provided|configured|valid))[^\n]{0,24}api[ _-]?key/.test(m) ||
157
+ /api[ _-]?key[^\n]{0,24}(?:invalid|incorrect|missing|not[ _-]?(?:provided|configured|valid))/.test(m) ||
158
+ /无效的|未配置.{0,8}(?:key|密钥)|(?:密钥|令牌).{0,8}(?:无效|错误)/.test(msg))
159
+ return 'auth';
160
+ // 限流 / 配额:429 / rate limit / quota / 余额不足
161
+ if (/\b429\b|rate[ _-]?limit|insufficient[ _-]?(?:quota|balance|funds)|quota[ _-]?(?:exceeded|exhausted)/.test(m) ||
162
+ /频率限制|使用量已超出|余额不足|配额(?:不足|已用完|超)/.test(msg))
163
+ return 'quota';
164
+ // 超时 / 连接中断
165
+ if (/\btime(?:d|ed)?[ _-]?out\b|etimedout|econnreset|socket hang up|econnaborted|请求超时/.test(m))
166
+ return 'timeout';
167
+ // 网络 / DNS / baseURL 不通
168
+ if (/\benotfound\b|\beconnrefused\b|\beai_again\b|getaddrinfo|fetch failed|network error|certificate/.test(m) ||
169
+ /无法连接|网络(?:错误|异常|不可用)|域名解析/.test(msg))
170
+ return 'network';
171
+ return null;
172
+ }
147
173
  /** 从 OpenAI APIError.headers 解析 Retry-After(秒);不支持或缺失返回 undefined。封顶 RETRY_MAX_MS。 */
148
174
  export function getRetryAfterMs(err) {
149
175
  const headers = err?.headers;
@@ -4,6 +4,7 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { promptIntervention } from '../ui/intervention.js';
6
6
  import { config } from '../config/index.js';
7
+ import { readConfigFile, updateConfigKey } from '../config/file.js';
7
8
  import { getSandboxRoot } from '../sandbox/index.js';
8
9
  import { t } from '../i18n/index.js';
9
10
  const PERMISSIONS_PATH = path.join(os.homedir(), '.mocode', 'permissions.json');
@@ -138,12 +139,25 @@ export async function checkPermission(tool, args, signal, options = {}) {
138
139
  const denyOption = t('permission.deny');
139
140
  const dangerous = getToolRisk(tool) === 'dangerous';
140
141
  const choices = [onceOption, sessionOption, projectOption, alwaysOption, denyOption];
142
+ // 新手首次审批引导:第一次弹出确认面板时附一段说明(每个用户只出现一次,
143
+ // ~/.mocode/config 的 onboarding_approval_hint 标记;读写失败静默,不阻断审批)。
144
+ let detail = summarizeArgs(args) + (dangerous ? `\n\n${t('permission.dangerWarning')}` : '');
145
+ const ONBOARDING_KEY = 'onboarding_approval_hint';
146
+ try {
147
+ if (!readConfigFile()[ONBOARDING_KEY]) {
148
+ detail += `\n\n${t('permission.firstTimeHint')}`;
149
+ updateConfigKey(ONBOARDING_KEY, '1');
150
+ }
151
+ }
152
+ catch {
153
+ // 标记读/写失败 → 视为未展示过:多提示一次无害,少提示也只是回归现状。
154
+ }
141
155
  const result = await (options.prompt ?? promptIntervention)({
142
156
  type: 'choice',
143
157
  title: dangerous
144
158
  ? t('permission.dangerTitle', { tool: tool.name })
145
159
  : t('permission.confirmTitle', { tool: tool.name }),
146
- detail: summarizeArgs(args) + (dangerous ? `\n\n${t('permission.dangerWarning')}` : ''),
160
+ detail,
147
161
  options: choices,
148
162
  allowCustom: false,
149
163
  // dangerous 默认落在「拒绝」:面板一弹出就高亮"允许一次"时,用户顺手回车就把命令放出去了。
@@ -10,7 +10,7 @@ import { runAgent } from '../agent/index.js';
10
10
  import { getAgentMode, setAgentMode, onModeChange } from '../agent/mode.js';
11
11
  import { togglePet, killPetProcess, listSkins, setSkin, sendState } from '../pet/bridge.js';
12
12
  import { setSandboxRoot } from '../sandbox/root.js';
13
- import { ui, setTheme, getTheme, listThemes, themeExists, applyTerminalBackground } from '../ui/theme.js';
13
+ import { ui, setTheme, getTheme, listThemes, themeExists } from '../ui/theme.js';
14
14
  import { bannerLines, displayWidth, padEndDisplay, summarizeToolCall, summarizeToolResult } from '../ui/render.js';
15
15
  import * as layout from '../ui/layout.js';
16
16
  import * as mouse from '../ui/mouse.js';
@@ -19,7 +19,7 @@ import { promptWithSlashMenu, promptTurnPicker, promptSessionPicker, promptTheme
19
19
  import { promptIntervention } from '../ui/intervention.js';
20
20
  import { registerToolsExtension } from '../tools/registry.js';
21
21
  import { initializeAllMcp, getMcpTools, closeAllMcp } from '../mcp/index.js';
22
- import { estimateMessagesTokens, reconfigureClient, refreshChatTools, chatTools, estimatePromptTokens, estimateTokens, } from '../llm/index.js';
22
+ import { estimateMessagesTokens, reconfigureClient, refreshChatTools, chatTools, estimatePromptTokens, estimateTokens, classifyChatError, } from '../llm/index.js';
23
23
  import { loadImageAttachment, renderChip, MAX_INLINE_BYTES_DEFAULT, } from '../attachments/image.js';
24
24
  import { modelSupportsVision } from '../llm/capabilities.js';
25
25
  import { computePruneStats } from '../context/relevance.js';
@@ -35,6 +35,7 @@ import fs from 'node:fs';
35
35
  import path from 'node:path';
36
36
  import { getSandboxRoot } from '../sandbox/root.js';
37
37
  import { setCurrentSessionId, getCurrentSessionId } from '../session/state.js';
38
+ import { collectQueryHistory } from '../session/query-history.js';
38
39
  import { checkVersion, fetchLatestVersion, getCurrentVersion, runUpgradeForeground, } from '../commands/upgrade.js';
39
40
  /**
40
41
  * readline 的 prompt 必须是纯文本(无 ANSI):readline 按字符数算光标位置,
@@ -144,6 +145,20 @@ function slashHelpLines(nodes = buildSlashCommands(), parentPath = '', depth = 0
144
145
  }
145
146
  return lines;
146
147
  }
148
+ /** chat 常见错误类别 → 中文引导文案的 i18n 键(识别不出的错误不走这张表,保留原始诊断)。 */
149
+ const LLM_ERROR_HINT_KEYS = {
150
+ auth: 'repl.llmAuthError',
151
+ quota: 'repl.llmQuotaError',
152
+ timeout: 'repl.llmTimeoutError',
153
+ network: 'repl.llmNetworkError',
154
+ context: 'repl.llmContextError',
155
+ };
156
+ /** /help 的分组:按使用场景归组,组内保持菜单树顺序。未列入的顶层命令兜底进「其他」。 */
157
+ const HELP_GROUPS = [
158
+ { key: 'help.groupFrequent', names: ['/help', '/clear', '/context', '/compact', '/resume', '/model'] },
159
+ { key: 'help.groupSession', names: ['/sessions', '/rollback', '/memory', '/skills', '/skill', '/init'] },
160
+ { key: 'help.groupConfig', names: ['/mode', '/subagent', '/fe', '/theme', '/pet', '/image', '/language', '/upgrade'] },
161
+ ];
147
162
  /**
148
163
  * 菜单树里全部可用命令名(含分支路径与叶子的真实命令),供未知命令纠错建议用。
149
164
  * /quit、/frontend 确实可用但没登记进菜单树,单独补上——否则用户照提示打了却仍被判未知。
@@ -443,7 +458,21 @@ let pendingPrefill = null; // /rollback 选中后预填的 user 输入(下轮 IN
443
458
  // 500ms 内 Ctrl+C / Esc → 整条用户气泡从内容区擦掉 + 原行 prefilled 回输入框(可改可再发);
444
459
  // 期间再按 Enter 立即推进 / 时间到自然推进 → 走原流程 enterRunningMode + runTurn。
445
460
  // attachmentsCount 记 pendingAttachments 当时长度——撤回时 attachments 保留(用户意图未变,只是改字)。
461
+ // 短单行:500ms(不拖慢日常)。长文本:MOCODE_RECALL_MS,默认 2000ms —— 人发现"误发了"通常要 1-3 秒,
462
+ // 500ms 根本来不及反应,只能事后 /rollback。
446
463
  const PENDING_RECALL_MS = 500;
464
+ const PENDING_RECALL_LONG_MS = 2000;
465
+ /** 长输入阈值:与 prompt.ts 的二次确认阈值保持一致(≥2 行 或 ≥120 码点)。 */
466
+ const LONG_INPUT_CHARS = 120;
467
+ /** 按输入长度决定撤回窗口:长 prompt 给更宽的补救时间,短单行仍走 500ms。 */
468
+ function recallWindowMs(input) {
469
+ const joined = input.join('\n');
470
+ const long = input.length >= 2 || [...joined].length >= LONG_INPUT_CHARS;
471
+ if (!long)
472
+ return PENDING_RECALL_MS;
473
+ const env = Number(process.env.MOCODE_RECALL_MS);
474
+ return Number.isFinite(env) && env >= 0 ? env : PENDING_RECALL_LONG_MS;
475
+ }
447
476
  let pendingRecall = null;
448
477
  let pendingTimer = null;
449
478
  // agent 模式状态已提到 src/agent/mode.ts(共享叶子:switch_mode 工具可写、agent 每步读、repl 注册 onModeChange 监听器)。
@@ -674,15 +703,19 @@ function awaitPendingRecall(input, attachmentsCount, placeholder) {
674
703
  finalize(false);
675
704
  return;
676
705
  }
677
- // 立即 commit
706
+ // 确认立即发送:Enter / Return
678
707
  if (key.name === 'enter' || key.name === 'return') {
679
708
  finalize(true);
680
709
  return;
681
710
  }
682
- // 其他键忽略
711
+ // 其它任何键 → 撤回。
712
+ // 旧实现把非 Enter 键"忽略"掉:误按 Enter 后人的第一反应是狂敲键盘 / 按空格 / 按退格,
713
+ // 这一串输入被静默丢弃,白白错过补救窗口。撤回窗口期间输入框尚未接管键盘,
714
+ // 这些键本来也不会进输入框,吞掉它们没有任何收益 —— 全部当作撤回信号更有用。
715
+ finalize(false);
683
716
  };
684
717
  emitter.on('keypress', onPendingKey);
685
- pendingTimer = setTimeout(() => finalize(true), PENDING_RECALL_MS);
718
+ pendingTimer = setTimeout(() => finalize(true), recallWindowMs(input));
686
719
  pendingTimer.unref?.();
687
720
  });
688
721
  }
@@ -882,6 +915,26 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
882
915
  tools: chatTools.map((tool) => tool.function.name).join(' · '),
883
916
  memoryEnabled: isMemoryEnabled(),
884
917
  });
918
+ /**
919
+ * 欢迎引导块:新会话开场写在内容区(banner 之下),教用户怎么开始 / 能做什么。
920
+ * 首次提交任何输入(消息或斜杠命令)前由 layout.dismissWelcomeBlock 整块撤掉——
921
+ * 「一打开就能看见,开始干活就消失」。/clear 清空后重新写一次(回到空会话状态)。
922
+ */
923
+ const welcomeLines = () => [
924
+ '',
925
+ ` ${ui.accent}${ui.bold}${t('welcome.gettingStarted')}${ui.reset}`,
926
+ ` ${ui.dim}· ${t('welcome.start1')}${ui.reset}`,
927
+ ` ${ui.dim}· ${t('welcome.start2')}${ui.reset}`,
928
+ ` ${ui.dim}· ${t('welcome.start3')}${ui.reset}`,
929
+ '',
930
+ ` ${ui.accent}${ui.bold}${t('welcome.capabilities')}${ui.reset}`,
931
+ ` ${ui.dim}· ${t('welcome.cap1')}${ui.reset}`,
932
+ ` ${ui.dim}· ${t('welcome.cap2')}${ui.reset}`,
933
+ ` ${ui.dim}· ${t('welcome.cap3')}${ui.reset}`,
934
+ '',
935
+ ` ${ui.dim}${t('welcome.try')}${ui.reset}`,
936
+ '',
937
+ ];
885
938
  // 开场:按 config.theme 切主题(横幅 / 状态行 / 后续渲染皆用新色),再进 alt screen + 状态基线 + 清内容区。
886
939
  // --resume 有历史则渲染对话,否则横幅。
887
940
  setTheme(config.theme);
@@ -890,6 +943,10 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
890
943
  layout.clearContent();
891
944
  layout.contentMode();
892
945
  if (history.some((m) => m.role === 'user')) {
946
+ // --resume 锚点:先给一行会话摘要(id/消息数/当前模型),新手对恢复状态有定位感,
947
+ // 再铺历史对话。renderHistory 之后不再追加,锚点恒为恢复内容的第一行。
948
+ const anchorCount = history.filter((m) => m.role !== 'system').length;
949
+ layout.contentWrite(`${ui.dim}${t('repl.resumeAnchor', { id: currentSessionId, count: anchorCount, model: config.model })}${ui.reset}\n`);
893
950
  renderHistory(history);
894
951
  // 强制回尾:同 /resume 命令,renderHistory 展开详情会设 scrollOffset>0,需复位避免闪烁。
895
952
  layout.resetScroll();
@@ -930,6 +987,10 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
930
987
  }
931
988
  }
932
989
  }
990
+ // 新会话开场:banner 之后写欢迎引导块(--resume 有历史时不写,历史本身就是上下文)。
991
+ if (!history.some((m) => m.role === 'user')) {
992
+ layout.writeWelcomeBlock(welcomeLines());
993
+ }
933
994
  /**
934
995
  * 切换 agent 模式(Shift+Tab 触发,经 prompt.ts 的 onCycleMode 回调)。
935
996
  * cycleMode 翻 agentMode + applyMode 重写 history[0] + refreshStatusBase 设状态行 modeTag;
@@ -939,7 +1000,10 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
939
1000
  history[0] = { role: 'system', content: buildSystemMessage(planMode) };
940
1001
  };
941
1002
  const cycleMode = () => {
942
- setAgentMode(getAgentMode() === 'plan' ? 'auto' : 'plan'); // listener 接手 applyMode + refreshStatusBase
1003
+ const prev = getAgentMode();
1004
+ setAgentMode(prev === 'plan' ? 'auto' : 'plan'); // listener 接手 applyMode + refreshStatusBase
1005
+ // Shift+Tab 只有 chip 变化,新手可能没感知:内容区补一行确认(与 /plan / /auto 同文案,复用不新造键)。
1006
+ layout.contentWrite(`${ui.dim}${t(prev === 'plan' ? 'repl.autoChanged' : 'repl.planChanged')}${ui.reset}\n`);
943
1007
  };
944
1008
  // 注册模式变更监听器:switch_mode 工具 / cycleMode / /plan / /auto / runTurn 调 setAgentMode 时同步触发,
945
1009
  // 重写 history[0] 系统提示(切 plan 追加 PLAN_MODE_SUFFIX)+ 刷状态行 modeTag。
@@ -1142,7 +1206,17 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1142
1206
  layout.contentWrite(`${ui.dim}${t('repl.visionHint')}${ui.reset}\n`);
1143
1207
  }
1144
1208
  else {
1145
- layout.contentWrite(`${ui.red}${t('repl.errorLabel')}${ui.reset} ${msg}\n`);
1209
+ // 常见错误翻译:认证 / 限流 / 超时 / 网络 / 上下文超长 → 中文引导(新手第一堵墙);
1210
+ // 认不出的错误保留原始 provider 诊断,不瞎猜。
1211
+ const kind = classifyChatError(msg);
1212
+ if (kind) {
1213
+ const key = LLM_ERROR_HINT_KEYS[kind];
1214
+ layout.contentWrite(`${ui.red}${t('repl.errorLabel')}${ui.reset} ${t(key, { base: config.baseURL, model: config.model })}\n`);
1215
+ layout.contentWrite(`${ui.dim}${t('repl.originalError', { message: msg })}${ui.reset}\n`);
1216
+ }
1217
+ else {
1218
+ layout.contentWrite(`${ui.red}${t('repl.errorLabel')}${ui.reset} ${msg}\n`);
1219
+ }
1146
1220
  }
1147
1221
  }
1148
1222
  finally {
@@ -1188,9 +1262,10 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1188
1262
  contextState.ephemeralText = undefined;
1189
1263
  lastTurnUsage = undefined; // 续接:旧会话的 token 累计已无意义,清空等下轮覆写
1190
1264
  layout.clearContent();
1265
+ // 锚点行:会话 id / 消息数 / 当前模型,给续接的会话一个定位起点(与 --resume 启动路径同文案)。
1266
+ const anchorCount = history.filter((m) => m.role !== 'system').length;
1267
+ layout.contentWrite(`${ui.dim}${t('repl.resumeAnchor', { id: loaded.id, count: anchorCount, model: config.model })}${ui.reset}\n\n`);
1191
1268
  renderHistory(history);
1192
- // 末尾 \n\n:与后续用户消息(❯ bubble)之间空一行。
1193
- layout.contentWrite(`${ui.dim}${t('repl.resumed', { id: loaded.id })}${ui.reset}\n\n`);
1194
1269
  // 强制回尾:renderHistory 展开 mutation 工具详情会经 contentInsertAfter 设置 scrollOffset>0;
1195
1270
  // 先把"已续接会话"提示写入缓冲,再统一回尾重画,避免切换后视口停留在历史顶部。
1196
1271
  layout.resetScroll();
@@ -1218,6 +1293,9 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1218
1293
  prompt: PROMPT,
1219
1294
  commands: buildSlashCommands(),
1220
1295
  onCycleMode: cycleMode,
1296
+ // Ctrl+R / Ctrl+P 历史搜索候选源:惰性工厂——当前会话内存 queryHistory + 最近落盘会话,
1297
+ // 聚合要读盘,只有面板真正打开(Ctrl+R/P)才求值,平常轮次零开销
1298
+ history: () => collectQueryHistory(queryHistory).map((e) => e.text),
1221
1299
  // /rollback 预填优先;否则上一轮运行中 typeahead 打的字 → 预填进输入框,用户可改可发
1222
1300
  ...(pendingPrefill
1223
1301
  ? { initialLines: pendingPrefill }
@@ -1240,6 +1318,8 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1240
1318
  hasSubmittedInput = true;
1241
1319
  if (line === '/exit' || line === '/quit')
1242
1320
  break;
1321
+ // 首次提交任何输入(消息或斜杠命令)→ 撤掉欢迎引导块,不让它出现在工作画面里。
1322
+ layout.dismissWelcomeBlock();
1243
1323
  // RUNNING 态:回显输入 → 底栏改 dim 占位、光标回内容续写位
1244
1324
  const cmd = line.split(/\s+/)[0];
1245
1325
  // 命令被刻意改写成别的内容后转发给 agent(如 /init)。这类不算"未知命令"。
@@ -1251,9 +1331,28 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1251
1331
  refreshStatusBase(history);
1252
1332
  layout.enterRunningMode(state.status, placeholder);
1253
1333
  if (line === '/help') {
1334
+ const nodes = buildSlashCommands();
1254
1335
  layout.contentWrite(`${ui.bold}${t('help.title')}${ui.reset}\n`);
1255
1336
  layout.contentWrite(`${ui.dim}${t('help.hint')}${ui.reset}\n`);
1256
- layout.contentWrite(`${slashHelpLines().join('\n')}\n`);
1337
+ // 按使用场景分组输出,常用置顶;未归组命令(如 /exit)兜底进「其他」。
1338
+ let first = true;
1339
+ for (const group of HELP_GROUPS) {
1340
+ const picked = nodes.filter((n) => group.names.includes(n.name));
1341
+ if (!picked.length)
1342
+ continue;
1343
+ if (!first)
1344
+ layout.contentWrite('\n');
1345
+ first = false;
1346
+ layout.contentWrite(`${ui.accent}${ui.bold}${t(group.key)}${ui.reset}\n`);
1347
+ layout.contentWrite(`${slashHelpLines(picked).join('\n')}\n`);
1348
+ }
1349
+ const rest = nodes.filter((n) => !HELP_GROUPS.some((g) => g.names.includes(n.name)));
1350
+ if (rest.length) {
1351
+ if (!first)
1352
+ layout.contentWrite('\n');
1353
+ layout.contentWrite(`${ui.accent}${ui.bold}${t('help.groupOther')}${ui.reset}\n`);
1354
+ layout.contentWrite(`${slashHelpLines(rest).join('\n')}\n`);
1355
+ }
1257
1356
  continue;
1258
1357
  }
1259
1358
  if (line === '/language' || line.startsWith('/language ')) {
@@ -1457,6 +1556,7 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1457
1556
  layout.clearContent();
1458
1557
  layout.writeBanner(bannerLines(banner()));
1459
1558
  layout.contentWrite(`${ui.dim}${t('repl.historyCleared')}${ui.reset}\n`);
1559
+ layout.writeWelcomeBlock(welcomeLines()); // 回到空会话状态,欢迎引导重新出现
1460
1560
  continue;
1461
1561
  }
1462
1562
  // /image:附加本地图片到下一条 user 消息(支持 /image <path> · /image list · /image clear)。
@@ -1802,7 +1902,6 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1802
1902
  // 切:setTheme → 重算状态行(新色)→ 清内容重绘(历史 / 横幅,镜像启动 + /resume)→ 确认 → 持久化。
1803
1903
  // markdown MEMO 按 themeVersion 自动失效,故 renderHistory 取新色;状态栏 / 输入框由 continue 回 INPUT 态时读 getter 刷。
1804
1904
  setTheme(name);
1805
- applyTerminalBackground();
1806
1905
  refreshStatusBase(history);
1807
1906
  layout.clearContent();
1808
1907
  if (history.some((m) => m.role === 'user')) {
@@ -0,0 +1,45 @@
1
+ import { listSessions, loadSession } from './persist.js';
2
+ /** 当前会话条目的 sessionId 标记。 */
3
+ export const CURRENT_SESSION = 'current';
4
+ export function collectQueryHistory(current, opts = {}) {
5
+ const maxSessions = Math.max(0, opts.maxSessions ?? 20);
6
+ const maxEntries = Math.max(1, opts.maxEntries ?? 500);
7
+ const seen = new Set();
8
+ const out = [];
9
+ const push = (raw, sessionId, at) => {
10
+ if (out.length >= maxEntries)
11
+ return;
12
+ const text = raw.trim();
13
+ if (!text || seen.has(text))
14
+ return;
15
+ seen.add(text);
16
+ out.push({ text, sessionId, at });
17
+ };
18
+ // 当前会话:数组末尾最新 → 倒序,让"刚发的"排最前。
19
+ for (let i = current.length - 1; i >= 0; i--)
20
+ push(current[i] ?? '', CURRENT_SESSION, '');
21
+ if (out.length < maxEntries && maxSessions > 0) {
22
+ // listSessions 已按 createdAt 降序(最新在前);损坏文件内部跳过。
23
+ for (const meta of listSessions(maxSessions)) {
24
+ let rec;
25
+ try {
26
+ rec = loadSession(meta.id);
27
+ }
28
+ catch {
29
+ continue;
30
+ }
31
+ const qh = rec?.queryHistory;
32
+ if (!qh?.length)
33
+ continue;
34
+ for (let i = qh.length - 1; i >= 0; i--)
35
+ push(qh[i] ?? '', meta.id, meta.createdAt ?? '');
36
+ if (out.length >= maxEntries)
37
+ break;
38
+ }
39
+ }
40
+ return out;
41
+ }
42
+ /** 取展示用首行(多行 query 折叠成一行;换行符显示为 ␊)。 */
43
+ export function historyFirstLine(text) {
44
+ return text.split('\n')[0] ?? '';
45
+ }