mocode-ai 1.2.10 → 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.
- package/dist/i18n/index.js +52 -2
- package/dist/llm/index.js +26 -0
- package/dist/permissions/index.js +18 -1
- package/dist/repl/index.js +173 -7
- package/dist/ui/intervention.js +4 -1
- package/dist/ui/layout.js +64 -7
- package/dist/ui/prompt.js +376 -291
- package/package.json +1 -1
package/dist/i18n/index.js
CHANGED
|
@@ -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.
|
|
125
|
+
'repl.resumeAnchor': '已恢复会话 {id} · {count} 条消息 · 当前模型 {model}(/model switch 可切换)',
|
|
112
126
|
'repl.noSessions': '(没有已保存的会话)',
|
|
113
127
|
'repl.noValue': '(无)',
|
|
114
128
|
'repl.historyCleared': '(历史已清空,保留系统提示)',
|
|
@@ -122,6 +136,10 @@ const zhCN = {
|
|
|
122
136
|
'repl.themeList': '可用主题:',
|
|
123
137
|
'repl.themeCurrent': '(当前:{theme};用 /theme <名称> 切换)',
|
|
124
138
|
'repl.themeChanged': '(已切换主题 {theme})',
|
|
139
|
+
'repl.modeCurrent': '当前模式:{mode};/plan 切到只读探查并产出计划,/auto 切回全工具执行',
|
|
140
|
+
'repl.unknownCommand': '未知命令:{value}',
|
|
141
|
+
'repl.didYouMean': '你是不是想输入 {value}?',
|
|
142
|
+
'repl.unknownCommandHint': '输入 /help 查看全部命令(普通文本直接打字发送,不必加 /)',
|
|
125
143
|
'theme.default': '16 色原版(深底)',
|
|
126
144
|
'theme.light': '浅底终端',
|
|
127
145
|
'theme.solarized': 'Solarized 强调色',
|
|
@@ -150,6 +168,7 @@ const zhCN = {
|
|
|
150
168
|
'banner.disabled': '关闭',
|
|
151
169
|
'status.modeSwitch': 'Shift+Tab 切换',
|
|
152
170
|
'status.history': '历史 ↑{count}(PgDn 回底)',
|
|
171
|
+
'status.interruptHint': 'Esc / Ctrl+C 中断',
|
|
153
172
|
'status.measured': '实测',
|
|
154
173
|
'status.estimated': '估算',
|
|
155
174
|
'status.messages': '{count} 条消息',
|
|
@@ -187,6 +206,11 @@ const zhCN = {
|
|
|
187
206
|
'repl.visionUnsupported': '当前模型 {model} 不支持视觉输入。',
|
|
188
207
|
'repl.originalError': '原始:{message}',
|
|
189
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 压缩会话后重试。',
|
|
190
214
|
'permission.path': '路径: {value}',
|
|
191
215
|
'permission.command': '命令: {value}',
|
|
192
216
|
'permission.task': '任务: {value}',
|
|
@@ -194,6 +218,7 @@ const zhCN = {
|
|
|
194
218
|
'permission.dangerTitle': '⚠ 高风险操作: {tool}',
|
|
195
219
|
'permission.confirmTitle': '确认执行: {tool}',
|
|
196
220
|
'permission.dangerWarning': '⚠ 此操作可能产生不可逆副作用,请谨慎确认。',
|
|
221
|
+
'permission.firstTimeHint': '说明:面板按工具风险等级弹出。「允许」仅放行本次操作;选择会话级/项目级选项后,同类命令不再重复询问。',
|
|
197
222
|
'permission.confirmExecute': '确认执行',
|
|
198
223
|
'permission.allowForever': '以后不再询问此工具',
|
|
199
224
|
'permission.deny': '拒绝',
|
|
@@ -314,6 +339,10 @@ const en = {
|
|
|
314
339
|
'commands.upgradeStatus': 'Show current local version',
|
|
315
340
|
'help.title': 'Available slash commands',
|
|
316
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',
|
|
317
346
|
'language.current': 'Current language: {language}',
|
|
318
347
|
'language.changed': 'Language switched to English.',
|
|
319
348
|
'language.usage': 'Usage: /language zh-CN|en',
|
|
@@ -361,13 +390,23 @@ const en = {
|
|
|
361
390
|
'prompt.revertFiles': 'Revert file changes{detail}',
|
|
362
391
|
'prompt.revertDetail': ' ({count} files restored to before the rollback)',
|
|
363
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"',
|
|
364
403
|
'intervention.custom': 'Other (custom input)',
|
|
365
404
|
'intervention.defaultChoice': 'automatically selected the default',
|
|
366
405
|
'intervention.emptyInput': 'automatically returned empty input',
|
|
367
406
|
'intervention.nonInteractive': '[intervention] {title} (non-interactive; {kind})',
|
|
368
407
|
'repl.idle': 'Idle',
|
|
369
408
|
'repl.loadFailed': '(load failed)',
|
|
370
|
-
'repl.
|
|
409
|
+
'repl.resumeAnchor': 'Resumed session {id} · {count} messages · model {model} (/model switch to change)',
|
|
371
410
|
'repl.noSessions': '(no saved sessions)',
|
|
372
411
|
'repl.noValue': '(none)',
|
|
373
412
|
'repl.historyCleared': '(history cleared; system prompt kept)',
|
|
@@ -381,6 +420,10 @@ const en = {
|
|
|
381
420
|
'repl.themeList': 'Available themes:',
|
|
382
421
|
'repl.themeCurrent': '(current: {theme}; use /theme <name> to switch)',
|
|
383
422
|
'repl.themeChanged': '(switched theme to {theme})',
|
|
423
|
+
'repl.modeCurrent': 'Current mode: {mode}; /plan for read-only planning, /auto for full tool access',
|
|
424
|
+
'repl.unknownCommand': 'Unknown command: {value}',
|
|
425
|
+
'repl.didYouMean': 'Did you mean {value}?',
|
|
426
|
+
'repl.unknownCommandHint': 'Run /help for all commands (plain text is sent as-is, no slash needed)',
|
|
384
427
|
'theme.default': 'Original 16-color theme (dark)',
|
|
385
428
|
'theme.light': 'Light terminal',
|
|
386
429
|
'theme.solarized': 'Solarized accents',
|
|
@@ -409,6 +452,7 @@ const en = {
|
|
|
409
452
|
'banner.disabled': 'Disabled',
|
|
410
453
|
'status.modeSwitch': 'Shift+Tab Switch',
|
|
411
454
|
'status.history': 'History ↑{count} (PgDn to bottom)',
|
|
455
|
+
'status.interruptHint': 'Esc / Ctrl+C to interrupt',
|
|
412
456
|
'status.measured': 'measured',
|
|
413
457
|
'status.estimated': 'estimated',
|
|
414
458
|
'status.messages': '{count} messages',
|
|
@@ -446,6 +490,11 @@ const en = {
|
|
|
446
490
|
'repl.visionUnsupported': 'The current model, {model}, does not support vision input.',
|
|
447
491
|
'repl.originalError': 'Original: {message}',
|
|
448
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.',
|
|
449
498
|
'permission.path': 'Path: {value}',
|
|
450
499
|
'permission.command': 'Command: {value}',
|
|
451
500
|
'permission.task': 'Task: {value}',
|
|
@@ -453,6 +502,7 @@ const en = {
|
|
|
453
502
|
'permission.dangerTitle': '⚠ High-risk operation: {tool}',
|
|
454
503
|
'permission.confirmTitle': 'Confirm execution: {tool}',
|
|
455
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.",
|
|
456
506
|
'permission.confirmExecute': 'Confirm and run',
|
|
457
507
|
'permission.allowForever': 'Always allow this tool',
|
|
458
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,14 +139,30 @@ 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
|
|
160
|
+
detail,
|
|
147
161
|
options: choices,
|
|
148
162
|
allowCustom: false,
|
|
163
|
+
// dangerous 默认落在「拒绝」:面板一弹出就高亮"允许一次"时,用户顺手回车就把命令放出去了。
|
|
164
|
+
// 放行必须是一次明确的选择(↑↓ 移到某一项,或按数字键),而不是默认项的惯性。
|
|
165
|
+
defaultIndex: dangerous ? choices.indexOf(denyOption) : 0,
|
|
149
166
|
});
|
|
150
167
|
if (signal?.aborted || result.action === 'cancelled' || result.value === denyOption)
|
|
151
168
|
return 'deny';
|
package/dist/repl/index.js
CHANGED
|
@@ -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,87 @@ 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
|
+
];
|
|
161
|
+
/**
|
|
162
|
+
* 菜单树里全部可用命令名(含分支路径与叶子的真实命令),供未知命令纠错建议用。
|
|
163
|
+
* /quit、/frontend 确实可用但没登记进菜单树,单独补上——否则用户照提示打了却仍被判未知。
|
|
164
|
+
*/
|
|
165
|
+
function knownCommandNames() {
|
|
166
|
+
const out = [];
|
|
167
|
+
const walk = (nodes, parent) => {
|
|
168
|
+
for (const node of nodes) {
|
|
169
|
+
const path = parent ? `${parent} ${node.name}` : node.name;
|
|
170
|
+
out.push(path);
|
|
171
|
+
const actual = node.value?.trim();
|
|
172
|
+
if (actual)
|
|
173
|
+
out.push(actual.split(/\s+/)[0]);
|
|
174
|
+
if (node.children?.length)
|
|
175
|
+
walk(node.children, path);
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
walk(buildSlashCommands(), '');
|
|
179
|
+
out.push('/quit', '/frontend');
|
|
180
|
+
return [...new Set(out)];
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* 是否"长得像一条斜杠命令":单个前导 / + 词字符(字母开头,可含数字/下划线/连字符)。
|
|
184
|
+
* 用来把 /hepl 这类拼写错误与 /tmp/foo、/src/index.ts:12:5 这类路径区分开——
|
|
185
|
+
* 后者在编码 agent 里很常见,同样以 / 开头,绝不能被当成命令拦下。
|
|
186
|
+
*/
|
|
187
|
+
function isCommandShape(cmd) {
|
|
188
|
+
return /^\/[A-Za-z][\w-]*$/.test(cmd);
|
|
189
|
+
}
|
|
190
|
+
/** Levenshtein 距离(标准 DP)。短命令字符串,直接两行数组滚动即可。 */
|
|
191
|
+
function editDistance(a, b) {
|
|
192
|
+
if (a === b)
|
|
193
|
+
return 0;
|
|
194
|
+
if (!a.length)
|
|
195
|
+
return b.length;
|
|
196
|
+
if (!b.length)
|
|
197
|
+
return a.length;
|
|
198
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
199
|
+
for (let i = 1; i <= a.length; i++) {
|
|
200
|
+
const cur = [i];
|
|
201
|
+
for (let j = 1; j <= b.length; j++) {
|
|
202
|
+
cur[j] = Math.min(prev[j] + 1, // 删
|
|
203
|
+
cur[j - 1] + 1, // 插
|
|
204
|
+
prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
205
|
+
}
|
|
206
|
+
prev = cur;
|
|
207
|
+
}
|
|
208
|
+
return prev[b.length];
|
|
209
|
+
}
|
|
210
|
+
/** 未知命令的纠错建议:返回编辑距离最近且在阈值内的命令名,没有则 null(不要瞎猜)。 */
|
|
211
|
+
export function suggestCommand(input) {
|
|
212
|
+
// 阈值随长度放宽,但对短命令保持严格:/hepl(5) vs /help 距离 2 → 命中;
|
|
213
|
+
// /x(2) 不该被建议成任何东西。
|
|
214
|
+
const threshold = Math.max(1, Math.ceil(input.length / 3));
|
|
215
|
+
let best = null;
|
|
216
|
+
let bestDist = Infinity;
|
|
217
|
+
for (const name of knownCommandNames()) {
|
|
218
|
+
if (name === input)
|
|
219
|
+
continue; // 别建议"你自己"(分支节点如 /mode 也在候选里)
|
|
220
|
+
const d = editDistance(input, name);
|
|
221
|
+
if (d < bestDist) {
|
|
222
|
+
bestDist = d;
|
|
223
|
+
best = name;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return bestDist <= threshold ? best : null;
|
|
227
|
+
}
|
|
147
228
|
/** 主题名 → 本地化描述。 */
|
|
148
229
|
function themeDescription(name) {
|
|
149
230
|
const key = `theme.${name}`;
|
|
@@ -815,6 +896,26 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
|
|
|
815
896
|
tools: chatTools.map((tool) => tool.function.name).join(' · '),
|
|
816
897
|
memoryEnabled: isMemoryEnabled(),
|
|
817
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
|
+
];
|
|
818
919
|
// 开场:按 config.theme 切主题(横幅 / 状态行 / 后续渲染皆用新色),再进 alt screen + 状态基线 + 清内容区。
|
|
819
920
|
// --resume 有历史则渲染对话,否则横幅。
|
|
820
921
|
setTheme(config.theme);
|
|
@@ -823,6 +924,10 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
|
|
|
823
924
|
layout.clearContent();
|
|
824
925
|
layout.contentMode();
|
|
825
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`);
|
|
826
931
|
renderHistory(history);
|
|
827
932
|
// 强制回尾:同 /resume 命令,renderHistory 展开详情会设 scrollOffset>0,需复位避免闪烁。
|
|
828
933
|
layout.resetScroll();
|
|
@@ -863,6 +968,10 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
|
|
|
863
968
|
}
|
|
864
969
|
}
|
|
865
970
|
}
|
|
971
|
+
// 新会话开场:banner 之后写欢迎引导块(--resume 有历史时不写,历史本身就是上下文)。
|
|
972
|
+
if (!history.some((m) => m.role === 'user')) {
|
|
973
|
+
layout.writeWelcomeBlock(welcomeLines());
|
|
974
|
+
}
|
|
866
975
|
/**
|
|
867
976
|
* 切换 agent 模式(Shift+Tab 触发,经 prompt.ts 的 onCycleMode 回调)。
|
|
868
977
|
* cycleMode 翻 agentMode + applyMode 重写 history[0] + refreshStatusBase 设状态行 modeTag;
|
|
@@ -872,7 +981,10 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
|
|
|
872
981
|
history[0] = { role: 'system', content: buildSystemMessage(planMode) };
|
|
873
982
|
};
|
|
874
983
|
const cycleMode = () => {
|
|
875
|
-
|
|
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`);
|
|
876
988
|
};
|
|
877
989
|
// 注册模式变更监听器:switch_mode 工具 / cycleMode / /plan / /auto / runTurn 调 setAgentMode 时同步触发,
|
|
878
990
|
// 重写 history[0] 系统提示(切 plan 追加 PLAN_MODE_SUFFIX)+ 刷状态行 modeTag。
|
|
@@ -1075,7 +1187,17 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
|
|
|
1075
1187
|
layout.contentWrite(`${ui.dim}${t('repl.visionHint')}${ui.reset}\n`);
|
|
1076
1188
|
}
|
|
1077
1189
|
else {
|
|
1078
|
-
|
|
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
|
+
}
|
|
1079
1201
|
}
|
|
1080
1202
|
}
|
|
1081
1203
|
finally {
|
|
@@ -1121,9 +1243,10 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
|
|
|
1121
1243
|
contextState.ephemeralText = undefined;
|
|
1122
1244
|
lastTurnUsage = undefined; // 续接:旧会话的 token 累计已无意义,清空等下轮覆写
|
|
1123
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`);
|
|
1124
1249
|
renderHistory(history);
|
|
1125
|
-
// 末尾 \n\n:与后续用户消息(❯ bubble)之间空一行。
|
|
1126
|
-
layout.contentWrite(`${ui.dim}${t('repl.resumed', { id: loaded.id })}${ui.reset}\n\n`);
|
|
1127
1250
|
// 强制回尾:renderHistory 展开 mutation 工具详情会经 contentInsertAfter 设置 scrollOffset>0;
|
|
1128
1251
|
// 先把"已续接会话"提示写入缓冲,再统一回尾重画,避免切换后视口停留在历史顶部。
|
|
1129
1252
|
layout.resetScroll();
|
|
@@ -1150,7 +1273,6 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
|
|
|
1150
1273
|
input = await promptWithSlashMenu({
|
|
1151
1274
|
prompt: PROMPT,
|
|
1152
1275
|
commands: buildSlashCommands(),
|
|
1153
|
-
queryHistory,
|
|
1154
1276
|
onCycleMode: cycleMode,
|
|
1155
1277
|
// /rollback 预填优先;否则上一轮运行中 typeahead 打的字 → 预填进输入框,用户可改可发
|
|
1156
1278
|
...(pendingPrefill
|
|
@@ -1174,8 +1296,12 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
|
|
|
1174
1296
|
hasSubmittedInput = true;
|
|
1175
1297
|
if (line === '/exit' || line === '/quit')
|
|
1176
1298
|
break;
|
|
1299
|
+
// 首次提交任何输入(消息或斜杠命令)→ 撤掉欢迎引导块,不让它出现在工作画面里。
|
|
1300
|
+
layout.dismissWelcomeBlock();
|
|
1177
1301
|
// RUNNING 态:回显输入 → 底栏改 dim 占位、光标回内容续写位
|
|
1178
1302
|
const cmd = line.split(/\s+/)[0];
|
|
1303
|
+
// 命令被刻意改写成别的内容后转发给 agent(如 /init)。这类不算"未知命令"。
|
|
1304
|
+
let forwardToAgent = false;
|
|
1179
1305
|
// 语言命令把视觉分隔放在确认文案之后;避免回显后先空一行、下一条命令却紧贴确认。
|
|
1180
1306
|
echoInput(input, cmd !== '/language');
|
|
1181
1307
|
const state = runningStateFor(cmd);
|
|
@@ -1183,9 +1309,28 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
|
|
|
1183
1309
|
refreshStatusBase(history);
|
|
1184
1310
|
layout.enterRunningMode(state.status, placeholder);
|
|
1185
1311
|
if (line === '/help') {
|
|
1312
|
+
const nodes = buildSlashCommands();
|
|
1186
1313
|
layout.contentWrite(`${ui.bold}${t('help.title')}${ui.reset}\n`);
|
|
1187
1314
|
layout.contentWrite(`${ui.dim}${t('help.hint')}${ui.reset}\n`);
|
|
1188
|
-
|
|
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
|
+
}
|
|
1189
1334
|
continue;
|
|
1190
1335
|
}
|
|
1191
1336
|
if (line === '/language' || line.startsWith('/language ')) {
|
|
@@ -1217,6 +1362,7 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
|
|
|
1217
1362
|
if (line === '/init') {
|
|
1218
1363
|
// /init:把 init 指令当 user 输入发给 agent(扫描项目 + 生成 AGENTS.md),fall through 走 runAgent
|
|
1219
1364
|
joined = buildInitPrompt();
|
|
1365
|
+
forwardToAgent = true; // 不是"没匹配上",别被下方未知命令兜底拦掉
|
|
1220
1366
|
}
|
|
1221
1367
|
if (line === '/upgrade' || line.startsWith('/upgrade ')) {
|
|
1222
1368
|
const arg = line === '/upgrade' ? '' : line.slice('/upgrade '.length).trim().toLowerCase();
|
|
@@ -1317,6 +1463,12 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
|
|
|
1317
1463
|
}
|
|
1318
1464
|
continue;
|
|
1319
1465
|
}
|
|
1466
|
+
if (line === '/mode') {
|
|
1467
|
+
// /mode:菜单里的分支节点(本身不切换,只挂 /plan 与 /auto)。/help 会把它列出来,
|
|
1468
|
+
// 所以直接敲它必须给点有用的东西——打印当前模式与两个子命令,而不是报未知命令。
|
|
1469
|
+
layout.contentWrite(`${ui.dim}${t('repl.modeCurrent', { mode: getAgentMode() })}${ui.reset}\n`);
|
|
1470
|
+
continue;
|
|
1471
|
+
}
|
|
1320
1472
|
if (line === '/pet quit') {
|
|
1321
1473
|
// /pet quit:完全关闭桌宠进程(区别于 /pet 的仅断开本连接)。方案C的 CLI 侧退出入口,
|
|
1322
1474
|
// 另一入口是桌宠托盘菜单"退出桌宠"(见 packages/pet-app/src/main.ts)。
|
|
@@ -1382,6 +1534,7 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
|
|
|
1382
1534
|
layout.clearContent();
|
|
1383
1535
|
layout.writeBanner(bannerLines(banner()));
|
|
1384
1536
|
layout.contentWrite(`${ui.dim}${t('repl.historyCleared')}${ui.reset}\n`);
|
|
1537
|
+
layout.writeWelcomeBlock(welcomeLines()); // 回到空会话状态,欢迎引导重新出现
|
|
1385
1538
|
continue;
|
|
1386
1539
|
}
|
|
1387
1540
|
// /image:附加本地图片到下一条 user 消息(支持 /image <path> · /image list · /image clear)。
|
|
@@ -2293,6 +2446,19 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
|
|
|
2293
2446
|
}
|
|
2294
2447
|
continue;
|
|
2295
2448
|
}
|
|
2449
|
+
// 未知斜杠命令兜底:命令分派是一串独立的 if(…){ …continue },没有 else,
|
|
2450
|
+
// 拼错的 /hepl 会一路走到这里被当成普通 prompt 送给模型——白烧一轮还拿不到任何提示。
|
|
2451
|
+
// 只拦「长得像命令」的输入:单个前导 / + 词字符。像 /tmp/foo/bar、/src/index.ts:12:5 这类
|
|
2452
|
+
// 路径(在编码 agent 里很常见,且同样以 / 开头)必须放行,不能当成拼错的命令。
|
|
2453
|
+
if (isCommandShape(cmd) && !forwardToAgent) {
|
|
2454
|
+
const suggestion = suggestCommand(cmd);
|
|
2455
|
+
layout.contentWrite(`${ui.yellow}${t('repl.unknownCommand', { value: cmd })}${ui.reset}\n`);
|
|
2456
|
+
if (suggestion) {
|
|
2457
|
+
layout.contentWrite(`${ui.dim}${t('repl.didYouMean', { value: suggestion })}${ui.reset}\n`);
|
|
2458
|
+
}
|
|
2459
|
+
layout.contentWrite(`${ui.dim}${t('repl.unknownCommandHint')}${ui.reset}\n`);
|
|
2460
|
+
continue; // 与其他命令一致:回 INPUT 态由循环顶部的 normalizeInputBoundary + enterInputMode 负责
|
|
2461
|
+
}
|
|
2296
2462
|
const bubbleRows = input.length + 2 + pendingAttachments.length; // N 行 message + 2 行尾随空(含 \n\n 留下的 open current 行) + 每附件 1 行
|
|
2297
2463
|
const shouldCommit = await awaitPendingRecall(input, pendingAttachments.length, placeholder);
|
|
2298
2464
|
if (!shouldCommit) {
|
package/dist/ui/intervention.js
CHANGED
|
@@ -33,7 +33,10 @@ export async function promptIntervention(req) {
|
|
|
33
33
|
const startMode = req.type === 'choice' && options.length > 0 ? 'choice' : 'input';
|
|
34
34
|
let mode = startMode;
|
|
35
35
|
// choice:选中下标(0..options.length-1 为各选项,options.length 为"自定义"项)。
|
|
36
|
-
|
|
36
|
+
// 初始选中项:默认 0(首项);调用方可传 defaultIndex 改。危险操作用它把默认落在「拒绝」上,
|
|
37
|
+
// 否则面板一弹出就高亮「允许一次」,用户顺手回车就把 rm -rf 放出去了。只认真实选项,不含"自定义"项。
|
|
38
|
+
const defaultIndex = Math.max(0, Math.min(req.defaultIndex ?? 0, Math.max(0, options.length - 1)));
|
|
39
|
+
let selected = defaultIndex;
|
|
37
40
|
// input:可编辑文本 + 光标(UTF-16 码元索引;显示位置由 displayWidth 算,见 paintInput)。
|
|
38
41
|
let text = req.seed ?? '';
|
|
39
42
|
let cursor = text.length;
|
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 把窗口冻在原绝对行,
|
|
@@ -873,6 +909,13 @@ export function scrollBy(delta) {
|
|
|
873
909
|
repaintViewport();
|
|
874
910
|
repaint();
|
|
875
911
|
}
|
|
912
|
+
/**
|
|
913
|
+
* 滚动一格「滚轮量」(WHEEL_LINES 行):dir=+1 看更旧(等同 ↑ / PgUp),dir=-1 看更新(等同 ↓ / PgDn)。
|
|
914
|
+
* 鼠标滚轮与键盘裸 ↑/↓ 共用同一入口,保证两者行为与步长恒一致。
|
|
915
|
+
*/
|
|
916
|
+
export function scrollWheel(dir) {
|
|
917
|
+
scrollBy(dir > 0 ? WHEEL_LINES : -WHEEL_LINES);
|
|
918
|
+
}
|
|
876
919
|
/** 清活跃选区(不复制),若原有选区则重画去掉反白。供 ESC / 点击别处 / 退出滚动态等场景调。 */
|
|
877
920
|
export function clearSelection() {
|
|
878
921
|
if (!selection && !inputSelection)
|
|
@@ -1153,7 +1196,7 @@ function handleMouseEvent(e) {
|
|
|
1153
1196
|
if (e.type === 'wheel') {
|
|
1154
1197
|
// 滚轮始终可用:面板/picker 期间也允许上下查看 agent 输出,
|
|
1155
1198
|
// 与 onRunningKey 的 PgUp/PgDn 行为一致;mouseEnabled 仅管选区/拖拽。
|
|
1156
|
-
|
|
1199
|
+
scrollWheel(e.dir);
|
|
1157
1200
|
return;
|
|
1158
1201
|
}
|
|
1159
1202
|
// 输入行左键点击:即使 mouseEnabled=false(如 intervention 面板期间)也允许定位光标,
|
|
@@ -1353,6 +1396,14 @@ function composeSpinnerLine(status, cols) {
|
|
|
1353
1396
|
const elapsed = (mode === 'running' && turnStart != null)
|
|
1354
1397
|
? fmtElapsed(Date.now() - turnStart)
|
|
1355
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 缩进 + 左右括号
|
|
1356
1407
|
// ── 左段:帧 + 状态 + 走时,全部紧跟 ──
|
|
1357
1408
|
let lead;
|
|
1358
1409
|
let leadW;
|
|
@@ -1371,17 +1422,17 @@ function composeSpinnerLine(status, cols) {
|
|
|
1371
1422
|
leadW = 1 + 1 + displayWidth(label) + (elapsed ? 1 + displayWidth(elapsed) : 0);
|
|
1372
1423
|
}
|
|
1373
1424
|
else if (hasSpinner) {
|
|
1374
|
-
// spinner 激活(思考中/执行工具…):帧 + 状态 + 走时
|
|
1425
|
+
// spinner 激活(思考中/执行工具…):帧 + 状态 + 走时 (+ 新手中断提示)
|
|
1375
1426
|
const ePart = elapsed ? ` ${ui.dim}${elapsed}${ui.reset}` : '';
|
|
1376
|
-
lead = `${ui.bold}${ui.accent}${status.spinnerFrame}${ui.reset} ${ui.dim}${status.status}${ui.reset}${ePart}`;
|
|
1377
|
-
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;
|
|
1378
1429
|
}
|
|
1379
1430
|
else if (spinning) {
|
|
1380
|
-
// 运行态心跳帧(流式输出中 / 命令态如 /rollback /compact /resume):帧 + 状态文字(优先)或生成中(兜底) + 走时
|
|
1431
|
+
// 运行态心跳帧(流式输出中 / 命令态如 /rollback /compact /resume):帧 + 状态文字(优先)或生成中(兜底) + 走时 (+ 新手中断提示)
|
|
1381
1432
|
const label = status.status || '生成中';
|
|
1382
1433
|
const ePart = elapsed ? ` ${ui.dim}${elapsed}${ui.reset}` : '';
|
|
1383
|
-
lead = `${ui.bold}${ui.accent}${RUNNING_FRAMES[runningFrame]}${ui.reset} ${ui.dim}${label}${ui.reset}${ePart}`;
|
|
1384
|
-
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;
|
|
1385
1436
|
}
|
|
1386
1437
|
else {
|
|
1387
1438
|
// INPUT 态:● + 状态文字(无走时)
|
|
@@ -1882,6 +1933,12 @@ export function paintInput(view) {
|
|
|
1882
1933
|
}
|
|
1883
1934
|
else {
|
|
1884
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
|
+
}
|
|
1885
1942
|
// 输入框反白叠层:仅当 paintInput 不是 dim(运行态 typeahead 不参与选区,避免干扰 IME 气泡),
|
|
1886
1943
|
// 且该可视段所属的逻辑行落在 inpSel 区间内 → 高亮行内 [colStart,colEnd) 段。
|
|
1887
1944
|
if (inpSel) {
|