mocode-ai 0.4.2 → 0.4.3

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.
@@ -25,6 +25,24 @@ function parseArgs(raw) {
25
25
  return null;
26
26
  }
27
27
  }
28
+ /**
29
+ * Thrashing 检测:同一工具 + 完全相同 arguments 在本轮重复 ≥ THRASH_THRESHOLD 次,
30
+ * 返一段提示(注入到工具结果尾部),引导模型换思路而不是再试一次。
31
+ * 阈值 3 = "试过两次同样的调用还没好,该停了"。指纹 = `${name}\\x00${args}`
32
+ * (直接拼,不哈希——避免热路径开销;args 长度本身有限,内存压力可忽略)。
33
+ * null 表示未触发,不污染输出。
34
+ */
35
+ const THRASH_THRESHOLD = 3;
36
+ function thrashHint(name, args, count) {
37
+ if (count < THRASH_THRESHOLD)
38
+ return null;
39
+ return (`\n\n[hint] This is call #${count} of \`${name}\` with identical arguments — ` +
40
+ 'either failing or returning the same content. STOP retrying and switch strategy:\n' +
41
+ '- read_file / glob → path likely wrong; call `glob` to discover paths, or `ask_human`\n' +
42
+ '- run_command → Windows path-escaping issue; use `read_file` / `glob` with absolute paths instead\n' +
43
+ '- edit_file → old_string mismatch; re-read the file to find the exact text\n' +
44
+ '- otherwise → re-read the tool description; the argument shape may be wrong');
45
+ }
28
46
  /** 只读工具集:一轮多个时,连续的只读工具成组 Promise.all 并行(无副作用、互不依赖)。 */
29
47
  const READ_TOOL_NAMES = new Set([
30
48
  'read_file',
@@ -136,9 +154,20 @@ export async function runAgentCore(opts) {
136
154
  promptTokens: turnUsage.promptTokens + u.promptTokens,
137
155
  completionTokens: turnUsage.completionTokens + u.completionTokens,
138
156
  totalTokens: turnUsage.totalTokens + u.totalTokens,
157
+ cachedTokens: turnUsage.cachedTokens + u.cachedTokens,
158
+ reasoningTokens: turnUsage.reasoningTokens + u.reasoningTokens,
139
159
  }
140
160
  : u;
141
161
  };
162
+ // Thrashing 检测:本轮内同 (name, args) 累计次数。≥3 在工具结果尾部追加 hint(见 thrashHint)。
163
+ // 只在 runAgentCore 内,turn 结束自然 GC;不跨 turn 持久(下一轮重新计数,避免误把历史判为 thrashing)。
164
+ const recentToolCalls = new Map();
165
+ const recordAndHint = (name, args) => {
166
+ const fp = `${name}\x00${args}`;
167
+ const c = (recentToolCalls.get(fp) ?? 0) + 1;
168
+ recentToolCalls.set(fp, c);
169
+ return thrashHint(name, args, c);
170
+ };
142
171
  history.push({ role: 'user', content: userInput });
143
172
  // drop_context 工具的上下文剔除回调:闭包捕获 history,原地剔除无关旧 tool 结果。
144
173
  // 保护由 dropContextFromHistory 内部保证:history[0](system)+ 当前轮(最后 user 及其后)永不剔除。
@@ -267,7 +296,9 @@ export async function runAgentCore(opts) {
267
296
  const output = await started[k];
268
297
  hooks.onToolDone?.();
269
298
  hooks.onToolResult?.(tc, output, null, null, 1); // 只读工具无 diff
270
- pushToolResult(history, tc, output, relprune, lifecycle, scheduler);
299
+ // Thrashing:history 里附 hint(UI 已用干净 output 渲染,避免屏幕噪声)
300
+ const hint = recordAndHint(tc.name, tc.arguments);
301
+ pushToolResult(history, tc, hint ? `${output}${hint}` : output, relprune, lifecycle, scheduler);
271
302
  }
272
303
  i = j;
273
304
  }
@@ -287,7 +318,9 @@ export async function runAgentCore(opts) {
287
318
  hooks.onToolHeader?.(tc);
288
319
  const err = `错误:计划模式下禁用工具 ${tc.name}(仅读探查,不改动文件 / 不跑命令)`;
289
320
  hooks.onToolResult?.(tc, err, null, null, 1);
290
- pushToolResult(history, tc, err, relprune, lifecycle, scheduler);
321
+ // Thrashing:同上
322
+ const hint = recordAndHint(tc.name, tc.arguments);
323
+ pushToolResult(history, tc, hint ? `${err}${hint}` : err, relprune, lifecycle, scheduler);
291
324
  i++;
292
325
  continue;
293
326
  }
@@ -306,7 +339,9 @@ export async function runAgentCore(opts) {
306
339
  const tc = batch[k];
307
340
  const output = await started[k];
308
341
  hooks.onToolResult?.(tc, output, null, null, 1); // task 结果是摘要,无 diff
309
- pushToolResult(history, tc, output, relprune, lifecycle, scheduler);
342
+ // Thrashing:同上
343
+ const hint = recordAndHint(tc.name, tc.arguments);
344
+ pushToolResult(history, tc, hint ? `${output}${hint}` : output, relprune, lifecycle, scheduler);
310
345
  }
311
346
  hooks.onToolDone?.();
312
347
  i = j;
@@ -320,7 +355,9 @@ export async function runAgentCore(opts) {
320
355
  hooks.onToolHeader?.(tc);
321
356
  const err = `错误:计划模式下禁用工具 ${tc.name}(仅读探查,不改动文件 / 不跑命令)`;
322
357
  hooks.onToolResult?.(tc, err, null, null, 1);
323
- pushToolResult(history, tc, err, relprune, lifecycle, scheduler);
358
+ // Thrashing:同上
359
+ const hint = recordAndHint(tc.name, tc.arguments);
360
+ pushToolResult(history, tc, hint ? `${err}${hint}` : err, relprune, lifecycle, scheduler);
324
361
  i++;
325
362
  continue;
326
363
  }
@@ -333,7 +370,9 @@ export async function runAgentCore(opts) {
333
370
  const output = await executeTool(tc.name, tc.arguments, signal, { skipRollback, dropContext });
334
371
  hooks.onToolDone?.();
335
372
  hooks.onToolResult?.(tc, output, parsed, preWriteOld, editStartLine);
336
- pushToolResult(history, tc, output, relprune, lifecycle, scheduler);
373
+ // Thrashing:同上(history hint,UI 干净)
374
+ const hint = recordAndHint(tc.name, tc.arguments);
375
+ pushToolResult(history, tc, hint ? `${output}${hint}` : output, relprune, lifecycle, scheduler);
337
376
  // 相关性裁剪 mutation 通知:edit_file/write_file 后,该 path 之前的所有 read_file
338
377
  // 结果已失效(已不再是文件当前状态)→ stub 为存根。pruner 内部 try/catch + 幂等。
339
378
  // 非 mutation 工具(run_command/use_skill/memory_* 等)此处 path="" 不触发。
@@ -150,8 +150,21 @@ function mergeHooks(a, b) {
150
150
  }
151
151
  return merged;
152
152
  }
153
- /** 摘要行后追加的本轮 token 文本。例:` · 1.5k tokens (↑ 1.2k ↓ 0.3k)`。
154
- * 关闭 include_usage / 全失败 → usage=undefined → 不输出(保持原摘要行长度,不留空白)。 */
153
+ /** 摘要行后追加的本轮 token 文本。
154
+ * 例(无 cache):
155
+ * ` · 1.5k tokens (↑ 1.2k ↓ 0.3k)`
156
+ * 例(命中 cache,DeepSeek 类折扣计费):
157
+ * ` · 72k tokens (↑ 8k ↓ 3k) · ↻ 61k cached`
158
+ * 例(CoT 模型 + cache):
159
+ * ` · 72k tokens (↑ 8k ↓ 3k) · ↻ 61k cached · reasoning 1.2k`
160
+ *
161
+ * 设计要点:
162
+ * - 括号里 ↑ 显示**计费 prompt**(= 全量 - cached),不是后端报的 raw prompt,
163
+ * 否则用户看到 69k 会按全价估成本,实际只花了 5-9k 的 $。
164
+ * - ↻ 显示 cache 命中(白嫖部分),让用户一眼看到优化效果(系统 prompt 越长、对话越长越显著)。
165
+ * - reasoning 是 completion 的子集(已含在 ↓ 里),仅作信息;不二次计入成本。
166
+ * - 总数 total 不变 —— 是数学意义上的"流过的 token",反映 LLM 实际工作量。
167
+ * 关闭 include_usage / 全失败 → usage=undefined → 不输出(保持原摘要行长度)。 */
155
168
  function formatTurnTokens(usage) {
156
169
  if (!usage)
157
170
  return '';
@@ -159,5 +172,14 @@ function formatTurnTokens(usage) {
159
172
  if (!total)
160
173
  return '';
161
174
  const fmt = (n) => (n < 1000 ? `${n}` : `${(n / 1000).toFixed(total >= 10000 ? 0 : 1)}k`);
162
- return ` · ${fmt(total)} tokens (↑ ${fmt(usage.promptTokens)} ↓ ${fmt(usage.completionTokens)})`;
175
+ const cached = usage.cachedTokens;
176
+ const reasoning = usage.reasoningTokens;
177
+ const billablePrompt = usage.promptTokens - cached;
178
+ const extras = [];
179
+ if (cached > 0)
180
+ extras.push(`↻ ${fmt(cached)} cached`);
181
+ if (reasoning > 0)
182
+ extras.push(`reasoning ${fmt(reasoning)}`);
183
+ const extrasStr = extras.length > 0 ? ` · ${extras.join(' · ')}` : '';
184
+ return ` · ${fmt(total)} tokens (↑ ${fmt(billablePrompt)} ↓ ${fmt(usage.completionTokens)})${extrasStr}`;
163
185
  }
@@ -108,7 +108,7 @@ export async function spawnAgent(opts) {
108
108
  onMaxSteps: () => writeBuf(` ● 达到最大步数(${maxSteps}),子 agent 停止。\n`),
109
109
  onDone: (elapsedMs, usage) => {
110
110
  const tok = usage && usage.totalTokens
111
- ? ` · ${usage.totalTokens} tokens`
111
+ ? ` · ${usage.totalTokens} tokens${usage.cachedTokens ? ` ↻${usage.cachedTokens} cached` : ''}`
112
112
  : '';
113
113
  writeBuf(` ✻ 子 agent 耗时 ${(elapsedMs / 1000).toFixed(1)}s${tok}\n`);
114
114
  },
@@ -59,6 +59,7 @@ const PLATFORM_NOTE = (() => {
59
59
  - You are on Windows; run_command runs commands via cmd.exe (/c). Unix shell builtins are NOT available here.
60
60
  - Windows equivalents: which→where, cat→type, ls→dir, rm→del/rd, cp→copy, mv→move. cmd.exe uses %VAR% (not $VAR); pipes (|) and redirects (>, >>) work, but no $(...) command substitution or backticks.
61
61
  - head/tail/find/grep/sed have no cmd.exe equivalent — use the dedicated tools (read_file for head/tail, glob for find, grep for grep), or invoke PowerShell via run_command if you need more.
62
+ - **Avoid \`run_command\` for file ops on Windows**: cmd /c re-parses paths with backslashes / spaces / quotes — fragile, and ~half of "agent can't find file" failures trace back to this. Use the dedicated tools (read_file/glob/grep) which take absolute Windows paths natively, no shell involved. In particular, NEVER \`dir\` / \`ls\` / \`Test-Path\` / \`if exist\` / \`python -c "os.path.exists(...)"\` — those waste turns on escaping. Use \`glob\` to list, and just call \`read_file\` to test existence (returns ENOENT as a clean error string). If you must shell out, use forward slashes (\`C:/foo/bar\`).
62
63
  - Prefer the dedicated tools (read_file/glob/grep) over shell equivalents — they're cross-platform and already wired in.`;
63
64
  }
64
65
  if (process.platform === 'darwin') {
@@ -100,8 +101,14 @@ You are in PLAN mode: investigate and design only — do NOT execute or change a
100
101
  - Your editing / command tools (write_file, edit_file, run_command) have been REMOVED from your tool list. Use only the read-only tools available to you (read_file, glob, grep, codegraph, web_search, web_fetch, use_skill, ask_human) to investigate.
101
102
  - Research thoroughly: locate the relevant code, trace call paths, and understand existing patterns and conventions before designing. Your FIRST action for code exploration should be the codegraph tool (explore/node) when a .codegraph/ index exists — not read_file/grep. Use read_file/grep only to fill gaps codegraph leaves.
102
103
  - Then produce a clear, actionable implementation plan: files to change (with paths), what to change in each and why, the ordered steps, edge cases to handle, and how to verify (typecheck / tests / build). Be specific enough to execute against.
103
- - Present the plan as your final reply and STOP, unless the user explicitly asked you to "plan first then execute" / "先 plan auto" / autonomous execution: in that case, after presenting the plan, call the switch_mode tool with mode="auto" to switch back to auto mode WITHIN THE SAME TURN and continue implementing the plan yourself (your write/edit/command tools become available again immediately). The user will see no approval prompt because you self-switched.
104
- - If the user entered plan mode manually (via /plan or Shift+Tab) for a safety review and did NOT ask for autonomous execution, do NOT call switch_mode — present the plan and STOP. Do NOT ask the user for confirmation or approval in your text reply (e.g. "is this plan OK?", "shall I proceed?", "需要你确认") the REPL automatically shows an approval prompt after you STOP, so asking in text is redundant and forces the user to answer twice. Just present the plan and end your reply.`;
104
+ - When the plan is complete and ready for review, you MUST call the \`ask_human\` tool to surface the plan to the user for approval do NOT just output the plan as plain text and STOP. ask_human renders a real interactive selection panel inside the TUI; plain-text approval questions in your reply are hard to see and easy to miss.
105
+ - Pass the \`ask_human\` tool a concise plan summary (goal + files/areas to change + key risks + verification) and these three options so the user can decide in one click:
106
+ 1. "按计划执行 (switch to auto and implement)" — user approves; you then call \`switch_mode("auto")\` IN THE SAME turn and proceed.
107
+ 2. "继续细化方案 (stay in plan, refine)" — user wants more detail / alternatives; stay in plan, iterate, and re-ask via \`ask_human\` when ready.
108
+ 3. "取消 / 暂不执行 (abort)" — user wants to stop; STOP, do NOT call \`switch_mode\`.
109
+ - This applies to BOTH paths: whether the user said "先 plan 再 auto" (autonomous) or entered plan mode manually (via /plan or Shift+Tab) for safety review. The single rule is: never silently self-switch and never silently STOP — always route through \`ask_human\` so the user has an explicit chance to approve, refine, or cancel.
110
+ - Do NOT in your text reply ask rhetorical confirmation questions like "shall I proceed? / 是否同意 / 需要你确认吗" — that bypasses the panel and forces the user to type free-text feedback, which is strictly worse than picking from the 3 options. ask_human is the only sanctioned approval channel in plan mode.
111
+ - Note: the REPL may still show its own approval prompt (\`promptIntervention\`) as a defense-in-depth fallback if you somehow STOP without calling ask_human — do not rely on it; the primary path is ask_human.`;
105
112
  }
106
113
  return `
107
114
 
@@ -110,8 +117,14 @@ You are in PLAN mode: investigate and design only — do NOT execute or change a
110
117
  - Your editing / command / memory-write tools (write_file, edit_file, run_command, memory_save, memory_update, memory_forget) have been REMOVED from your tool list. Use only the read-only tools available to you (read_file, glob, grep, codegraph, web_search, web_fetch, use_skill, ask_human, memory_search, memory_list) to investigate.
111
118
  - Research thoroughly: locate the relevant code, trace call paths, and understand existing patterns and conventions before designing. Your FIRST action for code exploration should be the codegraph tool (explore/node) when a .codegraph/ index exists — not read_file/grep. Use read_file/grep only to fill gaps codegraph leaves.
112
119
  - Then produce a clear, actionable implementation plan: files to change (with paths), what to change in each and why, the ordered steps, edge cases to handle, and how to verify (typecheck / tests / build). Be specific enough to execute against.
113
- - Present the plan as your final reply and STOP, unless the user explicitly asked you to "plan first then execute" / "先 plan auto" / autonomous execution: in that case, after presenting the plan, call the switch_mode tool with mode="auto" to switch back to auto mode WITHIN THE SAME TURN and continue implementing the plan yourself (your write/edit/command/memory-write tools become available again immediately). The user will see no approval prompt because you self-switched.
114
- - If the user entered plan mode manually (via /plan or Shift+Tab) for a safety review and did NOT ask for autonomous execution, do NOT call switch_mode — present the plan and STOP. Do NOT ask the user for confirmation or approval in your text reply (e.g. "is this plan OK?", "shall I proceed?", "需要你确认") the REPL automatically shows an approval prompt after you STOP, so asking in text is redundant and forces the user to answer twice. Just present the plan and end your reply.`;
120
+ - When the plan is complete and ready for review, you MUST call the \`ask_human\` tool to surface the plan to the user for approval do NOT just output the plan as plain text and STOP. ask_human renders a real interactive selection panel inside the TUI; plain-text approval questions in your reply are hard to see and easy to miss.
121
+ - Pass the \`ask_human\` tool a concise plan summary (goal + files/areas to change + key risks + verification) and these three options so the user can decide in one click:
122
+ 1. "按计划执行 (switch to auto and implement)" — user approves; you then call \`switch_mode("auto")\` IN THE SAME turn and proceed.
123
+ 2. "继续细化方案 (stay in plan, refine)" — user wants more detail / alternatives; stay in plan, iterate, and re-ask via \`ask_human\` when ready.
124
+ 3. "取消 / 暂不执行 (abort)" — user wants to stop; STOP, do NOT call \`switch_mode\`.
125
+ - This applies to BOTH paths: whether the user said "先 plan 再 auto" (autonomous) or entered plan mode manually (via /plan or Shift+Tab) for safety review. The single rule is: never silently self-switch and never silently STOP — always route through \`ask_human\` so the user has an explicit chance to approve, refine, or cancel.
126
+ - Do NOT in your text reply ask rhetorical confirmation questions like "shall I proceed? / 是否同意 / 需要你确认吗" — that bypasses the panel and forces the user to type free-text feedback, which is strictly worse than picking from the 3 options. ask_human is the only sanctioned approval channel in plan mode.
127
+ - Note: the REPL may still show its own approval prompt (\`promptIntervention\`) as a defense-in-depth fallback if you somehow STOP without calling ask_human — do not rely on it; the primary path is ask_human.`;
115
128
  }
116
129
  /** 兼容旧名字:repl 的 buildSystemMessage 仍引 PLAN_MODE_SUFFIX(变量)。运行时按需现拼。 */
117
130
  export function buildBasePrompt() {
@@ -126,23 +139,24 @@ export function buildBasePrompt() {
126
139
 
127
140
  ${PLATFORM_NOTE}
128
141
 
142
+ ## Step / Turn Economy (read this first — saves LLM calls)
143
+ - **Minimize turns**: each user message costs at least one LLM call, and history grows every step until threshold-triggered compact fires (extra call). If a request contains ≥2 independent sub-goals (e.g. "改 X 然后再优化 Y"), ask the user to split them into separate turns rather than chaining both in one go. State this politely: "这条包含 N 个独立目标,建议拆成 N 次对话,以避免上下文膨胀。"
144
+ - **Batch read-only tools in parallel**: in a single assistant turn, emit multiple tool_calls together — consecutive read-only tools (read_file, glob, grep, codegraph, web_search, web_fetch) auto-execute in parallel. Do NOT call them serially across turns when you could emit them together in one turn. This is the single biggest step-saver.
145
+ - **Decide before reading**: do not read files "just to see"; plan the 2-3 file paths you actually need, then emit them as one batched tool_calls turn.
146
+ - **Don't repeat failed calls**: if the same tool call fails or returns the same content 3 times in this turn, switch strategy (use a different tool, ask the user, or re-read the tool description) — don't keep retrying the same shape.
147
+
129
148
  ## Workflow
130
149
  - Understand before acting: when unsure about requirements or code state, explore first; don't assume.
131
150
  - **Code exploration first action**: before reading files with read_file or searching with grep, check if a .codegraph/ index exists. If it does, use the codegraph tool (explore for questions/features, node for a specific symbol) as your FIRST step — it returns source + call paths in one shot. Only fall back to read_file/grep when codegraph misses, you need just-changed content, or you're editing a known small file. Build the index with \`codegraph init\` if none exists.
132
151
  - Small steps: break tasks into verifiable sub-steps. Before each step, think clearly about what to change and why.
133
152
  - Verify after change: run typecheck / tests / build via run_command to confirm it works. Never claim done without verification.
134
153
 
135
- ## Step / Turn Economy (read this — saves LLM calls)
136
- - **Minimize turns**: each user message costs at least one LLM call, and history grows every step until threshold-triggered compact fires (extra call). If a request contains ≥2 independent sub-goals (e.g. "改 X 然后再优化 Y"), ask the user to split them into separate turns rather than chaining both in one go. State this politely: "这条包含 N 个独立目标,建议拆成 N 次对话,以避免上下文膨胀。"
137
- - **Batch read-only tools in parallel**: in a single assistant turn, emit multiple tool_calls together — consecutive read-only tools (read_file, glob, grep, codegraph, web_search, web_fetch) auto-execute in parallel. Do NOT call them serially across turns when you could emit them together in one turn. This is the single biggest step-saver.
138
- - **Decide before reading**: do not read files "just to see"; plan the 2-3 file paths you actually need, then emit them as one batched tool_calls turn.
139
-
140
154
  ## Tool Guidelines
141
155
  - See each tool's own description for parameters and usage; this section covers selection strategy and pitfalls only.
142
156
  - **Prefer codegraph for code exploration**: when understanding/locating code, tracing call chains, or assessing impact of changes, if a .codegraph/ index exists, use the codegraph tool first (explore to query by question, node to look up a single symbol) — it returns relevant source + call paths in one shot, more accurate and economical than piecing together via read_file/grep. Fall back to read_file / grep / glob only when codegraph is unavailable (no index), misses, you need to see just-changed content, or you're editing a single known small file. Build the index first with \`codegraph init\` if none exists.
143
157
  - Before editing code, read_file to confirm actual content (with line numbers); don't guess from memory.
144
158
  - For local edits use edit_file: old_string must be unique and match exactly (including indentation/newlines); include surrounding context lines to ensure uniqueness. Use write_file for new files or full rewrites.
145
- - Use glob to find file paths, grep to search content; don't use run_command to pipe cat / sed / find / grep.
159
+ - Use glob to find file paths, grep to search content. **Don't use run_command for file-level checks** (existence / listing / type) — those have no clean cmd.exe equivalent and Windows path escaping fails often. Use \`glob\` to list, and just call \`read_file\` to test existence (returns ENOENT as a clean error string). The earlier rule against \`run_command\` for cat/sed/find/grep still applies.
146
160
  - run_command runs per platform (cmd on Windows, bash elsewhere); state intent before running commands with side effects (deleting files, installing packages, git push, resets, etc.).
147
161
  - Use web_search for information beyond training data (new versions, news, real-time data, latest APIs); don't answer potentially outdated info from memory.
148
162
  - Use web_fetch to read a specific URL (a link from search results, or a URL given by the user); it only fetches static HTML — if a JS-rendered page yields no body, switch to web_search (its results include cleaned body text).
package/dist/llm/index.js CHANGED
@@ -162,6 +162,68 @@ export const chatTools = tools.map((t) => ({
162
162
  * 记忆,这里仍是按当前 isMemoryEnabled() 算出的快照——重启 REPL 才完全生效。
163
163
  */
164
164
  export const planChatTools = chatTools.filter((t) => !getPlanDisabledTools().has(t.function.name));
165
+ /**
166
+ * 多 provider 兼容的 cache / reasoning 字段提取。
167
+ * 不同后端报 cached 字段名差异巨大,这里按"最常见的几种"顺序 probe,
168
+ * 首个合法非负数字即用(0 也算合法 —— cache miss 是合法状态,不是"无数据")。
169
+ * 字段全缺 → 0,UI 不会显示任何额外标注(零行为变化)。
170
+ *
171
+ * 已实测 / 字段名已知:
172
+ * - OpenAI / Azure / 多数 OpenAI 兼容中转:
173
+ * prompt_tokens_details.cached_tokens
174
+ * - DeepSeek(含 R1):
175
+ * prompt_cache_hit_tokens(扁平,顶层)
176
+ * - Anthropic Claude:
177
+ * cache_read_input_tokens
178
+ * - Moonshot Kimi / GLM-4.6 / Qwen:同 OpenAI 标准
179
+ * - Ollama / 本地 vLLM / 其它:无 usage details → 0(零行为变化)
180
+ *
181
+ * reasoning 字段(CoT 模型):
182
+ * - OpenAI o1 / DeepSeek R1 / GLM-Z1:
183
+ * completion_tokens_details.reasoning_tokens
184
+ * - 其它(个别):reasoning_tokens(顶层)
185
+ */
186
+ export function extractUsageExtras(usage) {
187
+ if (!usage || typeof usage !== 'object')
188
+ return { cachedTokens: 0, reasoningTokens: 0 };
189
+ const u = usage;
190
+ // cached probe 顺序:OpenAI 标准 → DeepSeek 扁平 → Anthropic → 杂项兜底
191
+ const cachedCandidates = [
192
+ readPath(u, ['prompt_tokens_details', 'cached_tokens']),
193
+ u.prompt_cache_hit_tokens,
194
+ u.cache_read_input_tokens,
195
+ u.cached_tokens,
196
+ u.prompt_tokens_cached,
197
+ ];
198
+ // reasoning probe 顺序:OpenAI 标准 → 扁平兜底
199
+ const reasoningCandidates = [
200
+ readPath(u, ['completion_tokens_details', 'reasoning_tokens']),
201
+ u.reasoning_tokens,
202
+ ];
203
+ return {
204
+ cachedTokens: firstNumber(cachedCandidates) ?? 0,
205
+ reasoningTokens: firstNumber(reasoningCandidates) ?? 0,
206
+ };
207
+ }
208
+ /** 从对象按路径读嵌套字段(每段都做 null/undefined 检查,任一断即返 undefined)。 */
209
+ function readPath(obj, path) {
210
+ let cur = obj;
211
+ for (const k of path) {
212
+ if (cur == null || typeof cur !== 'object')
213
+ return undefined;
214
+ cur = cur[k];
215
+ }
216
+ return cur;
217
+ }
218
+ /** 从候选数组里取第一个合法非负有限数字(0 算合法 —— cache miss 不是"无数据")。
219
+ * 顺序敏感:数组里前面的 provider 优先(如 OpenAI 标准报 0 时,不会 fallback 到 DeepSeek 字段)。 */
220
+ function firstNumber(arr) {
221
+ for (const v of arr) {
222
+ if (typeof v === 'number' && Number.isFinite(v) && v >= 0)
223
+ return v;
224
+ }
225
+ return undefined;
226
+ }
165
227
  /**
166
228
  * 流式调一次 LLM:增量回调文本,内部累加 tool_calls 片段。
167
229
  * tool_calls 跨 chunk 按 index 累加(id / name / arguments 拼接)。
@@ -222,10 +284,13 @@ async function chatOnce(messages, handlers, signal, toolsOverride) {
222
284
  for await (const chunk of stream) {
223
285
  // usage:末尾 chunk(choices 可能为空)在 include_usage 时携带;先读再 continue。
224
286
  if (chunk.usage) {
287
+ const extras = extractUsageExtras(chunk.usage);
225
288
  usage = {
226
289
  promptTokens: chunk.usage.prompt_tokens,
227
290
  completionTokens: chunk.usage.completion_tokens,
228
291
  totalTokens: chunk.usage.total_tokens,
292
+ cachedTokens: extras.cachedTokens,
293
+ reasoningTokens: extras.reasoningTokens,
229
294
  };
230
295
  }
231
296
  const delta = chunk.choices?.[0]?.delta;
@@ -316,6 +316,14 @@ export function gcMemories() {
316
316
  * active 条目按 updatedAt 降序,封顶 MAX_INDEX_ENTRIES,只注 id/name/summary/type。
317
317
  * 无 active 返空串(零行为变化)。body 不注入——按需 memory_search 取。
318
318
  *
319
+ * 索引策略(省 token):不全量塞进每轮 systemPrompt。
320
+ * - pinned 永远包含(pinned = 用户明确想长期保留)
321
+ * - recallCount ≥ 1 包含(被引用过,价值已验证)
322
+ * - 否则仅当 (lastRecalledAt|createdAt) 近 RECENT_MS(=DECAY_DAYS×2) 内
323
+ * 排序:pinned 先 → recallCount 降 → updatedAt 降。
324
+ * 封顶 MAX_INDEX_ENTRIES;尾部标 hidden 数量,引导用 memory_list/memory_search 兜底。
325
+ * 真正「陈旧」被滤掉时也明示(让 LLM 知道有内容存在但被策略隐藏,而不是误以为空)。
326
+ *
319
327
  * memoryEnabled=false 时(记忆子系统总开关关闭)直接返空串:Memory Index 段
320
328
  * 不进系统提示,LLM 看不到工具使用提示;配合 tools/builtins 屏蔽 memory_* 工具,
321
329
  * 实现「关闭时零侵入」(默认行为)。传参由 repl 的 buildSystemMessage 在拼装前调
@@ -324,22 +332,49 @@ export function gcMemories() {
324
332
  export function buildMemoryIndexSection(memoryEnabled = true) {
325
333
  if (!memoryEnabled)
326
334
  return '';
327
- const active = loadAll()
328
- .filter((e) => e.status === 'active')
329
- .sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || ''));
335
+ const all = loadAll();
336
+ const active = all.filter((e) => e.status === 'active');
330
337
  if (active.length === 0)
331
338
  return '';
332
- const shown = active.slice(0, MAX_INDEX_ENTRIES);
339
+ const now = Date.now();
340
+ // DECAY_DAYS×2 = 60 天(常量在下方定义,同模块作用域内可见)
341
+ const recentCutoff = now - DECAY_DAYS * 2 * DAY_MS;
342
+ const refTs = (e) => {
343
+ const r = e.lastRecalledAt ? Date.parse(e.lastRecalledAt) : Date.parse(e.createdAt);
344
+ return Number.isFinite(r) ? r : now;
345
+ };
346
+ // 过滤:只保留"近 60 天有动静 / 有 recall / pinned"的三类。其它 active 视为陈旧。
347
+ const eligible = active.filter((e) => {
348
+ if (e.pinned)
349
+ return true;
350
+ if (e.recallCount >= 1)
351
+ return true;
352
+ return refTs(e) >= recentCutoff;
353
+ });
354
+ eligible.sort((a, b) => {
355
+ if (a.pinned !== b.pinned)
356
+ return a.pinned ? -1 : 1;
357
+ if (a.recallCount !== b.recallCount)
358
+ return b.recallCount - a.recallCount;
359
+ return (b.updatedAt || '').localeCompare(a.updatedAt || '');
360
+ });
361
+ const staleCount = active.length - eligible.length;
362
+ const shown = eligible.slice(0, MAX_INDEX_ENTRIES);
333
363
  const lines = shown.map((e) => `- ${e.id}: ${e.name} — ${e.summary} (${e.type})`);
334
- const tail = active.length > MAX_INDEX_ENTRIES
335
- ? `\n\n…(${active.length} total, showing first ${MAX_INDEX_ENTRIES}; use memory_search <id or keyword> for more)`
336
- : '';
364
+ const capOmitted = Math.max(0, eligible.length - shown.length);
365
+ const tailParts = [];
366
+ if (capOmitted > 0)
367
+ tailParts.push(`${capOmitted} additional active entries omitted by cap (${shown.length}/${eligible.length} shown)`);
368
+ if (staleCount > 0)
369
+ tailParts.push(`${staleCount} stale active entries hidden by index policy (no recall + older than ${DECAY_DAYS * 2}d; use memory_list to see all)`);
370
+ const tail = tailParts.length > 0 ? `\n\n…(${tailParts.join('; ')})` : '';
337
371
  return [
338
372
  '',
339
373
  '',
340
374
  '## Memory Index (retrieve full body via memory_search)',
341
375
  'The following are saved memory entries (title/summary only). Retrieve full body via memory_search (pass id or keyword); use memory_list to see all,'
342
- + ' memory_update to modify, memory_forget to archive. This list is a startup snapshot; entries added during the session are not listed here — use memory_list/memory_search to find them.',
376
+ + ' memory_update to modify, memory_forget to archive. This list is a startup snapshot; entries added during the session are not listed here — use memory_list/memory_search to find them.'
377
+ + ' Index policy: pinned + recently-recalled always shown; long-untouched active entries are hidden to keep this section lean.',
343
378
  ...lines,
344
379
  tail,
345
380
  ].join('\n');
@@ -195,6 +195,13 @@ let runningInput = ''; // 运行中已打字缓冲(单行;agent 结束后预填
195
195
  let runningPlaceholder = '';
196
196
  let currentAbort = null;
197
197
  let pendingPrefill = null; // /rollback 选中后预填的 user 输入(下轮 INPUT 态消费)
198
+ // ── pending send 撤回窗口(用户按 Enter 后、agent 真发请求前)──
199
+ // 500ms 内 Ctrl+C / Esc → 整条用户气泡从内容区擦掉 + 原行 prefilled 回输入框(可改可再发);
200
+ // 期间再按 Enter 立即推进 / 时间到自然推进 → 走原流程 enterRunningMode + runTurn。
201
+ // attachmentsCount 记 pendingAttachments 当时长度——撤回时 attachments 保留(用户意图未变,只是改字)。
202
+ const PENDING_RECALL_MS = 500;
203
+ let pendingRecall = null;
204
+ let pendingTimer = null;
198
205
  // agent 模式状态已提到 src/agent/mode.ts(共享叶子:switch_mode 工具可写、agent 每步读、repl 注册 onModeChange 监听器)。
199
206
  /** 多模态 user 输入的附件状态。pending = 本轮尚未提交的待发图片;messageAttachments = 已 push 进 history 的图片元数据
200
207
  * (供 renderHistory 复显文件名——base64 不可逆地塞进 history 后,只能从侧 channel 拿原文件名)。 */
@@ -325,6 +332,76 @@ function echoInput(lines) {
325
332
  layout.contentWrite(` ${ui.dim}${renderChip(a)}${ui.reset}\n`);
326
333
  }
327
334
  }
335
+ /**
336
+ * 等待 pending 撤回窗口(用户 Enter 后、agent 真发请求前的 500ms 兜底)。
337
+ * 返 true=应 commit(走原 enterRunningMode + runTurn);false=应 recall(主循环 rewindContent 擦气泡 + prefill 回输入框)。
338
+ *
339
+ * 监听:
340
+ * - Esc / Ctrl+C → recall(shouldCommit=false)
341
+ * - Enter / Return → 立即 commit(shouldCommit=true)
342
+ * - 其它键忽略(不进 paste 路径、不挂 timer)
343
+ * - 500ms 定时器到 → 自动 commit(shouldCommit=true)
344
+ *
345
+ * 视觉:状态行 spinner 位临时改 '发送中… (Esc / Ctrl+C 撤回)';commit 后
346
+ * runAgent.onStepStart 会 setStatus('思考中') 接管,无需手动还原。
347
+ *
348
+ * 降级:非 TTY(setRawMode 抛错)直接 commit,window=0 —— CI / 管道回放路径不退化。
349
+ */
350
+ function awaitPendingRecall(input, attachmentsCount, placeholder) {
351
+ pendingRecall = { lines: input, attachmentsCount, placeholder };
352
+ layout.setStatus('发送中… (Esc / Ctrl+C 撤回)', '●');
353
+ // 非 TTY:setRawMode 抛错 → window=0 直返 true(向后退化,不走 raw + 不挂监听)。
354
+ let ttyReady = false;
355
+ try {
356
+ stdin.setRawMode(true);
357
+ ttyReady = true;
358
+ }
359
+ catch {
360
+ ttyReady = false;
361
+ }
362
+ if (!ttyReady) {
363
+ pendingRecall = null;
364
+ return Promise.resolve(true);
365
+ }
366
+ stdin.resume();
367
+ emitKeypressEvents(stdin);
368
+ return new Promise((resolve) => {
369
+ let done = false;
370
+ const finalize = (shouldCommit) => {
371
+ if (done)
372
+ return;
373
+ done = true;
374
+ if (pendingTimer) {
375
+ clearTimeout(pendingTimer);
376
+ pendingTimer = null;
377
+ }
378
+ emitter.off('keypress', onPendingKey);
379
+ pendingRecall = null;
380
+ resolve(shouldCommit);
381
+ };
382
+ const onPendingKey = (_str, key) => {
383
+ if (!key || done)
384
+ return;
385
+ // 鼠标报表:吞(与 prompt.ts / onRunningKey 风格一致)
386
+ if (mouse.swallow(key.sequence ?? ''))
387
+ return;
388
+ // 撤回
389
+ if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
390
+ finalize(false);
391
+ return;
392
+ }
393
+ // 立即 commit
394
+ if (key.name === 'enter' || key.name === 'return') {
395
+ finalize(true);
396
+ return;
397
+ }
398
+ // 其他键忽略
399
+ };
400
+ emitter.on('keypress', onPendingKey);
401
+ pendingTimer = setTimeout(() => finalize(true), PENDING_RECALL_MS);
402
+ pendingTimer.unref?.();
403
+ });
404
+ }
328
405
  /** 把任意消息 content 拍平成字符串(OpenAI 可能 string / null / 多模态数组)。 */
329
406
  function textOf(c) {
330
407
  if (typeof c === 'string')
@@ -1298,6 +1375,17 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
1298
1375
  }
1299
1376
  continue;
1300
1377
  }
1378
+ const bubbleRows = input.length + 2 + pendingAttachments.length; // N 行 message + 2 行尾随空(含 \n\n 留下的 open current 行) + 每附件 1 行
1379
+ const shouldCommit = await awaitPendingRecall(input, pendingAttachments.length, placeholder);
1380
+ if (!shouldCommit) {
1381
+ // 撤回:气泡从内容区擦掉 + 行放回输入框(下轮 promptWithSlashMenu 经 initialLines 消费)+ 切回 INPUT 视觉。
1382
+ // pendingAttachments **保留** —— 撤回的是输入文本不是意图,再发时随消息一起带走
1383
+ // (runTurn 入口的 pendingAttachments.flush 仍按现有逻辑把附件塞进 userInput)。
1384
+ layout.rewindContent(bubbleRows);
1385
+ pendingPrefill = input;
1386
+ layout.enterInputMode('空闲');
1387
+ continue;
1388
+ }
1301
1389
  const initialPlan = getAgentMode() === 'plan'; // 轮首模式(在 runTurn 之前读)
1302
1390
  const ok = await runTurn(joined, initialPlan, placeholder);
1303
1391
  // plan 轮正常结束(未中断 / 未抛错)→ 看轮末模式决定:
@@ -55,6 +55,33 @@ export function breakRow() {
55
55
  rowStartSgr = curSgr; // 下行继承本行末状态
56
56
  hasCurrent = true; // 新空行即当前行
57
57
  }
58
+ /**
59
+ * 弹出 buffer 末尾 n 物理行(供 layout.rewindContent 撤回刚写入段用)。
60
+ * 当前行先 commit 再裁剪(消除 hasCurrent 边界);n ≥ totalRows 则全清,
61
+ * 但**不动 segMark** —— recall 走普通 contentWrite、不撞 md 段;
62
+ * segMark 由 reset() / commitSegment() 清,recall 不掺和。
63
+ */
64
+ export function rewind(n) {
65
+ if (n <= 0)
66
+ return;
67
+ const cur = totalRows();
68
+ if (n >= cur) {
69
+ rows = [];
70
+ curSgr = '';
71
+ rowStartSgr = '';
72
+ curRaw = '';
73
+ hasCurrent = false;
74
+ return;
75
+ }
76
+ // 当前行先 commit 再裁剪(否则 hasCurrent 边界会被打穿)
77
+ if (hasCurrent) {
78
+ rows.push(rowStartSgr + curRaw + '\x1B[0m');
79
+ curRaw = '';
80
+ rowStartSgr = curSgr;
81
+ hasCurrent = false;
82
+ }
83
+ rows.splice(rows.length - n);
84
+ }
58
85
  /** 标记段起点:快照当前缓冲状态,供 setLines 截断定位段头(md 流式渲染每 chunk 截断重渲)。 */
59
86
  export function beginSegment() {
60
87
  segMark = { rowIdx: rows.length, rowStartSgr, curSgr, curRaw, hasCurrent };
package/dist/ui/layout.js CHANGED
@@ -396,6 +396,30 @@ export function clearContent() {
396
396
  content.reset();
397
397
  stdout.write(esc.home);
398
398
  }
399
+ /**
400
+ * 撤销(rewind)内容区末尾 n 物理行 —— 供 "刚 echoInput 写下用户气泡、用户撤回"
401
+ * 把气泡从可视区与 buffer 一起拿掉。content.rewind 弹出 buffer 末 n 行;layout 这边
402
+ * 同步上溯 contentRow(contentTop 钳位,防下溢)、scrollOffset 钳到新 totalRows、
403
+ * 最后 repaintViewport 重画可视区(running 态回 runningCaretPos,input 态回续写位)。
404
+ *
405
+ * 与 clearContent 不同:只擦最近一段、保留更早内容与 buffer;光标归内容区底,
406
+ * 不是 (1,1)。常发生在 RUNNING 态的 pending 窗口内 recall —— recall 后调用方
407
+ * 通常再 enterInputMode 切回 INPUT 视觉,光标由后续 paintInput 重画。
408
+ */
409
+ export function rewindContent(rowsToRewind) {
410
+ if (!active || rowsToRewind <= 0)
411
+ return;
412
+ content.rewind(rowsToRewind);
413
+ const g = getGeo();
414
+ contentRow = Math.max(g.contentTop, contentRow - rowsToRewind);
415
+ contentCol = 1;
416
+ // 钳 scrollOffset:不能让 viewport 视点超出新 totalRows
417
+ const maxOff = Math.max(0, content.totalRows() - g.contentBottom);
418
+ scrollOffset = Math.min(scrollOffset, maxOff);
419
+ // 末段 frameRow/frameCol 是 spinner 的画位,撤回若跨过 frame 行也不必清——
420
+ // repaintViewport 会按新 buffer 重画整片,旧 frame 自然被覆盖。
421
+ repaintViewport();
422
+ }
399
423
  // ── viewport 滚动回看(Phase 2)──
400
424
  /** 是否处于滚动回看态(offset>0,内容区显历史)。prompt 据此在非滚动键时回尾。 */
401
425
  export function isScrolled() {
@@ -423,7 +447,16 @@ function normalizeSelection() {
423
447
  ? { startLine: a.line, startCol: a.col, endLine: b.line, endCol: b.col }
424
448
  : { startLine: b.line, startCol: b.col, endLine: a.line, endCol: a.col };
425
449
  }
426
- /** 给自洽带色行的显示列区间 [colStart,colEnd) 套反白(\x1B[7m...\x1B[27m),SGR 码原样穿过不受影响。 */
450
+ /** 给自洽带色行的显示列区间 [colStart,colEnd) 套「统一亮黄底 + 黑字」。
451
+ * 强制重设前/背景,无视原 SGR:md 字符常带 ui.dim / ui.cyan / ui.gray 等,
452
+ * 仅靠 SGR 7 反转对比度极弱;改用显式「亮黄底 SGR 103 + 黑前景 SGR 30」,
453
+ * 跨终端一致。退反白用 0 清 SGR,行末 active SGR 自然续接。
454
+ * 关键:反白 active 期间**吃掉所有行内 SGR**(不让前景色干扰)——否则
455
+ * 行内首个 \x1B[2m(dim)/\x1B[36m(cyan)/\x1B[1m(bold) 进选区后仍生效,
456
+ * 前景被压回原色,整片亮黄底被切割、看着「花」;直穿则带 dim 等,对比不足。
457
+ * 反白外 SGR 直穿,保留原色。 */
458
+ const SEL_OPEN = '\x1B[30;103m'; // 30:黑前景 | 103:亮黄背景
459
+ const SEL_OFF = '\x1B[0m'; // 全清 SGR,行末 active 状态续接
427
460
  function highlightRange(line, colStart, colEnd) {
428
461
  if (colEnd <= colStart)
429
462
  return line;
@@ -433,17 +466,20 @@ function highlightRange(line, colStart, colEnd) {
433
466
  let opened = false;
434
467
  for (let i = 0; i < parts.length; i++) {
435
468
  if (i % 2 === 1) {
436
- out += parts[i]; // SGR 码原样穿过
469
+ // SGR 段:反白内直接吃,不让行内前景色进选区;反白外直穿,保留原色。
470
+ if (opened)
471
+ continue;
472
+ out += parts[i];
437
473
  continue;
438
474
  }
439
475
  for (const ch of parts[i]) {
440
476
  const cw = charWidth(ch.codePointAt(0) ?? 0);
441
477
  if (!opened && w >= colStart && w < colEnd) {
442
- out += '\x1B[7m';
478
+ out += SEL_OPEN;
443
479
  opened = true;
444
480
  }
445
481
  if (opened && w >= colEnd) {
446
- out += '\x1B[27m';
482
+ out += SEL_OFF;
447
483
  opened = false;
448
484
  }
449
485
  out += ch;
@@ -451,7 +487,7 @@ function highlightRange(line, colStart, colEnd) {
451
487
  }
452
488
  }
453
489
  if (opened)
454
- out += '\x1B[27m';
490
+ out += SEL_OFF;
455
491
  return out;
456
492
  }
457
493
  /**
@@ -773,14 +809,20 @@ function composeModelLine(status, cols) {
773
809
  }
774
810
  return twoColumn(leftStr, leftW, rightStr, rightW, cols);
775
811
  }
776
- /** 把本轮 token 总量格式化成 chip 文本(纯字符串,带 ANSI 色)。无 usage 返空串。 */
812
+ /** 把本轮 token 总量格式化成 chip 文本(纯字符串,带 ANSI 色)。无 usage 返空串。
813
+ * 显示策略:chip 信息密度有限,只显示总量 + 一个 ↻ 标记表示有 cache 命中。
814
+ * 详细分项(↑↓ 计费/↻ 缓存/reasoning)在 turn 末 summary 行展示,不在此处展开。 */
777
815
  function formatTurnTokenChip(usage) {
778
816
  if (!usage || !usage.totalTokens)
779
817
  return '';
780
818
  const n = usage.totalTokens;
781
819
  const text = n < 1000 ? `${n}` : `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k`;
782
- // 单段:量足够稳定时不显分项,避免无谓视觉负担。chip mid 灰(降优先级)— 模式仍是主色。
783
- return `${ui.dim}${text} tokens${ui.reset}`;
820
+ const cached = usage.cachedTokens ?? 0;
821
+ const cacheTag = cached > 0
822
+ ? ` ↻${cached < 1000 ? cached : `${(cached / 1000).toFixed(cached >= 10000 ? 0 : 1)}k`}`
823
+ : '';
824
+ // chip 用 mid 灰(降优先级)— 模式仍是主色
825
+ return `${ui.dim}${text} tokens${cacheTag}${ui.reset}`;
784
826
  }
785
827
  /** spinner 行上方的「虚拟空行」(contentBottom+1)。
786
828
  * - 有活跃 plan:显「plan: <summary> ▸ N. step」整行左对齐(yellow + dim)
package/dist/ui/prompt.js CHANGED
@@ -128,9 +128,15 @@ export async function promptWithSlashMenu(opts) {
128
128
  function cursorCol() {
129
129
  return displayWidth(lines[cl].slice(0, cc));
130
130
  }
131
- /** chip 预览前缀:整段扁平化(行界→空格,避免框内折行)取前 ~20 列,超长 truncateDisplay 自带 …;末尾空格与 suffix 分隔。 */
131
+ /** chip 预览前缀:行数 + 字符数(字符 <1K 直出,≥1K 缩为 X.XK)。比"前 20 字符截断"对 CJK 更友好
132
+ * ——后者在中文里 20 列只够 10 个汉字就截没,几乎看不到内容;元信息密度更高、长度可预测。 */
132
133
  function chipPrefix() {
133
- return chip ? `[${truncateDisplay(chip.split('\n').join(' '), 20)}] ` : '';
134
+ if (!chip)
135
+ return '';
136
+ const lines = chip.split('\n').length;
137
+ const chars = chip.length;
138
+ const charStr = chars < 1000 ? `${chars} 字符` : `${(chars / 1000).toFixed(1)}K 字符`;
139
+ return `[📋 ${lines} 行 · ${charStr}] `;
134
140
  }
135
141
  /** chipPre 按行拆分(粘贴发生前光标之前已有的文本,可能多行,原样保留在 chip 之前)。 */
136
142
  function chipPreLines() {
@@ -178,7 +184,7 @@ export async function promptWithSlashMenu(opts) {
178
184
  function applyPastedText(buf) {
179
185
  if (!buf)
180
186
  return;
181
- const isLong = buf.split('\n').length > 8 || buf.length > 400;
187
+ const isLong = buf.split('\n').length > 8 || buf.length > 200;
182
188
  if (isLong) {
183
189
  const before = lines[cl].slice(0, cc);
184
190
  const after = lines[cl].slice(cc);
@@ -202,7 +208,7 @@ export async function promptWithSlashMenu(opts) {
202
208
  computeFiltered();
203
209
  redraw();
204
210
  }
205
- /** 粘贴结束:长粘贴(>8 行或 >400 字符)落/并进 chip(原子,整段封预览),短粘贴落为可编辑文本。 */
211
+ /** 粘贴结束:长粘贴(>8 行或 >200 字符)落/并进 chip(原子,整段封预览),短粘贴落为可编辑文本。 */
206
212
  function finalizePaste() {
207
213
  if (resolved) {
208
214
  pasteParts = [];
@@ -411,9 +417,13 @@ export async function promptWithSlashMenu(opts) {
411
417
  return;
412
418
  }
413
419
  const isReturn = key.name === 'return' || key.name === 'enter';
414
- // 换行:Ctrl+J / Alt+Enter(meta)/ Shift+Enter(终端区分时)/ lone LF
420
+ // 换行:Ctrl+J / Ctrl+Enter / Alt+Enter(meta)/ Shift+Enter(终端区分时)/ lone LF
415
421
  // (粘贴的 CR/LF 已在上方 pasting 分支累积进 pasteParts,不会到此)
422
+ // Ctrl+Enter:readline 解析得到 key.ctrl=true && isReturn 走这条路;少数老 xterm 把
423
+ // Ctrl+Enter 当裸 \r 发(没 ctrl flag)→ 落到下方「提交」分支做 Enter 处理,
424
+ // 此时用 Ctrl+J 兜底。
416
425
  const wantNewline = (key.ctrl && key.name === 'j') ||
426
+ (key.ctrl && isReturn) ||
417
427
  (key.meta && isReturn) ||
418
428
  (key.shift && isReturn) ||
419
429
  (key.sequence === '\n' && !key.ctrl);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {