mocode-ai 0.4.2 → 0.4.4
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/agent/core.js +44 -5
- package/dist/agent/index.js +25 -3
- package/dist/agent/spawn.js +1 -1
- package/dist/config/index.js +44 -24
- package/dist/llm/index.js +65 -0
- package/dist/memory/store.js +43 -8
- package/dist/repl/index.js +144 -25
- package/dist/session/persist.js +15 -6
- package/dist/ui/content.js +27 -0
- package/dist/ui/layout.js +54 -11
- package/dist/ui/prompt.js +15 -5
- package/package.json +1 -1
package/dist/agent/core.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="" 不触发。
|
package/dist/agent/index.js
CHANGED
|
@@ -150,8 +150,21 @@ function mergeHooks(a, b) {
|
|
|
150
150
|
}
|
|
151
151
|
return merged;
|
|
152
152
|
}
|
|
153
|
-
/** 摘要行后追加的本轮 token
|
|
154
|
-
*
|
|
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
|
-
|
|
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
|
}
|
package/dist/agent/spawn.js
CHANGED
|
@@ -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
|
},
|
package/dist/config/index.js
CHANGED
|
@@ -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') {
|
|
@@ -98,20 +99,32 @@ function buildPlanModeSuffix() {
|
|
|
98
99
|
## ⛯ PLAN MODE (active now)
|
|
99
100
|
You are in PLAN mode: investigate and design only — do NOT execute or change anything.
|
|
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
|
-
- Research thoroughly: locate the relevant code, trace call paths, and understand existing patterns and conventions before designing.
|
|
102
|
+
- Research thoroughly: locate the relevant code, trace call paths, and understand existing patterns and conventions before designing. (Codegraph is the default first action for code exploration — see Workflow in the base prompt.)
|
|
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
|
-
-
|
|
104
|
-
-
|
|
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
|
|
|
108
115
|
## ⛯ PLAN MODE (active now)
|
|
109
116
|
You are in PLAN mode: investigate and design only — do NOT execute or change anything.
|
|
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
|
-
- Research thoroughly: locate the relevant code, trace call paths, and understand existing patterns and conventions before designing.
|
|
118
|
+
- Research thoroughly: locate the relevant code, trace call paths, and understand existing patterns and conventions before designing. (Codegraph is the default first action for code exploration — see Workflow in the base prompt.)
|
|
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
|
-
-
|
|
114
|
-
-
|
|
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() {
|
|
@@ -124,32 +137,42 @@ export function buildBasePrompt() {
|
|
|
124
137
|
: '- For complex or multi-step tasks, the user may switch to PLAN mode (Shift+Tab): your editing/command tools are then removed from your tool list, and you must research with read-only tools only and produce a step-by-step plan (no execution). On approval the session returns to auto mode to execute the plan.';
|
|
125
138
|
return `You are mocode, a terminal coding agent. You complete programming tasks through a "think → call tool → observe result → think again" loop until the problem is solved. Reply to the user in Chinese.
|
|
126
139
|
|
|
140
|
+
## 模式 (Modes)
|
|
141
|
+
${autoAllToolsLine}
|
|
142
|
+
${planLine}
|
|
143
|
+
|
|
127
144
|
${PLATFORM_NOTE}
|
|
128
145
|
|
|
146
|
+
## Step / Turn Economy (read this first — saves LLM calls)
|
|
147
|
+
- **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 次对话,以避免上下文膨胀。"
|
|
148
|
+
- **Plan the full turn, then emit it as one batch — this is the single biggest step-saver**: before emitting anything, enumerate every read / edit / command you'll need for this sub-goal, then return them together as one set of tool_calls (reads run in parallel, writes/commands run in the order given). Don't emit one call, observe, then emit the next in a follow-up turn when you could have planned both upfront.
|
|
149
|
+
- ✅ one turn: \`[read_file A, read_file B, edit_file A, run_command 'npm test']\`
|
|
150
|
+
- ❌ four turns: \`[read_file A]\` → \`[read_file B]\` → \`[edit_file A]\` → \`[run_command 'npm test']\`
|
|
151
|
+
- **Batch read-only tools in parallel**: consecutive read-only tools (read_file, glob, grep, codegraph, web_search, web_fetch) auto-execute in parallel within one turn — this is the concrete read-side case of the rule above. Do NOT call them serially across turns when you could emit them together.
|
|
152
|
+
- **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.
|
|
153
|
+
- **Chain read→edit→verify in one turn**: when the edit is obvious after a read, call edit_file (and verify with run_command) in the SAME response — don't split into 3 separate turns.
|
|
154
|
+
- **Verify once at the end of an edit chain, not after every edit**: after batching a set of related edits, run a single typecheck / test / build command to verify the whole change together. Running a verify command after each individual edit_file call wastes turns — batch the edits, then verify once.
|
|
155
|
+
- **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.
|
|
156
|
+
- **Don't re-read a file you already have, unless it may have changed**: if you (or an earlier step in this session) already read a file's relevant content and nothing has touched it since, edit directly from that content instead of calling read_file again "to be safe". This does NOT apply when the file was edited (by you or externally) since your last read, when a prior edit may have shifted line numbers you're about to target, or right after a compact where you're unsure the surviving context is accurate — in those cases re-reading is expected and correct, not wasteful.
|
|
157
|
+
|
|
129
158
|
## Workflow
|
|
130
159
|
- Understand before acting: when unsure about requirements or code state, explore first; don't assume.
|
|
131
160
|
- **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
161
|
- Small steps: break tasks into verifiable sub-steps. Before each step, think clearly about what to change and why.
|
|
133
162
|
- Verify after change: run typecheck / tests / build via run_command to confirm it works. Never claim done without verification.
|
|
134
163
|
|
|
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
164
|
## Tool Guidelines
|
|
141
165
|
- See each tool's own description for parameters and usage; this section covers selection strategy and pitfalls only.
|
|
142
|
-
- **
|
|
166
|
+
- **If the user gave a precise path or symbol, go directly**: read_file or codegraph node it — don't pre-validate with glob/grep.
|
|
143
167
|
- Before editing code, read_file to confirm actual content (with line numbers); don't guess from memory.
|
|
144
168
|
- 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
|
|
169
|
+
- 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
170
|
- 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
171
|
- 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
172
|
- 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).
|
|
149
173
|
- Call ask_human when you hit a decision point requiring user input (multiple implementation approaches, unclear intent, or needing extra info to proceed) — list options for the user to pick (they can also choose "custom input" to answer freely). Don't call it frequently when the task is clear and you can decide yourself; if the user cancels, switch approach or proceed with available info — don't re-ask the same question.
|
|
150
|
-
- **
|
|
151
|
-
- **
|
|
152
|
-
- **Batch independent tool calls in one turn**: the executor runs ALL returned tool calls before the next LLM call, so emitting [read_file, glob, read_file] together is dramatically cheaper than three separate turns. Default to bundling exploration reads and parallel writes.
|
|
174
|
+
- **Trim context when stale**: when an old tool result is dead weight (sub-goal done, no downstream consumer, or superseded by a later read), call drop_context to stub it. Otherwise rely on automatic pruning — don't carry stale reads into new sub-goals.
|
|
175
|
+
- **Batch writes and commands too, not just reads**: the executor runs ALL returned tool_calls (reads, writes, commands) before the next LLM call. Emit independent edit_file / write_file / run_command in one response when the chain is clear — don't serialize them across turns just because they have side effects. (The read-only batching note in Step Economy applies to writes the same way.)
|
|
153
176
|
- **Chain shell workflows in a single \`run_command\`**: use \`&&\`, \`;\`, \`|\`, \`>\`, heredocs to fold multi-step scripts (\`mkdir -p x && cat > x/file.ts <<'EOF' ... EOF && npm test\`) into one call. Only emit a follow-up turn when the result forces a decision (error, ambiguous output, branching logic).
|
|
154
177
|
|
|
155
178
|
## Large file writes (avoid token-cap truncation)
|
|
@@ -168,18 +191,15 @@ ${PLATFORM_NOTE}
|
|
|
168
191
|
- Operate only within authorized scope; when unsure, ask — don't guess.
|
|
169
192
|
|
|
170
193
|
${memorySection}
|
|
171
|
-
${autoAllToolsLine}
|
|
172
|
-
${planLine}
|
|
173
194
|
|
|
174
|
-
## Working notepad (todolist) —
|
|
175
|
-
- For
|
|
176
|
-
- The plan is file-backed (survives context compression
|
|
177
|
-
-
|
|
178
|
-
- **Lifecycle** (5 actions total): \`create\` / \`read\` / \`update\` / \`add_step\` / \`finish\` for normal flow. \`finish plan_status=finished\` AUTO-ARCHIVES the plan to \`.mocode/plans/archive/<id>.md\` (history preserved, active dir stays clean). To revisit old plans: \`list scope=archived\` (or \`all\`) + \`unarchive id=<id>\` to bring back. \`delete id=<id>\` permanently removes (any location); cannot delete the currently active plan.
|
|
179
|
-
- Don't over-use it: for a single edit or a quick lookup, \`todolist\` is overhead. The threshold is "this needs ≥3 steps OR I might forget the plan after context compaction."
|
|
195
|
+
## Working notepad (todolist) — for multi-step tasks
|
|
196
|
+
- For tasks spanning **≥2 independent modules** OR when the user asks for stepwise progress ("先计划再执行" / "plan then do" / "按步骤来"), call \`todolist create\` first to write the plan to \`.mocode/plans/<id>.md\`, then \`todolist update\` to mark progress as you go. For single-file edits or quick lookups, skip it.
|
|
197
|
+
- The plan is file-backed (survives context compression; user can see/edit), and the active plan summary is auto-injected into this system prompt each turn. Re-read via \`todolist read\` when unsure of your place.
|
|
198
|
+
- See the \`todolist\` tool description for the full action set (create / read / update / add_step / finish / list / unarchive / delete) and lifecycle.
|
|
180
199
|
|
|
181
200
|
## Termination & Reporting
|
|
182
201
|
- Stop immediately when no more tools are needed; give conclusions directly.
|
|
202
|
+
- **No flattery / no preamble in conclusions**: skip "Sure", "好的", "我已经完成了" and similar no-information prefixes — jump straight to substance.
|
|
183
203
|
- Report honestly: say success when successful, say where you're stuck when failing, and mention anything skipped. Reference code in "path:line" format (e.g., src/index.ts:42). Keep it concise.`;
|
|
184
204
|
}
|
|
185
205
|
/**
|
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;
|
package/dist/memory/store.js
CHANGED
|
@@ -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
|
|
328
|
-
|
|
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
|
|
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
|
|
335
|
-
|
|
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');
|
package/dist/repl/index.js
CHANGED
|
@@ -33,7 +33,8 @@ const SLASH_COMMANDS = [
|
|
|
33
33
|
{ name: '/context', desc: '显示上下文用量条' },
|
|
34
34
|
{ name: '/skills', desc: '列出已发现的 skill' },
|
|
35
35
|
{ name: '/compact', desc: '压缩历史(可带焦点 /compact …)' },
|
|
36
|
-
{ name: '/resume', desc: '
|
|
36
|
+
{ name: '/resume', desc: '续接最近 10 个已保存会话(快速)' },
|
|
37
|
+
{ name: '/sessions', desc: '浏览全部已保存会话(慢,翻历史用)' },
|
|
37
38
|
{ name: '/rollback', desc: '菜单选轮次回滚(↑↓·Enter)' },
|
|
38
39
|
{ name: '/memory', desc: '记忆库:条目计数与近期索引(关闭时提示先开 /memory_switch)' },
|
|
39
40
|
{ name: '/memory_switch', desc: '切换记忆子系统开关(无参=切换;/on 或 /off 显式;持久化 MEMORY_ENABLED)' },
|
|
@@ -195,6 +196,13 @@ let runningInput = ''; // 运行中已打字缓冲(单行;agent 结束后预填
|
|
|
195
196
|
let runningPlaceholder = '';
|
|
196
197
|
let currentAbort = null;
|
|
197
198
|
let pendingPrefill = null; // /rollback 选中后预填的 user 输入(下轮 INPUT 态消费)
|
|
199
|
+
// ── pending send 撤回窗口(用户按 Enter 后、agent 真发请求前)──
|
|
200
|
+
// 500ms 内 Ctrl+C / Esc → 整条用户气泡从内容区擦掉 + 原行 prefilled 回输入框(可改可再发);
|
|
201
|
+
// 期间再按 Enter 立即推进 / 时间到自然推进 → 走原流程 enterRunningMode + runTurn。
|
|
202
|
+
// attachmentsCount 记 pendingAttachments 当时长度——撤回时 attachments 保留(用户意图未变,只是改字)。
|
|
203
|
+
const PENDING_RECALL_MS = 500;
|
|
204
|
+
let pendingRecall = null;
|
|
205
|
+
let pendingTimer = null;
|
|
198
206
|
// agent 模式状态已提到 src/agent/mode.ts(共享叶子:switch_mode 工具可写、agent 每步读、repl 注册 onModeChange 监听器)。
|
|
199
207
|
/** 多模态 user 输入的附件状态。pending = 本轮尚未提交的待发图片;messageAttachments = 已 push 进 history 的图片元数据
|
|
200
208
|
* (供 renderHistory 复显文件名——base64 不可逆地塞进 history 后,只能从侧 channel 拿原文件名)。 */
|
|
@@ -325,6 +333,76 @@ function echoInput(lines) {
|
|
|
325
333
|
layout.contentWrite(` ${ui.dim}${renderChip(a)}${ui.reset}\n`);
|
|
326
334
|
}
|
|
327
335
|
}
|
|
336
|
+
/**
|
|
337
|
+
* 等待 pending 撤回窗口(用户 Enter 后、agent 真发请求前的 500ms 兜底)。
|
|
338
|
+
* 返 true=应 commit(走原 enterRunningMode + runTurn);false=应 recall(主循环 rewindContent 擦气泡 + prefill 回输入框)。
|
|
339
|
+
*
|
|
340
|
+
* 监听:
|
|
341
|
+
* - Esc / Ctrl+C → recall(shouldCommit=false)
|
|
342
|
+
* - Enter / Return → 立即 commit(shouldCommit=true)
|
|
343
|
+
* - 其它键忽略(不进 paste 路径、不挂 timer)
|
|
344
|
+
* - 500ms 定时器到 → 自动 commit(shouldCommit=true)
|
|
345
|
+
*
|
|
346
|
+
* 视觉:状态行 spinner 位临时改 '发送中… (Esc / Ctrl+C 撤回)';commit 后
|
|
347
|
+
* runAgent.onStepStart 会 setStatus('思考中') 接管,无需手动还原。
|
|
348
|
+
*
|
|
349
|
+
* 降级:非 TTY(setRawMode 抛错)直接 commit,window=0 —— CI / 管道回放路径不退化。
|
|
350
|
+
*/
|
|
351
|
+
function awaitPendingRecall(input, attachmentsCount, placeholder) {
|
|
352
|
+
pendingRecall = { lines: input, attachmentsCount, placeholder };
|
|
353
|
+
layout.setStatus('发送中… (Esc / Ctrl+C 撤回)', '●');
|
|
354
|
+
// 非 TTY:setRawMode 抛错 → window=0 直返 true(向后退化,不走 raw + 不挂监听)。
|
|
355
|
+
let ttyReady = false;
|
|
356
|
+
try {
|
|
357
|
+
stdin.setRawMode(true);
|
|
358
|
+
ttyReady = true;
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
ttyReady = false;
|
|
362
|
+
}
|
|
363
|
+
if (!ttyReady) {
|
|
364
|
+
pendingRecall = null;
|
|
365
|
+
return Promise.resolve(true);
|
|
366
|
+
}
|
|
367
|
+
stdin.resume();
|
|
368
|
+
emitKeypressEvents(stdin);
|
|
369
|
+
return new Promise((resolve) => {
|
|
370
|
+
let done = false;
|
|
371
|
+
const finalize = (shouldCommit) => {
|
|
372
|
+
if (done)
|
|
373
|
+
return;
|
|
374
|
+
done = true;
|
|
375
|
+
if (pendingTimer) {
|
|
376
|
+
clearTimeout(pendingTimer);
|
|
377
|
+
pendingTimer = null;
|
|
378
|
+
}
|
|
379
|
+
emitter.off('keypress', onPendingKey);
|
|
380
|
+
pendingRecall = null;
|
|
381
|
+
resolve(shouldCommit);
|
|
382
|
+
};
|
|
383
|
+
const onPendingKey = (_str, key) => {
|
|
384
|
+
if (!key || done)
|
|
385
|
+
return;
|
|
386
|
+
// 鼠标报表:吞(与 prompt.ts / onRunningKey 风格一致)
|
|
387
|
+
if (mouse.swallow(key.sequence ?? ''))
|
|
388
|
+
return;
|
|
389
|
+
// 撤回
|
|
390
|
+
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
|
|
391
|
+
finalize(false);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
// 立即 commit
|
|
395
|
+
if (key.name === 'enter' || key.name === 'return') {
|
|
396
|
+
finalize(true);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
// 其他键忽略
|
|
400
|
+
};
|
|
401
|
+
emitter.on('keypress', onPendingKey);
|
|
402
|
+
pendingTimer = setTimeout(() => finalize(true), PENDING_RECALL_MS);
|
|
403
|
+
pendingTimer.unref?.();
|
|
404
|
+
});
|
|
405
|
+
}
|
|
328
406
|
/** 把任意消息 content 拍平成字符串(OpenAI 可能 string / null / 多模态数组)。 */
|
|
329
407
|
function textOf(c) {
|
|
330
408
|
if (typeof c === 'string')
|
|
@@ -661,6 +739,31 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
661
739
|
layout.contentWrite('\n'); // 轮次之间空行
|
|
662
740
|
return ok;
|
|
663
741
|
};
|
|
742
|
+
/** 把 picker 选中的会话加载进 REPL(刷 history + 重建 snapshots + 重画)。/resume / /sessions 共用。 */
|
|
743
|
+
async function resumeFromPick(pick) {
|
|
744
|
+
if (!pick)
|
|
745
|
+
return; // Esc / Ctrl+D 取消
|
|
746
|
+
const loaded = loadSession(pick.id);
|
|
747
|
+
if (!loaded || !loaded.history.length) {
|
|
748
|
+
layout.contentWrite(`${ui.yellow}(加载失败)${ui.reset}\n`);
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
if (loaded.history[0]?.role === 'system') {
|
|
752
|
+
loaded.history[0] = { role: 'system', content: buildSystemMessage(false) };
|
|
753
|
+
}
|
|
754
|
+
history.length = 0;
|
|
755
|
+
history.push(...loaded.history);
|
|
756
|
+
setAgentMode('auto'); // 续接重置为 auto(mode 不落盘;listener 重写 history[0] 回 auto,与 loaded 幂等)
|
|
757
|
+
currentSessionId = loaded.id;
|
|
758
|
+
// 读回该会话的轮次/快照;无文件则从 history 重建 turns(无快照→旧轮次文件改动不可撤销)
|
|
759
|
+
if (!loadSnapshots(loaded.id))
|
|
760
|
+
rebuildFromHistory(history);
|
|
761
|
+
contextState.lastUsage = undefined;
|
|
762
|
+
lastTurnUsage = undefined; // 续接:旧会话的 token 累计已无意义,清空等下轮覆写
|
|
763
|
+
layout.clearContent();
|
|
764
|
+
renderHistory(history);
|
|
765
|
+
layout.contentWrite(`${ui.dim}(已续接会话 ${loaded.id})${ui.reset}\n`);
|
|
766
|
+
}
|
|
664
767
|
while (true) {
|
|
665
768
|
// INPUT 态:画底栏输入框 + 状态行,光标入输入框
|
|
666
769
|
refreshStatusBase(history);
|
|
@@ -952,10 +1055,11 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
952
1055
|
}
|
|
953
1056
|
continue;
|
|
954
1057
|
}
|
|
955
|
-
if (line === '/
|
|
956
|
-
// /
|
|
957
|
-
//
|
|
958
|
-
|
|
1058
|
+
if (line === '/sessions') {
|
|
1059
|
+
// /sessions:浏览全部已保存会话(慢路径,readdir+全量 JSON.parse,目录 N 大时会有可感知卡顿)。
|
|
1060
|
+
// 默认走 /resume(仅最近 10 条,瞬开);要翻历史续接更早的会话才用这条。
|
|
1061
|
+
// picker 走全显(cap=items.length,无 a 展开提示),靠 picker 自身开窗(以选中为中心分屏)。
|
|
1062
|
+
const sessions = listSessions(); // 不传 limit = 全量
|
|
959
1063
|
if (sessions.length === 0) {
|
|
960
1064
|
layout.contentWrite(`${ui.dim}(没有已保存的会话)${ui.reset}\n`);
|
|
961
1065
|
continue;
|
|
@@ -967,33 +1071,37 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
967
1071
|
}));
|
|
968
1072
|
let pick;
|
|
969
1073
|
try {
|
|
970
|
-
pick = await promptSessionPicker(items);
|
|
1074
|
+
pick = await promptSessionPicker(items, items.length);
|
|
971
1075
|
}
|
|
972
1076
|
catch {
|
|
973
1077
|
continue; // Ctrl+C(SIGINT)→ 取消
|
|
974
1078
|
}
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
1079
|
+
await resumeFromPick(pick);
|
|
1080
|
+
continue;
|
|
1081
|
+
}
|
|
1082
|
+
if (line === '/resume') {
|
|
1083
|
+
// /resume:打开会话菜单(↑/↓ 选,Enter 续接,Esc 取消)。只加载最近 10 条,
|
|
1084
|
+
// 避免 sessions 目录堆了几百个会话时 readdir+全量 JSON.parse 卡顿。
|
|
1085
|
+
// 仿 /rollback 菜单化(promptSessionPicker);选中项 cyan+bold + ▸ 高亮。
|
|
1086
|
+
// 要续接更早的会话请用 /sessions 翻全表,或 CLI `mocode --resume <id>`。
|
|
1087
|
+
const sessions = listSessions(10);
|
|
1088
|
+
if (sessions.length === 0) {
|
|
1089
|
+
layout.contentWrite(`${ui.dim}(没有已保存的会话)${ui.reset}\n`);
|
|
980
1090
|
continue;
|
|
981
1091
|
}
|
|
982
|
-
|
|
983
|
-
|
|
1092
|
+
const items = sessions.map((s) => ({
|
|
1093
|
+
id: s.id,
|
|
1094
|
+
title: s.firstUser || '(无)',
|
|
1095
|
+
subtitle: `${s.id} ${s.model}`,
|
|
1096
|
+
}));
|
|
1097
|
+
let pick;
|
|
1098
|
+
try {
|
|
1099
|
+
pick = await promptSessionPicker(items);
|
|
984
1100
|
}
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
// 读回该会话的轮次/快照;无文件则从 history 重建 turns(无快照→旧轮次文件改动不可撤销)
|
|
990
|
-
if (!loadSnapshots(loaded.id))
|
|
991
|
-
rebuildFromHistory(history);
|
|
992
|
-
contextState.lastUsage = undefined;
|
|
993
|
-
lastTurnUsage = undefined; // /resume:旧会话的 token 累计已无意义,清空等下轮覆写
|
|
994
|
-
layout.clearContent();
|
|
995
|
-
renderHistory(history);
|
|
996
|
-
layout.contentWrite(`${ui.dim}(已续接会话 ${loaded.id})${ui.reset}\n`);
|
|
1101
|
+
catch {
|
|
1102
|
+
continue; // Ctrl+C(SIGINT)→ 取消
|
|
1103
|
+
}
|
|
1104
|
+
await resumeFromPick(pick);
|
|
997
1105
|
continue;
|
|
998
1106
|
}
|
|
999
1107
|
if (line === '/theme' || line.startsWith('/theme ')) {
|
|
@@ -1298,6 +1406,17 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
1298
1406
|
}
|
|
1299
1407
|
continue;
|
|
1300
1408
|
}
|
|
1409
|
+
const bubbleRows = input.length + 2 + pendingAttachments.length; // N 行 message + 2 行尾随空(含 \n\n 留下的 open current 行) + 每附件 1 行
|
|
1410
|
+
const shouldCommit = await awaitPendingRecall(input, pendingAttachments.length, placeholder);
|
|
1411
|
+
if (!shouldCommit) {
|
|
1412
|
+
// 撤回:气泡从内容区擦掉 + 行放回输入框(下轮 promptWithSlashMenu 经 initialLines 消费)+ 切回 INPUT 视觉。
|
|
1413
|
+
// pendingAttachments **保留** —— 撤回的是输入文本不是意图,再发时随消息一起带走
|
|
1414
|
+
// (runTurn 入口的 pendingAttachments.flush 仍按现有逻辑把附件塞进 userInput)。
|
|
1415
|
+
layout.rewindContent(bubbleRows);
|
|
1416
|
+
pendingPrefill = input;
|
|
1417
|
+
layout.enterInputMode('空闲');
|
|
1418
|
+
continue;
|
|
1419
|
+
}
|
|
1301
1420
|
const initialPlan = getAgentMode() === 'plan'; // 轮首模式(在 runTurn 之前读)
|
|
1302
1421
|
const ok = await runTurn(joined, initialPlan, placeholder);
|
|
1303
1422
|
// plan 轮正常结束(未中断 / 未抛错)→ 看轮末模式决定:
|
package/dist/session/persist.js
CHANGED
|
@@ -81,14 +81,24 @@ export function loadSession(id) {
|
|
|
81
81
|
return null;
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
|
-
/**
|
|
85
|
-
|
|
84
|
+
/** 列出最近会话,按 createdAt 降序。损坏文件跳过。
|
|
85
|
+
* - limit?: 仅返回前 N 条。会话文件名是 YYYYMMDD-HHmmss.json,字典序=时间序;
|
|
86
|
+
* 先按文件名降序取前 N,再只解析这 N 个文件(history 大字段全部跳过不读),避免
|
|
87
|
+
* /resume 在 sessions 目录堆了几百个文件时 readdirSync + 全量 JSON.parse 慢。
|
|
88
|
+
* - 不传 limit 时读全部(向后兼容,供裸 --resume 列全表用)。
|
|
89
|
+
*/
|
|
90
|
+
export function listSessions(limit) {
|
|
86
91
|
if (!existsSync(config.sessionDir))
|
|
87
92
|
return [];
|
|
93
|
+
// 过滤掉 .snapshots.json:ASCII 排序里 's'(115) > 'j'(106),后者排在前面,会让
|
|
94
|
+
// slice(0, limit) 取到一堆快照文件(JSON.parse 后 rec.id=undefined 被吞),真会话被挤掉。
|
|
95
|
+
const all = readdirSync(config.sessionDir)
|
|
96
|
+
.filter((f) => f.endsWith('.json') && !f.endsWith('.snapshots.json'))
|
|
97
|
+
.sort() // YYYYMMDD-HHmmss.json 字典序 ≡ 时间序(同 createdAt 升序)
|
|
98
|
+
.reverse(); // 降序:最新在前
|
|
99
|
+
const toRead = typeof limit === 'number' ? all.slice(0, Math.max(0, limit)) : all;
|
|
88
100
|
const out = [];
|
|
89
|
-
for (const f of
|
|
90
|
-
if (!f.endsWith('.json'))
|
|
91
|
-
continue;
|
|
101
|
+
for (const f of toRead) {
|
|
92
102
|
try {
|
|
93
103
|
const rec = JSON.parse(readFileSync(path.join(config.sessionDir, f), 'utf8'));
|
|
94
104
|
if (rec && typeof rec.id === 'string') {
|
|
@@ -104,6 +114,5 @@ export function listSessions() {
|
|
|
104
114
|
// 跳过损坏文件
|
|
105
115
|
}
|
|
106
116
|
}
|
|
107
|
-
out.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
|
|
108
117
|
return out;
|
|
109
118
|
}
|
package/dist/ui/content.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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 +=
|
|
478
|
+
out += SEL_OPEN;
|
|
443
479
|
opened = true;
|
|
444
480
|
}
|
|
445
481
|
if (opened && w >= colEnd) {
|
|
446
|
-
out +=
|
|
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 +=
|
|
490
|
+
out += SEL_OFF;
|
|
455
491
|
return out;
|
|
456
492
|
}
|
|
457
493
|
/**
|
|
@@ -531,7 +567,7 @@ export function clearSelection() {
|
|
|
531
567
|
export function setPasteHandler(fn) {
|
|
532
568
|
pasteHandler = fn;
|
|
533
569
|
}
|
|
534
|
-
/** picker /
|
|
570
|
+
/** picker / 介入面板期间禁用鼠标选区与拖拽(避免 viewport 重画覆盖菜单);滚轮仍可用。面板退出后恢复。 */
|
|
535
571
|
export function setMouseEnabled(v) {
|
|
536
572
|
mouseEnabled = v;
|
|
537
573
|
if (!v) {
|
|
@@ -591,8 +627,9 @@ function handleMouseEvent(e) {
|
|
|
591
627
|
if (!active)
|
|
592
628
|
return;
|
|
593
629
|
if (e.type === 'wheel') {
|
|
594
|
-
|
|
595
|
-
|
|
630
|
+
// 滚轮始终可用:面板/picker 期间也允许上下查看 agent 输出,
|
|
631
|
+
// 与 onRunningKey 的 PgUp/PgDn 行为一致;mouseEnabled 仅管选区/拖拽。
|
|
632
|
+
scrollBy(e.dir * WHEEL_LINES);
|
|
596
633
|
return;
|
|
597
634
|
}
|
|
598
635
|
if (!mouseEnabled)
|
|
@@ -773,14 +810,20 @@ function composeModelLine(status, cols) {
|
|
|
773
810
|
}
|
|
774
811
|
return twoColumn(leftStr, leftW, rightStr, rightW, cols);
|
|
775
812
|
}
|
|
776
|
-
/** 把本轮 token 总量格式化成 chip 文本(纯字符串,带 ANSI 色)。无 usage 返空串。
|
|
813
|
+
/** 把本轮 token 总量格式化成 chip 文本(纯字符串,带 ANSI 色)。无 usage 返空串。
|
|
814
|
+
* 显示策略:chip 信息密度有限,只显示总量 + 一个 ↻ 标记表示有 cache 命中。
|
|
815
|
+
* 详细分项(↑↓ 计费/↻ 缓存/reasoning)在 turn 末 summary 行展示,不在此处展开。 */
|
|
777
816
|
function formatTurnTokenChip(usage) {
|
|
778
817
|
if (!usage || !usage.totalTokens)
|
|
779
818
|
return '';
|
|
780
819
|
const n = usage.totalTokens;
|
|
781
820
|
const text = n < 1000 ? `${n}` : `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k`;
|
|
782
|
-
|
|
783
|
-
|
|
821
|
+
const cached = usage.cachedTokens ?? 0;
|
|
822
|
+
const cacheTag = cached > 0
|
|
823
|
+
? ` ↻${cached < 1000 ? cached : `${(cached / 1000).toFixed(cached >= 10000 ? 0 : 1)}k`}`
|
|
824
|
+
: '';
|
|
825
|
+
// chip 用 mid 灰(降优先级)— 模式仍是主色
|
|
826
|
+
return `${ui.dim}${text} tokens${cacheTag}${ui.reset}`;
|
|
784
827
|
}
|
|
785
828
|
/** spinner 行上方的「虚拟空行」(contentBottom+1)。
|
|
786
829
|
* - 有活跃 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
|
|
131
|
+
/** chip 预览前缀:行数 + 字符数(字符 <1K 直出,≥1K 缩为 X.XK)。比"前 20 字符截断"对 CJK 更友好
|
|
132
|
+
* ——后者在中文里 20 列只够 10 个汉字就截没,几乎看不到内容;元信息密度更高、长度可预测。 */
|
|
132
133
|
function chipPrefix() {
|
|
133
|
-
|
|
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 >
|
|
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 行或 >
|
|
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);
|