mocode-ai 1.3.0 → 1.3.1

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.
@@ -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,23 @@ const zhCN = {
102
106
  'prompt.revertFiles': '撤销文件改动{detail}',
103
107
  'prompt.revertDetail': '({count} 个文件恢复到回滚前)',
104
108
  'prompt.messagesOnly': '只撤销消息,保留文件改动',
109
+ 'prompt.emptyPlaceholder': '输入消息,按 Enter 开始对话;输入 /help 查看命令…',
110
+ 'welcome.gettingStarted': '快速上手',
111
+ 'welcome.start1': '直接输入消息,按 Enter 发送;Shift+Tab 切换 auto / plan 模式',
112
+ 'welcome.start2': '输入 / 打开命令菜单;输入 /help 查看全部命令',
113
+ 'welcome.start3': '长文本直接粘贴,会折叠成粘贴块',
114
+ 'welcome.capabilities': '我能做什么',
115
+ 'welcome.cap1': '读懂并修改代码:修 bug、重构、写测试、跨文件检索',
116
+ 'welcome.cap2': '运行命令与脚本,根据输出继续排查',
117
+ 'welcome.cap3': '生成文档与图片;/memory 管理记忆,/skills 管理技能',
118
+ 'welcome.try': '试试:「修复 <某个 bug>」或「解释这个仓库的结构」',
105
119
  'intervention.custom': '其他(自定义输入)',
106
120
  'intervention.defaultChoice': '自动选择默认项',
107
121
  'intervention.emptyInput': '自动返回空输入',
108
122
  'intervention.nonInteractive': '[介入] {title}(非交互环境,{kind})',
109
123
  'repl.idle': '空闲',
110
124
  'repl.loadFailed': '(加载失败)',
111
- 'repl.resumed': '(已续接会话 {id}',
125
+ 'repl.resumeAnchor': '已恢复会话 {id} · {count} 条消息 · 当前模型 {model}(/model switch 可切换)',
112
126
  'repl.noSessions': '(没有已保存的会话)',
113
127
  'repl.noValue': '(无)',
114
128
  'repl.historyCleared': '(历史已清空,保留系统提示)',
@@ -154,6 +168,7 @@ const zhCN = {
154
168
  'banner.disabled': '关闭',
155
169
  'status.modeSwitch': 'Shift+Tab 切换',
156
170
  'status.history': '历史 ↑{count}(PgDn 回底)',
171
+ 'status.interruptHint': 'Esc / Ctrl+C 中断',
157
172
  'status.measured': '实测',
158
173
  'status.estimated': '估算',
159
174
  'status.messages': '{count} 条消息',
@@ -191,6 +206,11 @@ const zhCN = {
191
206
  'repl.visionUnsupported': '当前模型 {model} 不支持视觉输入。',
192
207
  'repl.originalError': '原始:{message}',
193
208
  'repl.visionHint': '提示:运行 /model 切换到支持视觉的模型(如 gpt-4o / claude-3.5-sonnet / gemini-1.5-pro)。',
209
+ 'repl.llmAuthError': '认证失败:API key 无效或未配置。输入 /model 配置 baseURL / apiKey,或退出后在 .env、~/.mocode/config 设置 MOCODE_LLM_API_KEY(参考 .env.example)。',
210
+ 'repl.llmQuotaError': '请求被限流或配额不足(429)。稍等片刻重试,或输入 /model switch 切换其他模型。',
211
+ 'repl.llmTimeoutError': '请求超时:模型服务响应过慢。可重试,或输入 /model switch 换一个模型。',
212
+ 'repl.llmNetworkError': '网络错误:无法连接 {base}。检查网络连接,或输入 /model 核对 baseURL 配置。',
213
+ 'repl.llmContextError': '上下文超长:输入 /compact 压缩会话后重试。',
194
214
  'permission.path': '路径: {value}',
195
215
  'permission.command': '命令: {value}',
196
216
  'permission.task': '任务: {value}',
@@ -198,6 +218,7 @@ const zhCN = {
198
218
  'permission.dangerTitle': '⚠ 高风险操作: {tool}',
199
219
  'permission.confirmTitle': '确认执行: {tool}',
200
220
  'permission.dangerWarning': '⚠ 此操作可能产生不可逆副作用,请谨慎确认。',
221
+ 'permission.firstTimeHint': '说明:面板按工具风险等级弹出。「允许」仅放行本次操作;选择会话级/项目级选项后,同类命令不再重复询问。',
201
222
  'permission.confirmExecute': '确认执行',
202
223
  'permission.allowForever': '以后不再询问此工具',
203
224
  'permission.deny': '拒绝',
@@ -318,6 +339,10 @@ const en = {
318
339
  'commands.upgradeStatus': 'Show current local version',
319
340
  'help.title': 'Available slash commands',
320
341
  'help.hint': '(› opens a submenu; → shows the command that will run)',
342
+ 'help.groupFrequent': 'Frequently used',
343
+ 'help.groupSession': 'Session & memory',
344
+ 'help.groupConfig': 'Configuration',
345
+ 'help.groupOther': 'Other',
321
346
  'language.current': 'Current language: {language}',
322
347
  'language.changed': 'Language switched to English.',
323
348
  'language.usage': 'Usage: /language zh-CN|en',
@@ -365,13 +390,23 @@ const en = {
365
390
  'prompt.revertFiles': 'Revert file changes{detail}',
366
391
  'prompt.revertDetail': ' ({count} files restored to before the rollback)',
367
392
  'prompt.messagesOnly': 'Roll back messages only; keep file changes',
393
+ 'prompt.emptyPlaceholder': 'Type a message, press Enter to send; /help for commands…',
394
+ 'welcome.gettingStarted': 'Getting started',
395
+ 'welcome.start1': 'Type a message and press Enter; Shift+Tab toggles auto / plan mode',
396
+ 'welcome.start2': 'Type / for the command menu; /help lists all commands',
397
+ 'welcome.start3': 'Paste long text directly — it folds into a paste block',
398
+ 'welcome.capabilities': 'What I can do',
399
+ 'welcome.cap1': 'Read and modify code: fix bugs, refactor, write tests, search across files',
400
+ 'welcome.cap2': 'Run commands and scripts, then keep going based on the output',
401
+ 'welcome.cap3': 'Generate docs and images; /memory manages memory, /skills manages skills',
402
+ 'welcome.try': 'Try: "fix <a bug>" or "explain the structure of this repository"',
368
403
  'intervention.custom': 'Other (custom input)',
369
404
  'intervention.defaultChoice': 'automatically selected the default',
370
405
  'intervention.emptyInput': 'automatically returned empty input',
371
406
  'intervention.nonInteractive': '[intervention] {title} (non-interactive; {kind})',
372
407
  'repl.idle': 'Idle',
373
408
  'repl.loadFailed': '(load failed)',
374
- 'repl.resumed': '(resumed session {id})',
409
+ 'repl.resumeAnchor': 'Resumed session {id} · {count} messages · model {model} (/model switch to change)',
375
410
  'repl.noSessions': '(no saved sessions)',
376
411
  'repl.noValue': '(none)',
377
412
  'repl.historyCleared': '(history cleared; system prompt kept)',
@@ -417,6 +452,7 @@ const en = {
417
452
  'banner.disabled': 'Disabled',
418
453
  'status.modeSwitch': 'Shift+Tab Switch',
419
454
  'status.history': 'History ↑{count} (PgDn to bottom)',
455
+ 'status.interruptHint': 'Esc / Ctrl+C to interrupt',
420
456
  'status.measured': 'measured',
421
457
  'status.estimated': 'estimated',
422
458
  'status.messages': '{count} messages',
@@ -454,6 +490,11 @@ const en = {
454
490
  'repl.visionUnsupported': 'The current model, {model}, does not support vision input.',
455
491
  'repl.originalError': 'Original: {message}',
456
492
  '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).',
493
+ '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).',
494
+ 'repl.llmQuotaError': 'Rate limited or quota exhausted (429). Retry in a moment, or run /model switch to use another model.',
495
+ 'repl.llmTimeoutError': 'Request timed out: the model service responded too slowly. Retry, or run /model switch to use another model.',
496
+ 'repl.llmNetworkError': 'Network error: cannot reach {base}. Check your connection, or run /model to verify the baseURL.',
497
+ 'repl.llmContextError': 'Context too long: run /compact to compress the session, then retry.',
457
498
  'permission.path': 'Path: {value}',
458
499
  'permission.command': 'Command: {value}',
459
500
  'permission.task': 'Task: {value}',
@@ -461,6 +502,7 @@ const en = {
461
502
  'permission.dangerTitle': '⚠ High-risk operation: {tool}',
462
503
  'permission.confirmTitle': 'Confirm execution: {tool}',
463
504
  'permission.dangerWarning': '⚠ This operation may have irreversible side effects. Confirm carefully.',
505
+ '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
506
  'permission.confirmExecute': 'Confirm and run',
465
507
  'permission.allowForever': 'Always allow this tool',
466
508
  '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 默认落在「拒绝」:面板一弹出就高亮"允许一次"时,用户顺手回车就把命令放出去了。
@@ -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';
@@ -144,6 +144,20 @@ function slashHelpLines(nodes = buildSlashCommands(), parentPath = '', depth = 0
144
144
  }
145
145
  return lines;
146
146
  }
147
+ /** chat 常见错误类别 → 中文引导文案的 i18n 键(识别不出的错误不走这张表,保留原始诊断)。 */
148
+ const LLM_ERROR_HINT_KEYS = {
149
+ auth: 'repl.llmAuthError',
150
+ quota: 'repl.llmQuotaError',
151
+ timeout: 'repl.llmTimeoutError',
152
+ network: 'repl.llmNetworkError',
153
+ context: 'repl.llmContextError',
154
+ };
155
+ /** /help 的分组:按使用场景归组,组内保持菜单树顺序。未列入的顶层命令兜底进「其他」。 */
156
+ const HELP_GROUPS = [
157
+ { key: 'help.groupFrequent', names: ['/help', '/clear', '/context', '/compact', '/resume', '/model'] },
158
+ { key: 'help.groupSession', names: ['/sessions', '/rollback', '/memory', '/skills', '/skill', '/init'] },
159
+ { key: 'help.groupConfig', names: ['/mode', '/subagent', '/fe', '/theme', '/pet', '/image', '/language', '/upgrade'] },
160
+ ];
147
161
  /**
148
162
  * 菜单树里全部可用命令名(含分支路径与叶子的真实命令),供未知命令纠错建议用。
149
163
  * /quit、/frontend 确实可用但没登记进菜单树,单独补上——否则用户照提示打了却仍被判未知。
@@ -882,6 +896,26 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
882
896
  tools: chatTools.map((tool) => tool.function.name).join(' · '),
883
897
  memoryEnabled: isMemoryEnabled(),
884
898
  });
899
+ /**
900
+ * 欢迎引导块:新会话开场写在内容区(banner 之下),教用户怎么开始 / 能做什么。
901
+ * 首次提交任何输入(消息或斜杠命令)前由 layout.dismissWelcomeBlock 整块撤掉——
902
+ * 「一打开就能看见,开始干活就消失」。/clear 清空后重新写一次(回到空会话状态)。
903
+ */
904
+ const welcomeLines = () => [
905
+ '',
906
+ ` ${ui.accent}${ui.bold}${t('welcome.gettingStarted')}${ui.reset}`,
907
+ ` ${ui.dim}· ${t('welcome.start1')}${ui.reset}`,
908
+ ` ${ui.dim}· ${t('welcome.start2')}${ui.reset}`,
909
+ ` ${ui.dim}· ${t('welcome.start3')}${ui.reset}`,
910
+ '',
911
+ ` ${ui.accent}${ui.bold}${t('welcome.capabilities')}${ui.reset}`,
912
+ ` ${ui.dim}· ${t('welcome.cap1')}${ui.reset}`,
913
+ ` ${ui.dim}· ${t('welcome.cap2')}${ui.reset}`,
914
+ ` ${ui.dim}· ${t('welcome.cap3')}${ui.reset}`,
915
+ '',
916
+ ` ${ui.dim}${t('welcome.try')}${ui.reset}`,
917
+ '',
918
+ ];
885
919
  // 开场:按 config.theme 切主题(横幅 / 状态行 / 后续渲染皆用新色),再进 alt screen + 状态基线 + 清内容区。
886
920
  // --resume 有历史则渲染对话,否则横幅。
887
921
  setTheme(config.theme);
@@ -890,6 +924,10 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
890
924
  layout.clearContent();
891
925
  layout.contentMode();
892
926
  if (history.some((m) => m.role === 'user')) {
927
+ // --resume 锚点:先给一行会话摘要(id/消息数/当前模型),新手对恢复状态有定位感,
928
+ // 再铺历史对话。renderHistory 之后不再追加,锚点恒为恢复内容的第一行。
929
+ const anchorCount = history.filter((m) => m.role !== 'system').length;
930
+ layout.contentWrite(`${ui.dim}${t('repl.resumeAnchor', { id: currentSessionId, count: anchorCount, model: config.model })}${ui.reset}\n`);
893
931
  renderHistory(history);
894
932
  // 强制回尾:同 /resume 命令,renderHistory 展开详情会设 scrollOffset>0,需复位避免闪烁。
895
933
  layout.resetScroll();
@@ -930,6 +968,10 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
930
968
  }
931
969
  }
932
970
  }
971
+ // 新会话开场:banner 之后写欢迎引导块(--resume 有历史时不写,历史本身就是上下文)。
972
+ if (!history.some((m) => m.role === 'user')) {
973
+ layout.writeWelcomeBlock(welcomeLines());
974
+ }
933
975
  /**
934
976
  * 切换 agent 模式(Shift+Tab 触发,经 prompt.ts 的 onCycleMode 回调)。
935
977
  * cycleMode 翻 agentMode + applyMode 重写 history[0] + refreshStatusBase 设状态行 modeTag;
@@ -939,7 +981,10 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
939
981
  history[0] = { role: 'system', content: buildSystemMessage(planMode) };
940
982
  };
941
983
  const cycleMode = () => {
942
- setAgentMode(getAgentMode() === 'plan' ? 'auto' : 'plan'); // listener 接手 applyMode + refreshStatusBase
984
+ const prev = getAgentMode();
985
+ setAgentMode(prev === 'plan' ? 'auto' : 'plan'); // listener 接手 applyMode + refreshStatusBase
986
+ // Shift+Tab 只有 chip 变化,新手可能没感知:内容区补一行确认(与 /plan / /auto 同文案,复用不新造键)。
987
+ layout.contentWrite(`${ui.dim}${t(prev === 'plan' ? 'repl.autoChanged' : 'repl.planChanged')}${ui.reset}\n`);
943
988
  };
944
989
  // 注册模式变更监听器:switch_mode 工具 / cycleMode / /plan / /auto / runTurn 调 setAgentMode 时同步触发,
945
990
  // 重写 history[0] 系统提示(切 plan 追加 PLAN_MODE_SUFFIX)+ 刷状态行 modeTag。
@@ -1142,7 +1187,17 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1142
1187
  layout.contentWrite(`${ui.dim}${t('repl.visionHint')}${ui.reset}\n`);
1143
1188
  }
1144
1189
  else {
1145
- layout.contentWrite(`${ui.red}${t('repl.errorLabel')}${ui.reset} ${msg}\n`);
1190
+ // 常见错误翻译:认证 / 限流 / 超时 / 网络 / 上下文超长 → 中文引导(新手第一堵墙);
1191
+ // 认不出的错误保留原始 provider 诊断,不瞎猜。
1192
+ const kind = classifyChatError(msg);
1193
+ if (kind) {
1194
+ const key = LLM_ERROR_HINT_KEYS[kind];
1195
+ layout.contentWrite(`${ui.red}${t('repl.errorLabel')}${ui.reset} ${t(key, { base: config.baseURL, model: config.model })}\n`);
1196
+ layout.contentWrite(`${ui.dim}${t('repl.originalError', { message: msg })}${ui.reset}\n`);
1197
+ }
1198
+ else {
1199
+ layout.contentWrite(`${ui.red}${t('repl.errorLabel')}${ui.reset} ${msg}\n`);
1200
+ }
1146
1201
  }
1147
1202
  }
1148
1203
  finally {
@@ -1188,9 +1243,10 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1188
1243
  contextState.ephemeralText = undefined;
1189
1244
  lastTurnUsage = undefined; // 续接:旧会话的 token 累计已无意义,清空等下轮覆写
1190
1245
  layout.clearContent();
1246
+ // 锚点行:会话 id / 消息数 / 当前模型,给续接的会话一个定位起点(与 --resume 启动路径同文案)。
1247
+ const anchorCount = history.filter((m) => m.role !== 'system').length;
1248
+ layout.contentWrite(`${ui.dim}${t('repl.resumeAnchor', { id: loaded.id, count: anchorCount, model: config.model })}${ui.reset}\n\n`);
1191
1249
  renderHistory(history);
1192
- // 末尾 \n\n:与后续用户消息(❯ bubble)之间空一行。
1193
- layout.contentWrite(`${ui.dim}${t('repl.resumed', { id: loaded.id })}${ui.reset}\n\n`);
1194
1250
  // 强制回尾:renderHistory 展开 mutation 工具详情会经 contentInsertAfter 设置 scrollOffset>0;
1195
1251
  // 先把"已续接会话"提示写入缓冲,再统一回尾重画,避免切换后视口停留在历史顶部。
1196
1252
  layout.resetScroll();
@@ -1240,6 +1296,8 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1240
1296
  hasSubmittedInput = true;
1241
1297
  if (line === '/exit' || line === '/quit')
1242
1298
  break;
1299
+ // 首次提交任何输入(消息或斜杠命令)→ 撤掉欢迎引导块,不让它出现在工作画面里。
1300
+ layout.dismissWelcomeBlock();
1243
1301
  // RUNNING 态:回显输入 → 底栏改 dim 占位、光标回内容续写位
1244
1302
  const cmd = line.split(/\s+/)[0];
1245
1303
  // 命令被刻意改写成别的内容后转发给 agent(如 /init)。这类不算"未知命令"。
@@ -1251,9 +1309,28 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1251
1309
  refreshStatusBase(history);
1252
1310
  layout.enterRunningMode(state.status, placeholder);
1253
1311
  if (line === '/help') {
1312
+ const nodes = buildSlashCommands();
1254
1313
  layout.contentWrite(`${ui.bold}${t('help.title')}${ui.reset}\n`);
1255
1314
  layout.contentWrite(`${ui.dim}${t('help.hint')}${ui.reset}\n`);
1256
- layout.contentWrite(`${slashHelpLines().join('\n')}\n`);
1315
+ // 按使用场景分组输出,常用置顶;未归组命令(如 /exit)兜底进「其他」。
1316
+ let first = true;
1317
+ for (const group of HELP_GROUPS) {
1318
+ const picked = nodes.filter((n) => group.names.includes(n.name));
1319
+ if (!picked.length)
1320
+ continue;
1321
+ if (!first)
1322
+ layout.contentWrite('\n');
1323
+ first = false;
1324
+ layout.contentWrite(`${ui.accent}${ui.bold}${t(group.key)}${ui.reset}\n`);
1325
+ layout.contentWrite(`${slashHelpLines(picked).join('\n')}\n`);
1326
+ }
1327
+ const rest = nodes.filter((n) => !HELP_GROUPS.some((g) => g.names.includes(n.name)));
1328
+ if (rest.length) {
1329
+ if (!first)
1330
+ layout.contentWrite('\n');
1331
+ layout.contentWrite(`${ui.accent}${ui.bold}${t('help.groupOther')}${ui.reset}\n`);
1332
+ layout.contentWrite(`${slashHelpLines(rest).join('\n')}\n`);
1333
+ }
1257
1334
  continue;
1258
1335
  }
1259
1336
  if (line === '/language' || line.startsWith('/language ')) {
@@ -1457,6 +1534,7 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1457
1534
  layout.clearContent();
1458
1535
  layout.writeBanner(bannerLines(banner()));
1459
1536
  layout.contentWrite(`${ui.dim}${t('repl.historyCleared')}${ui.reset}\n`);
1537
+ layout.writeWelcomeBlock(welcomeLines()); // 回到空会话状态,欢迎引导重新出现
1460
1538
  continue;
1461
1539
  }
1462
1540
  // /image:附加本地图片到下一条 user 消息(支持 /image <path> · /image list · /image clear)。
package/dist/ui/layout.js CHANGED
@@ -552,6 +552,9 @@ export function clearContent() {
552
552
  // /theme、/clear、/resume 等命令 clearContent 后紧接 writeBanner 的场景均依赖此重置。
553
553
  bannerH = 0;
554
554
  bannerRows = [];
555
+ // 欢迎引导块随 buffer 一起清掉(/clear / /resume 等路径),状态复位后可由 repl 重新写入。
556
+ welcomeStart = -1;
557
+ welcomeRows = 0;
555
558
  // 清内容区必须同时作废旧菜单擦除坐标:picker(/resume /rollback /theme)把菜单画在内容区底部,
556
559
  // 菜单行号缓存在 lastMenuStartRow/lastMenuRows;若不清零,后续 paintInput 会按旧坐标“擦菜单”,
557
560
  // 把刚 renderHistory/contentWrite 写好的内容(如“已续接会话”提示)清掉,导致用户要滚动一下才刷新。
@@ -585,6 +588,39 @@ export function rewindContent(rowsToRewind) {
585
588
  // repaintViewport 会按新 buffer 重画整片,旧 frame 自然被覆盖。
586
589
  repaintViewport();
587
590
  }
591
+ // ── 欢迎引导块(新会话开场)──
592
+ // 开场写在内容区(banner 之下),教用户怎么开始;首次提交任何输入(消息或斜杠命令)前
593
+ // 由 dismissWelcomeBlock 整块从 buffer 撤掉——「一打开就能看见,开始干活就消失」。
594
+ let welcomeStart = -1; // 块起点(content.committedRows 口径的绝对行索引)
595
+ let welcomeRows = 0; // 块行数(0 = 屏上无欢迎块)
596
+ /** 写欢迎引导块(每行自洽带色、行宽须 ≤ cols,contentWrite 状态机兜底折行);已在屏上则跳过。 */
597
+ export function writeWelcomeBlock(lines) {
598
+ if (!active || !ui.isTTY || lines.length === 0)
599
+ return;
600
+ if (welcomeRows > 0)
601
+ return; // 已在屏上,不重复写
602
+ welcomeStart = content.committedRows();
603
+ contentWrite(lines.join('\n') + '\n');
604
+ welcomeRows = content.committedRows() - welcomeStart;
605
+ }
606
+ /** 撤掉欢迎引导块:删 buffer 区间(若已被外部清空/裁掉则只复位状态)+ 续写位前移 + 钳位重画。 */
607
+ export function dismissWelcomeBlock() {
608
+ if (welcomeRows <= 0)
609
+ return;
610
+ const start = welcomeStart;
611
+ const n = welcomeRows;
612
+ welcomeRows = 0;
613
+ welcomeStart = -1;
614
+ if (start < 0 || start >= content.committedRows())
615
+ return; // 块已被 clear/trim,无需删
616
+ content.deleteFrom(start, n);
617
+ const g = getGeo();
618
+ contentRow = Math.max(g.contentTop, contentRow - n);
619
+ contentCol = 1;
620
+ const maxOff = Math.max(0, content.totalRows() - g.contentBottom);
621
+ scrollOffset = Math.min(scrollOffset, maxOff);
622
+ repaintViewport();
623
+ }
588
624
  /**
589
625
  * 在绝对行索引 after 后插入自洽行(详情展开用)。
590
626
  * 滚动回看冻结:scrollOffset>0 时 offset += delta 把窗口冻在原绝对行,
@@ -1360,6 +1396,14 @@ function composeSpinnerLine(status, cols) {
1360
1396
  const elapsed = (mode === 'running' && turnStart != null)
1361
1397
  ? fmtElapsed(Date.now() - turnStart)
1362
1398
  : '';
1399
+ // 新手中断提示:每轮开始后前 6 秒,spinner 行尾随一次 dim「(Esc / Ctrl+C 中断)」,
1400
+ // 6 秒后自然消失(不常驻,不打扰老用户)。只在非滚动回看时画,回看态右段让给历史指示。
1401
+ const INTERRUPT_HINT_WINDOW_MS = 6000;
1402
+ const hintActive = !scrolled && mode === 'running' && turnStart != null
1403
+ && Date.now() - turnStart < INTERRUPT_HINT_WINDOW_MS;
1404
+ const hintText = t('status.interruptHint');
1405
+ const hint = hintActive ? ` ${ui.dim}(${hintText})${ui.reset}` : '';
1406
+ const hintW = hintActive ? 4 + displayWidth(hintText) : 0; // 2 缩进 + 左右括号
1363
1407
  // ── 左段:帧 + 状态 + 走时,全部紧跟 ──
1364
1408
  let lead;
1365
1409
  let leadW;
@@ -1378,17 +1422,17 @@ function composeSpinnerLine(status, cols) {
1378
1422
  leadW = 1 + 1 + displayWidth(label) + (elapsed ? 1 + displayWidth(elapsed) : 0);
1379
1423
  }
1380
1424
  else if (hasSpinner) {
1381
- // spinner 激活(思考中/执行工具…):帧 + 状态 + 走时
1425
+ // spinner 激活(思考中/执行工具…):帧 + 状态 + 走时 (+ 新手中断提示)
1382
1426
  const ePart = elapsed ? ` ${ui.dim}${elapsed}${ui.reset}` : '';
1383
- lead = `${ui.bold}${ui.accent}${status.spinnerFrame}${ui.reset} ${ui.dim}${status.status}${ui.reset}${ePart}`;
1384
- leadW = 1 + 1 + displayWidth(status.status) + (elapsed ? 1 + displayWidth(elapsed) : 0);
1427
+ lead = `${ui.bold}${ui.accent}${status.spinnerFrame}${ui.reset} ${ui.dim}${status.status}${ui.reset}${ePart}${hint}`;
1428
+ leadW = 1 + 1 + displayWidth(status.status) + (elapsed ? 1 + displayWidth(elapsed) : 0) + hintW;
1385
1429
  }
1386
1430
  else if (spinning) {
1387
- // 运行态心跳帧(流式输出中 / 命令态如 /rollback /compact /resume):帧 + 状态文字(优先)或生成中(兜底) + 走时
1431
+ // 运行态心跳帧(流式输出中 / 命令态如 /rollback /compact /resume):帧 + 状态文字(优先)或生成中(兜底) + 走时 (+ 新手中断提示)
1388
1432
  const label = status.status || '生成中';
1389
1433
  const ePart = elapsed ? ` ${ui.dim}${elapsed}${ui.reset}` : '';
1390
- lead = `${ui.bold}${ui.accent}${RUNNING_FRAMES[runningFrame]}${ui.reset} ${ui.dim}${label}${ui.reset}${ePart}`;
1391
- leadW = 1 + 1 + displayWidth(label) + (elapsed ? 1 + displayWidth(elapsed) : 0);
1434
+ lead = `${ui.bold}${ui.accent}${RUNNING_FRAMES[runningFrame]}${ui.reset} ${ui.dim}${label}${ui.reset}${ePart}${hint}`;
1435
+ leadW = 1 + 1 + displayWidth(label) + (elapsed ? 1 + displayWidth(elapsed) : 0) + hintW;
1392
1436
  }
1393
1437
  else {
1394
1438
  // INPUT 态:● + 状态文字(无走时)
@@ -1889,6 +1933,12 @@ export function paintInput(view) {
1889
1933
  }
1890
1934
  else {
1891
1935
  text = `${prefix}${line}`;
1936
+ // 空输入引导:INPUT 态缓冲为空时,在首行画 dim ghost 占位(「输入 / 查看命令…」);
1937
+ // 任何按键都会改变缓冲 → 下一帧 isEmpty=false → 占位自然消失,无需额外擦除逻辑。
1938
+ if (!line && view.placeholder && vis.startVis === 0 && i === 0) {
1939
+ const avail = Math.max(0, preGeo.cols - promptW);
1940
+ text = `${prefix}${ui.dim}${truncateDisplay(view.placeholder, avail)}${ui.reset}`;
1941
+ }
1892
1942
  // 输入框反白叠层:仅当 paintInput 不是 dim(运行态 typeahead 不参与选区,避免干扰 IME 气泡),
1893
1943
  // 且该可视段所属的逻辑行落在 inpSel 区间内 → 高亮行内 [colStart,colEnd) 段。
1894
1944
  if (inpSel) {
package/dist/ui/prompt.js CHANGED
@@ -339,12 +339,16 @@ export async function promptWithSlashMenu(opts) {
339
339
  function redraw() {
340
340
  const { lines } = buildDisplay();
341
341
  const cur = cursorDisp();
342
+ // 空输入引导:缓冲为空时输入框内画 dim ghost 占位(含 /help 提示),让用户知道怎么开始;
343
+ // 一旦有输入(打字/粘贴/补全)isEmpty 变 false,下一帧占位即消失。
344
+ const isEmpty = segs.length === 1 && !segs[0].frozen && segs[0].text === '';
342
345
  layout.paintInput({
343
346
  prompt: opts.prompt,
344
347
  lines,
345
348
  cursorLine: cur.line,
346
349
  cursorCol: cur.col,
347
350
  menu: menuLines().length ? { lines: menuLines() } : null,
351
+ placeholder: isEmpty ? t('prompt.emptyPlaceholder') : undefined,
348
352
  });
349
353
  }
350
354
  /** 鼠标点击输入框:layout 已把"屏 tap 位置"以 (flatIdx, inSegVis) 形式传进来
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {