mocode-ai 0.5.2 → 0.5.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.
@@ -17,6 +17,7 @@ import { createRelevancePruner } from '../context/relevance.js';
17
17
  import { config } from '../config/index.js';
18
18
  import { jailResolve } from '../sandbox/index.js';
19
19
  import { createLifecycleEngine } from '../context/lifecycle.js';
20
+ import { hasActivePlan, getActivePlan } from '../plan/active.js';
20
21
  /** 解析工具 arguments JSON;非法或空返 null(调用方据此降级到普通 preview)。 */
21
22
  function parseArgs(raw) {
22
23
  try {
@@ -166,11 +167,45 @@ export async function runAgentCore(opts) {
166
167
  // Thrashing 检测:本轮内同 (name, args) 累计次数。≥3 在工具结果尾部追加 hint(见 thrashHint)。
167
168
  // 只在 runAgentCore 内,turn 结束自然 GC;不跨 turn 持久(下一轮重新计数,避免误把历史判为 thrashing)。
168
169
  const recentToolCalls = new Map();
170
+ // Plan progress nudge:LLM 连续 ≥ PLAN_NUDGE_THRESHOLD 次工具调用没调 todolist → 注入提醒到结果末尾。
171
+ // 让进度 chip 反映真实进展,而不是做完一大段后才一次性批量更新。
172
+ // 计数器在任何 todolist 调用后重置;nudge 发出后也重置(避免后续每次工具调用都重复提醒)。
173
+ let stepsSincePlanUpdate = 0;
174
+ const PLAN_NUDGE_THRESHOLD = 2;
175
+ // Plan nudge 节流:同一步骤只发一次,避免模型未响应时重复刷屏。
176
+ // nudge 发出后置 -1;调 todolist 后重置为 0(可重新触发);中间步骤保持负值(不重复)。
177
+ const planNudge = (name) => {
178
+ if (name === 'todolist') {
179
+ stepsSincePlanUpdate = 0;
180
+ return null;
181
+ }
182
+ stepsSincePlanUpdate++;
183
+ if (stepsSincePlanUpdate < PLAN_NUDGE_THRESHOLD)
184
+ return null;
185
+ if (stepsSincePlanUpdate > PLAN_NUDGE_THRESHOLD)
186
+ return null; // 已发过,不再重复
187
+ if (!hasActivePlan())
188
+ return null;
189
+ const p = getActivePlan();
190
+ if (!p || p.status !== 'in_progress')
191
+ return null;
192
+ const hasPending = p.steps.some((s) => s.status === 'pending' || s.status === 'in_progress');
193
+ if (!hasPending)
194
+ return null;
195
+ return ('\n\n[plan-nudge] ⚠ You\'ve run ' + PLAN_NUDGE_THRESHOLD +
196
+ '+ tool calls since the last plan update. ' +
197
+ 'Call `todolist` (action=update) NOW to mark the current step done and set the next step in_progress. ' +
198
+ 'Do this BEFORE making more tool calls.');
199
+ };
169
200
  const recordAndHint = (name, args) => {
170
201
  const fp = `${name}\x00${args}`;
171
202
  const c = (recentToolCalls.get(fp) ?? 0) + 1;
172
203
  recentToolCalls.set(fp, c);
173
- return thrashHint(name, args, c);
204
+ const thrash = thrashHint(name, args, c);
205
+ const nudge = planNudge(name);
206
+ if (!thrash && !nudge)
207
+ return null;
208
+ return [thrash, nudge].filter(Boolean).join('');
174
209
  };
175
210
  history.push({ role: 'user', content: userInput });
176
211
  // drop_context 工具的上下文剔除回调:闭包捕获 history,原地剔除无关旧 tool 结果。
@@ -207,10 +242,6 @@ export async function runAgentCore(opts) {
207
242
  const onToolCall = (name) => {
208
243
  // 文本/思考已流完,模型转而生成 tool_call 参数(可能很长,如 write_file 整篇内容):
209
244
  // 补换行(让随后的 ● 行与 diff 不黏在正文末尾)+ 启「生成中」内联 spinner,内容区不再干等。
210
- if (lastChar && lastChar !== '\n') {
211
- hooks.onTextEnd?.(); // 主 agent:layout.contentWrite('\n')
212
- lastChar = '\n';
213
- }
214
245
  hooks.onToolCall?.(name); // 主 agent:spinner.start(`生成 ${name}…`)
215
246
  };
216
247
  // 中断还原:停 spinner + 补换行 + (已中断)提示 + history 还原到本 turn 前 + 模式还原。
@@ -267,9 +298,7 @@ export async function runAgentCore(opts) {
267
298
  onContextUpdate?.();
268
299
  if (result.toolCalls.length > 0) {
269
300
  hadToolsThisTurn = true;
270
- // 流式正文末尾补换行(若 onToolCall 已补则 lastChar='\n',此处 no-op);防 ● 行黏在正文行尾
271
- if (mode !== 'idle' && lastChar !== '\n')
272
- hooks.onTextEnd?.();
301
+ // 正文与工具批次的分隔由 onToolBatchStart 连同摘要一次性提交,避免两次写屏错位。
273
302
  // 带工具调用的 assistant 消息原样回灌(OpenAI 格式要求)
274
303
  history.push({
275
304
  role: 'assistant',
@@ -287,6 +316,7 @@ export async function runAgentCore(opts) {
287
316
  // 渲染与 history 回灌一律按原顺序;并发只影响执行时序,tool_call_id 仍按序配对。
288
317
  // executeTool 永不抛错(调度器 try/catch 返字符串),故 await 单个 promise 不会抛(永远 resolve 为字符串)。
289
318
  const calls = result.toolCalls;
319
+ hooks.onToolBatchStart?.(calls);
290
320
  let i = 0;
291
321
  while (i < calls.length) {
292
322
  if (READ_TOOL_NAMES.has(calls[i].name)) {
@@ -12,8 +12,6 @@ import { beginTurn } from '../rollback/index.js';
12
12
  import { config } from '../config/index.js';
13
13
  import { runAgentCore, isMutationTool, } from './core.js';
14
14
  import { createPetHooks } from '../pet/state.js';
15
- /** 当前 turn 的 batch id(runAgent 内闭包变量;一条 turn 一轮 tool batch 结束即清空)。 */
16
- let currentBatchId = null;
17
15
  /** 取 userInput 的首行:字符串直接 split;多模态 parts 找首个 text part 再 split。 */
18
16
  function firstLineOf(ui) {
19
17
  if (typeof ui === 'string')
@@ -21,19 +19,24 @@ function firstLineOf(ui) {
21
19
  const first = ui.find((p) => p.type === 'text');
22
20
  return first?.text.split('\n')[0] ?? '';
23
21
  }
24
- /** 工具调用 ● 头:工具名 + 参数摘要(按 tool_calls 原顺序打印,让用户看到本轮跑哪些工具)。
25
- * 重构后改为累积到 BatchRenderer,onToolBatchEnd 时统一打摘要行;
26
- * 展开/折叠由 BatchRenderer + 鼠标 release 决定,本函数不再直接写屏。 */
27
- function writeToolHeader(tc) {
28
- if (!currentBatchId)
29
- currentBatchId = batch.beginBatch();
30
- batch.recordCall(currentBatchId, tc.name, summarizeToolCall(tc.name, tc.arguments));
22
+ /** 完整工具批次一拿到就聚合成一行写屏,结果随后回填到同一可展开项。 */
23
+ function writeToolBatch(calls, leadingBlank) {
24
+ const id = batch.beginBatch();
25
+ for (const tc of calls) {
26
+ batch.recordCall(id, tc.name, summarizeToolCall(tc.name, tc.arguments));
27
+ }
28
+ batch.showPendingBatch(id, layout, leadingBlank);
29
+ return id;
30
+ }
31
+ function appendToolBatch(id, calls) {
32
+ batch.appendPendingCalls(id, calls.map((tc) => ({
33
+ name: tc.name,
34
+ callSummary: summarizeToolCall(tc.name, tc.arguments),
35
+ })), layout);
31
36
  }
32
37
  /** 渲染工具结果:mutation 成功走 diff 块(行号 + 语法高亮,仿 Claude Code);其余走一行 preview。
33
38
  * 同 writeToolHeader,改为累积到 BatchRenderer(只缓存字符串,不写屏)。 */
34
- function writeToolResult(tc, output, parsed, preWriteOld, editStartLine) {
35
- if (!currentBatchId)
36
- return;
39
+ function writeToolResult(batchId, tc, output, parsed, preWriteOld, editStartLine) {
37
40
  let diff = null;
38
41
  if (isMutationTool(tc.name) && parsed && !output.startsWith('错误')) {
39
42
  diff = renderFileChange({
@@ -47,7 +50,7 @@ function writeToolResult(tc, output, parsed, preWriteOld, editStartLine) {
47
50
  });
48
51
  }
49
52
  const preview = diff ? '' : summarizeToolResult(tc.name, output);
50
- batch.recordResult(currentBatchId, tc.name, preview, diff, output);
53
+ batch.completePendingBatch(batchId, tc.name, preview, diff, output, layout);
51
54
  }
52
55
  /**
53
56
  * agent 核心循环(主 agent,TUI 渲染版):
@@ -69,7 +72,6 @@ onContextUpdate) {
69
72
  // 开新轮次(回滚用):首行截断 40,供 /rollback 轮次菜单展示。
70
73
  beginTurn(truncateDisplay(firstLineOf(userInput), 40));
71
74
  layout.contentMode(); // 防御性:运行态光标归输入框光标位供 IME 锚定(enterRunningMode 已置,这里兜底)
72
- currentBatchId = null; // 新 turn 清旧 batch id(防上 turn 残留)
73
75
  // spinner:状态行最前面转圈(思考中 / 生成 / 执行 工具时,状态栏 lead 位显帧 + 文字)。
74
76
  // 经 setStatus 注入状态行(spinnerFrame + statusText),composeStatus 把帧 + 文字放 lead 位;
75
77
  // 不画内容区续写位——内容区在等待期间保持干净,首 token 到达即从续写位开始写正文。
@@ -79,20 +81,29 @@ onContextUpdate) {
79
81
  // lastChar 镜像:core 跟踪流式末字符决定补换行,但 TUI hooks 需读它决定 layout.contentWrite('\n')。
80
82
  // core 的 onTextEnd hook 只在 lastChar !== '\n' 时才调,调后置 '\n';镜像与此同步。
81
83
  let lastChar = '';
84
+ let visibleBlock = 'none';
85
+ let currentBatchId = null;
82
86
  const hooks = {
83
87
  onText: (s) => {
84
88
  spinner.stop(); // 任何正文 token 都停 spinner(首 token 停「思考中」;onToolCall 重启后若又来文本则停「生成中」)。未旋转时 stop 为 no-op。
85
- layout.contentWriteMd(s); // 正文走 markdown 渲染(代码块高亮 / 标题 / 列表 / 行内 …),见 ui/markdown.ts
89
+ const hasVisibleText = s.trim().length > 0;
90
+ let expandedBatchHasBlankTail = false;
91
+ if (hasVisibleText && currentBatchId) {
92
+ batch.finalizePendingBatch(currentBatchId, layout);
93
+ // 展开详情通过中段插入,原摘要后的空行会保留在详情尾部;无需再补一次。
94
+ expandedBatchHasBlankTail = batch.isExpanded(currentBatchId);
95
+ currentBatchId = null;
96
+ }
97
+ const leadingBlank = hasVisibleText && visibleBlock === 'tool' && !expandedBatchHasBlankTail;
98
+ layout.contentWriteMd(s, leadingBlank); // 分隔与正文同次重绘,避免续写位二次定位多出空行
99
+ if (hasVisibleText)
100
+ visibleBlock = 'text';
86
101
  if (s)
87
102
  lastChar = s[s.length - 1];
88
103
  },
89
104
  onToolCall: (name) => {
90
105
  // 文本/思考已流完,模型转而生成 tool_call 参数(可能很长,如 write_file 整篇内容):
91
106
  // 补换行(让随后的 ● 行与 diff 不黏在正文末尾)+ 启「生成中」内联 spinner,内容区不再干等。
92
- if (lastChar && lastChar !== '\n') {
93
- layout.contentWrite('\n');
94
- lastChar = '\n';
95
- }
96
107
  if (name)
97
108
  spinner.start(`生成 ${name}`);
98
109
  },
@@ -104,30 +115,38 @@ onContextUpdate) {
104
115
  lastChar = '\n';
105
116
  }
106
117
  },
107
- onToolHeader: (tc) => writeToolHeader(tc),
118
+ onToolBatchStart: (calls) => {
119
+ if (currentBatchId)
120
+ appendToolBatch(currentBatchId, calls);
121
+ else
122
+ currentBatchId = writeToolBatch(calls, visibleBlock !== 'none');
123
+ visibleBlock = 'tool';
124
+ },
125
+ onToolHeader: () => { },
108
126
  onToolStart: (name) => spinner.start(`执行 ${name}`),
109
127
  onToolDone: () => spinner.stop(),
110
- onToolResult: (tc, output, parsed, preWriteOld, editStartLine) => writeToolResult(tc, output, parsed, preWriteOld, editStartLine),
111
- onToolBatchEnd: () => {
112
- // 收尾:把累积的 batch 渲染成单行摘要(批内 N 个 tool 调用共用一行,
113
- // 鼠标点击该行可展开完整明细——见 ui/batch.ts)。无 batch(模型未调工具)则补空行保持间距。
128
+ onToolResult: (tc, output, parsed, preWriteOld, editStartLine) => {
114
129
  if (currentBatchId) {
115
- const id = currentBatchId;
116
- currentBatchId = null;
117
- batch.endBatch(id, layout);
130
+ writeToolResult(currentBatchId, tc, output, parsed, preWriteOld, editStartLine);
118
131
  }
119
- layout.contentWrite('\n');
120
132
  },
133
+ // 连续无正文的工具步骤保持同一 batch;遇到正文或本轮结束时才收尾。
134
+ onToolBatchEnd: () => { },
121
135
  onNoReply: () => layout.contentWrite(`${ui.dim}(无回复)${ui.reset}\n`),
122
136
  onMaxSteps: () => layout.contentWrite(` ${ui.yellow}●${ui.reset} ${ui.yellow}达到最大步数(${config.maxSteps}),本轮停止。${ui.reset}\n`),
123
137
  onAbort: () => {
124
138
  spinner.stop();
139
+ if (currentBatchId)
140
+ batch.finalizePendingBatch(currentBatchId, layout);
141
+ currentBatchId = null;
125
142
  if (lastChar && lastChar !== '\n')
126
143
  layout.contentWrite('\n');
127
144
  layout.contentWrite(`${ui.dim}(已中断)${ui.reset}\n`);
128
- currentBatchId = null; // 丢弃未收尾 batch
129
145
  },
130
146
  onDone: (elapsedMs, usage) => {
147
+ if (currentBatchId)
148
+ batch.finalizePendingBatch(currentBatchId, layout);
149
+ currentBatchId = null;
131
150
  const tok = formatTurnTokens(usage);
132
151
  layout.contentWrite(` ${ui.dim}✻ Worked for ${fmtElapsed(elapsedMs)}${tok}${ui.reset}\n`);
133
152
  },
@@ -223,6 +223,7 @@ ${buildSnapshotSection()}${config.projectSkillEnabled ? buildProjectSkillSection
223
223
 
224
224
  ## Working notepad (todolist)
225
225
  - For genuinely complex tasks only: explore codebase → clarify with user → create plan → execute step by step. See tool description for details.
226
+ - **Update the plan after each step** — do NOT batch all status updates at the end. The progress chip must reflect real-time state.
226
227
 
227
228
  ## Termination & Reporting
228
229
  - Stop immediately when no more tools are needed; give conclusions directly.
@@ -36,8 +36,10 @@ export const todolistTool = {
36
36
  'BAD: "打开文件A → 修改函数X → 保存文件A → 运行测试" (too fine-grained, just do it)',
37
37
  '',
38
38
  '## UPDATE WORKFLOW',
39
- 'Update in real-time after completing each step (one step = one update, or batch_update for 2-3 at once).',
40
- 'Do NOT batch all updates at the end update as you go so the chip reflects real progress.',
39
+ 'Update the plan AFTER completing each step not before, not in bulk at the end.',
40
+ '- Mark the current step `done` + set the next step `in_progress` in the same `todolist` call.',
41
+ '- Use `update` for single steps; `batch_update` ONLY when 2-3 steps finish simultaneously.',
42
+ '- ⛔ NEVER defer all updates to the end — this defeats the purpose of the progress chip.',
41
43
  'All steps done/skipped → plan auto-finishes, archives, and chip disappears.',
42
44
  '',
43
45
  '## LIFECYCLE',
package/dist/ui/batch.js CHANGED
@@ -20,6 +20,7 @@ const absLineToBatchId = new Map();
20
20
  /** 已展开的 batch id;默认空(全折叠);layout.mouse release 点击摘要行时切。
21
21
  * 含 mutation 的 batch 不在此 set——它们走 forceExpanded 永远展开,与 toggle 隔离。 */
22
22
  const expandedBatches = new Set();
23
+ const expandedEntries = new Map();
23
24
  /** mutation 工具名集合(写盘操作);与 src/agent/core.ts 的 isMutationTool 同步,本模块独立持有
24
25
  * 避免 ui → agent 反向依赖。 */
25
26
  const MUTATION_TOOLS = new Set(['write_file', 'edit_file']);
@@ -33,6 +34,7 @@ export function reset() {
33
34
  batches.clear();
34
35
  absLineToBatchId.clear();
35
36
  expandedBatches.clear();
37
+ expandedEntries.clear();
36
38
  }
37
39
  /** 新建一个 batch(在 agent 拿到第一条 onToolHeader 时调)。返回 id。 */
38
40
  export function beginBatch() {
@@ -89,14 +91,76 @@ function buildSummaryLine(entries) {
89
91
  parts.push(`${n} ${c}`);
90
92
  return ` ${ui.bold}${ui.accent}●${ui.reset} ${ui.dim}Ran ${entries.length} tools · ${parts.join(', ')}${ui.reset}`;
91
93
  }
94
+ /** 连续无正文的下一步工具追加到现有 batch,并原位刷新同一条摘要。 */
95
+ export function appendPendingCalls(id, calls, layout) {
96
+ const b = batches.get(id);
97
+ if (!b)
98
+ return;
99
+ const open = expandedEntries.get(id) ?? new Set();
100
+ const oldLines = expandedBatches.has(id)
101
+ ? buildExpandedLines(b.entries, ' ', open)
102
+ : [];
103
+ for (const call of calls) {
104
+ b.entries.push({ ...call, resultSummary: '', diffBlock: null });
105
+ }
106
+ if (b.summaryAbsIdx >= 0) {
107
+ layout.contentReplaceLine?.(b.summaryAbsIdx, buildSummaryLine(b.entries));
108
+ }
109
+ if (oldLines.length > 0 && layout.contentDeleteFrom) {
110
+ layout.contentDeleteFrom(b.summaryAbsIdx + 1, oldLines.length);
111
+ layout.contentInsertAfter(b.summaryAbsIdx, buildExpandedLines(b.entries, ' ', open));
112
+ }
113
+ }
114
+ /** 工具开始执行时立即写摘要行并注册点击区域;结果稍后回填,不阻塞首屏展示。 */
115
+ export function showPendingBatch(id, layout, leadingBlank = false) {
116
+ const b = batches.get(id);
117
+ if (!b || b.summaryAbsIdx >= 0 || b.entries.length === 0)
118
+ return;
119
+ // 分隔换行与摘要必须在同一次 contentWrite 中提交。拆成两次时,第二次入口会重新
120
+ // 校正终端续写行,在 pending 空行状态下可能把光标再推进一行,造成视觉上的双空行。
121
+ layout.contentWrite(`${leadingBlank ? '\n' : ''}${buildSummaryLine(b.entries)}\n`);
122
+ b.summaryAbsIdx = Math.max(0, layout.totalRows() - 2);
123
+ absLineToBatchId.set(b.summaryAbsIdx, b.id);
124
+ }
125
+ /** 单个工具完成后,把结果回填到正在展示的批量项。 */
126
+ export function completePendingBatch(id, name, resultSummary, diffBlock, fullOutput, layout) {
127
+ const b = batches.get(id);
128
+ const open = expandedEntries.get(id) ?? new Set();
129
+ const oldLines = b && expandedBatches.has(id)
130
+ ? buildExpandedLines(b.entries, ' ', open)
131
+ : [];
132
+ recordResult(id, name, resultSummary, diffBlock, fullOutput);
133
+ if (b && oldLines.length > 0 && layout?.contentDeleteFrom) {
134
+ layout.contentDeleteFrom(b.summaryAbsIdx + 1, oldLines.length);
135
+ layout.contentInsertAfter(b.summaryAbsIdx, buildExpandedLines(b.entries, ' ', open));
136
+ }
137
+ }
138
+ /** 整批工具完成后再自动展开 mutation,确保展开内容包含批内所有结果。 */
139
+ export function finalizePendingBatch(id, layout) {
140
+ const b = batches.get(id);
141
+ if (!b)
142
+ return;
143
+ b.forceExpanded = b.entries.some((e) => isMutationTool(e.name));
144
+ if (b.forceExpanded) {
145
+ const open = expandedEntries.get(b.id) ?? new Set();
146
+ b.entries.forEach((e, i) => { if (isMutationTool(e.name))
147
+ open.add(i); });
148
+ expandedEntries.set(b.id, open);
149
+ if (!expandedBatches.has(b.id))
150
+ expand(b, layout);
151
+ }
152
+ }
92
153
  // ── 展开/折叠 ──
93
154
  /** 把 batch 的详情行展开成自洽行数组(供 layout.contentInsertAfter 走 mid-buffer 插入)。
94
155
  * 每行末尾必须以 \x1B[0m 收尾(SGR 自洽模型),行内允许含 SGR(行末 reset 不影响行内样式),
95
156
  * 但**绝不**带 \n——rows[] 是行数组,不是流输出。 */
96
- function buildExpandedLines(entries, indent = ' ') {
157
+ function buildExpandedLines(entries, indent = ' ', openEntries) {
97
158
  const lines = [];
98
- for (const e of entries) {
159
+ for (let entryIndex = 0; entryIndex < entries.length; entryIndex++) {
160
+ const e = entries[entryIndex];
99
161
  lines.push(`${indent}${ui.bold}${ui.accent}●${ui.reset} ${ui.accent}${e.name}${ui.reset} ${ui.dim}${e.callSummary}${ui.reset}\x1B[0m`);
162
+ if (openEntries && !openEntries.has(entryIndex))
163
+ continue;
100
164
  if (e.diffBlock) {
101
165
  // diff 块多行文本(由 renderFileChange 渲染);按 \n 拆成物理行,
102
166
  // 每行单独入 rows[]。行末 reset 由本函数统一追加(若原行已带 reset,终端合并即可)。
@@ -127,6 +191,35 @@ function buildExpandedLines(entries, indent = ' ') {
127
191
  }
128
192
  return lines;
129
193
  }
194
+ /**
195
+ * 中断清理:abort 时正在执行的 batch 可能已写屏但尚未 finalize。
196
+ * 从 buffer 中删除已写入的 pending 摘要行,避免中断后残留无结果的工具执行信息。
197
+ * 未写屏(summaryAbsIdx < 0)时仅清理内存记录。
198
+ */
199
+ export function discardPendingBatch(id, layout) {
200
+ const b = batches.get(id);
201
+ if (!b)
202
+ return;
203
+ // 已写屏且未被 finalize(finalize 后 forceExpanded/expandedBatches 已设置)
204
+ // 只删 pending 态的摘要行——已 finalize 的 batch 含完整结果,保留给回看
205
+ if (b.summaryAbsIdx >= 0 && !expandedBatches.has(b.id) && !b.forceExpanded) {
206
+ // contentDeleteFrom 内部已调 shiftBatchesAfter,此处不重复调
207
+ layout.contentDeleteFrom(b.summaryAbsIdx, 1);
208
+ }
209
+ // 清理内存
210
+ batches.delete(id);
211
+ expandedBatches.delete(id);
212
+ expandedEntries.delete(id);
213
+ // 清理 absLineToBatchId 中该 id 的映射
214
+ if (b.summaryAbsIdx >= 0) {
215
+ for (const [idx, bid] of absLineToBatchId) {
216
+ if (bid === id) {
217
+ absLineToBatchId.delete(idx);
218
+ break;
219
+ }
220
+ }
221
+ }
222
+ }
130
223
  /** 在 batch 收尾时(onToolBatchEnd):写摘要行 + 登记 summaryAbsIdx;若已展开(回放场景)立即插详情。 */
131
224
  export function endBatch(id, layout) {
132
225
  const b = batches.get(id);
@@ -160,6 +253,26 @@ export function endBatch(id, layout) {
160
253
  export function findBatchByAbsLine(absLine) {
161
254
  return absLineToBatchId.get(absLine) ?? null;
162
255
  }
256
+ export function findBatchHit(absLine) {
257
+ const summaryId = absLineToBatchId.get(absLine);
258
+ if (summaryId)
259
+ return { id: summaryId, entryIndex: null };
260
+ for (const id of expandedBatches) {
261
+ const b = batches.get(id);
262
+ if (!b || absLine <= b.summaryAbsIdx)
263
+ continue;
264
+ const open = expandedEntries.get(id) ?? new Set();
265
+ let row = b.summaryAbsIdx + 1;
266
+ for (let i = 0; i < b.entries.length; i++) {
267
+ if (absLine === row)
268
+ return { id, entryIndex: i };
269
+ row += 1;
270
+ if (open.has(i))
271
+ row += buildExpandedLines([b.entries[i]]).length - 1;
272
+ }
273
+ }
274
+ return null;
275
+ }
163
276
  /** 当前 batch 是否已展开。 */
164
277
  export function isExpanded(id) {
165
278
  return expandedBatches.has(id);
@@ -189,15 +302,34 @@ export function toggleBatch(id, layout) {
189
302
  expand(b, layout);
190
303
  }
191
304
  }
305
+ export function toggleEntry(id, entryIndex, layout) {
306
+ const b = batches.get(id);
307
+ if (!b || !expandedBatches.has(id) || !b.entries[entryIndex])
308
+ return;
309
+ const open = expandedEntries.get(id) ?? new Set();
310
+ const oldLines = buildExpandedLines(b.entries, ' ', open);
311
+ if (open.has(entryIndex))
312
+ open.delete(entryIndex);
313
+ else
314
+ open.add(entryIndex);
315
+ expandedEntries.set(id, open);
316
+ const newLines = buildExpandedLines(b.entries, ' ', open);
317
+ layout.contentDeleteFrom(b.summaryAbsIdx + 1, oldLines.length);
318
+ layout.contentInsertAfter(b.summaryAbsIdx, newLines);
319
+ }
192
320
  function expand(b, layout) {
193
- const lines = buildExpandedLines(b.entries);
321
+ const open = expandedEntries.get(b.id) ?? new Set();
322
+ expandedEntries.set(b.id, open);
323
+ const lines = buildExpandedLines(b.entries, ' ', open);
194
324
  layout.contentInsertAfter(b.summaryAbsIdx, lines);
195
325
  expandedBatches.add(b.id);
196
326
  }
197
327
  function collapse(b, layout) {
198
- const lines = buildExpandedLines(b.entries);
328
+ const open = expandedEntries.get(b.id) ?? new Set();
329
+ const lines = buildExpandedLines(b.entries, ' ', open);
199
330
  layout.contentDeleteFrom(b.summaryAbsIdx + 1, lines.length);
200
331
  expandedBatches.delete(b.id);
332
+ expandedEntries.delete(b.id);
201
333
  }
202
334
  /**
203
335
  * 当 buffer 中段插/删 N 行后,所有受影响 batch 的 summaryAbsIdx 需平移。
@@ -131,6 +131,11 @@ export function sliceFromEnd(offset, count) {
131
131
  export function totalRows() {
132
132
  return rows.length + (hasCurrent ? 1 : 0);
133
133
  }
134
+ /** 已提交行数(不含 hasCurrent 空行)。供续写位校正:breakRow 后 hasCurrent=true 时光标已在
135
+ * rows.length+1 位,若用 totalRows()+1 会多跳 1 行(2 空行 bug)。 */
136
+ export function committedRows() {
137
+ return rows.length;
138
+ }
134
139
  /** 取绝对行索引(0-based,含当前行)的原始自洽行;越界返 null。供鼠标选区文本提取。 */
135
140
  export function lineAt(abs) {
136
141
  const all = snapshot();
package/dist/ui/layout.js CHANGED
@@ -47,6 +47,7 @@ let sigwinchHandler = null;
47
47
  // 非 md 写(contentWrite)先 commitMd 收尾(清 segMark,后续写不再被 setLines 截断)。
48
48
  let mdActive = false;
49
49
  let mdBuf = '';
50
+ let mdLeadingBlank = false;
50
51
  let selection = null;
51
52
  let selecting = false; // 左键按下中(press→release 之间)
52
53
  let mouseEnabled = true; // 导航菜单(picker)期间置 false:只吞报表不做选区/滚动,防菜单被 viewport 重画覆盖
@@ -191,13 +192,15 @@ export function contentWrite(s) {
191
192
  const bottom = g.contentBottom;
192
193
  // resize 后 contentRow 可能过时(拖终端框):
193
194
  // - 缩小:contentRow > 新 bottom → 钳到新 bottom
194
- // - 放大:contentRow < 新 bottom 且回尾 → 推进到 min(total+1, bottom)
195
- // total+1 是合法「待写位」(所有行已 breakRow 提交、光标在新空行);用 total 会把续写位拉回到
195
+ // - 放大:contentRow < 新 bottom 且回尾 → 推进到 min(committed+1, bottom)
196
+ // committed+1 是合法「待写位」(所有行已 breakRow 提交、光标在新空行);用 committed 会把续写位拉回到
196
197
  // 最后一行 banner/content 上,首次 contentWrite 覆盖 banner(首条消息「插到 logo 下面」bug)。
198
+ // 注意:不能用 totalRows()+1——breakRow 后 hasCurrent=true,totalRows 已含当前空行,再 +1 会多跳一行,
199
+ // 导致 onDone 摘要行等写 \n 后的 contentWrite 入口多跳 1 行(2 空行 bug)。
197
200
  if (contentRow > bottom)
198
201
  contentRow = bottom;
199
202
  else if (scrollOffset === 0 && contentRow < bottom) {
200
- contentRow = Math.min(content.totalRows() + 1, bottom);
203
+ contentRow = Math.min(content.committedRows() + 1, bottom);
201
204
  }
202
205
  const startRow = contentRow;
203
206
  const startCol = contentCol;
@@ -310,10 +313,11 @@ export function contentWrite(s) {
310
313
  }
311
314
  }
312
315
  /** 进入 markdown 流式段:标记段起点(layout 续写位 + content.beginSegment),清 accumulator。 */
313
- function beginMdSegment() {
316
+ function beginMdSegment(leadingBlank = false) {
314
317
  segmentStartRow = contentRow;
315
318
  content.beginSegment();
316
319
  mdBuf = '';
320
+ mdLeadingBlank = leadingBlank;
317
321
  mdActive = true;
318
322
  }
319
323
  /** 提交 markdown 段:清 accumulator + content.commitSegment(后续非 md 写不再被 setLines 截断)。 */
@@ -322,6 +326,7 @@ function commitMd() {
322
326
  return;
323
327
  mdActive = false;
324
328
  mdBuf = '';
329
+ mdLeadingBlank = false;
325
330
  content.commitSegment();
326
331
  }
327
332
  // ── Banner(启动横幅/模式切换横幅)固定顶部行 ──
@@ -401,13 +406,15 @@ export function bannerHeight() {
401
406
  * 物理重画用 repaintViewport(全内容区,原子一次 write 无闪烁)— md 段是缓冲尾,viewport 显尾即显段。
402
407
  * 滚动回看(scrollOffset>0)只更新缓冲不物理写(回尾时显);打字中照常物理写——单次 write 结尾 cup 回输入框,IME 锚定不动。
403
408
  */
404
- export function contentWriteMd(s) {
409
+ export function contentWriteMd(s, leadingBlank = false) {
405
410
  if (!active || !ui.isTTY) {
406
411
  stdout.write(s);
407
412
  return;
408
413
  }
409
414
  if (!mdActive)
410
- beginMdSegment();
415
+ beginMdSegment(leadingBlank);
416
+ else if (leadingBlank)
417
+ mdLeadingBlank = true;
411
418
  mdBuf += s;
412
419
  const g = getGeo();
413
420
  // 滚动回看冻结(同 contentWrite):scrollOffset>0 时 setLines 替换段会改缓冲行数,若 offset 不变,
@@ -415,7 +422,12 @@ export function contentWriteMd(s) {
415
422
  // offset += delta(可负:setLines 重渲染可能缩行)冻住视图。md 段在尾,窗口在上方,冻结后不重叠。
416
423
  const scrolled = scrollOffset > 0;
417
424
  const totalBefore = scrolled ? content.totalRows() : 0;
418
- const lines = renderMarkdown(mdBuf, g.cols);
425
+ // 工具→正文边界由本层统一提供一个空行;模型偶尔自带 \n/\n\n,先剥掉,避免随机叠成两行。
426
+ const markdownSource = mdLeadingBlank
427
+ ? mdBuf.replace(/^(?:[ \t]*\r?\n)+/, '')
428
+ : mdBuf;
429
+ const rendered = renderMarkdown(markdownSource, g.cols);
430
+ const lines = mdLeadingBlank ? [ui.reset, ...rendered] : rendered;
419
431
  content.setLines(lines);
420
432
  const segRows = lines.length;
421
433
  const available = g.contentBottom - segmentStartRow + 1;
@@ -467,6 +479,8 @@ export function clearContent() {
467
479
  scrollLockUntil = 0;
468
480
  mdActive = false;
469
481
  mdBuf = '';
482
+ mdLeadingBlank = false;
483
+ mdLeadingBlank = false;
470
484
  content.reset();
471
485
  notifyContentReset(); // batch 渲染器同步重置(batch 摘要行索引全部失效)
472
486
  stdout.write(esc.home);
@@ -576,6 +590,12 @@ export function contentDeleteFrom(startIdx, n) {
576
590
  export function totalRows() {
577
591
  return content.totalRows();
578
592
  }
593
+ /** 原位替换一条已提交的内容行并重画;供连续工具链更新同一条 batch 摘要。 */
594
+ export function contentReplaceLine(absIdx, line) {
595
+ content.replaceHead(absIdx, [line.endsWith(ui.reset) ? line : line + ui.reset]);
596
+ if (active && scrollOffset === 0)
597
+ repaintViewport();
598
+ }
579
599
  /** 清空内容区时通知 batch 渲染器重置(摘要行映射与展开态)。 */
580
600
  export function notifyContentReset() {
581
601
  // 动态 import 避免循环;模块级 reset() 只清映射,不动 batch 内部数据(id 与 entries 仍可重用)
@@ -1133,12 +1153,16 @@ function handleMouseEvent(e) {
1133
1153
  void (async () => {
1134
1154
  try {
1135
1155
  const m = await import('./batch.js');
1136
- const id = m.findBatchByAbsLine(absClick);
1137
- if (id) {
1138
- m.toggleBatch(id, {
1156
+ const hit = m.findBatchHit(absClick);
1157
+ if (hit) {
1158
+ const batchLayout = {
1139
1159
  contentInsertAfter: (after, lines) => contentInsertAfter(after, lines),
1140
1160
  contentDeleteFrom: (start, n) => contentDeleteFrom(start, n),
1141
- });
1161
+ };
1162
+ if (hit.entryIndex === null)
1163
+ m.toggleBatch(hit.id, batchLayout);
1164
+ else
1165
+ m.toggleEntry(hit.id, hit.entryIndex, batchLayout);
1142
1166
  selection = null;
1143
1167
  repaintViewport();
1144
1168
  repaint();
@@ -1422,11 +1446,13 @@ export function paintLiveAtCursor(text) {
1422
1446
  // 首次 contentWrite 覆盖 banner(首条消息「插到 logo 下面」bug)。
1423
1447
  // 否则 cup 到旧行号→帧画在屏幕中间(旧 bottom 位置),而非内容末尾/最底部。
1424
1448
  const g = getGeo();
1425
- const total = content.totalRows();
1449
+ const committed = content.committedRows();
1426
1450
  if (contentRow > g.contentBottom)
1427
1451
  contentRow = g.contentBottom;
1428
1452
  if (scrollOffset === 0 && contentRow < g.contentBottom) {
1429
- contentRow = Math.min(total + 1, g.contentBottom);
1453
+ // committed+1 而非 total+1:breakRow 后 hasCurrent=true,totalRows 已含当前空行,+1 会多跳一行
1454
+ // (contentWrite 入口的同类逻辑已同步修正,见上方注释)。
1455
+ contentRow = Math.min(committed + 1, g.contentBottom);
1430
1456
  }
1431
1457
  let out = '';
1432
1458
  if (frameRow && (frameRow !== contentRow || frameCol !== contentCol)) {
@@ -1846,6 +1872,7 @@ export function enterAltScreen() {
1846
1872
  scrollLockUntil = 0;
1847
1873
  mdActive = false;
1848
1874
  mdBuf = '';
1875
+ mdLeadingBlank = false;
1849
1876
  selection = null;
1850
1877
  selecting = false;
1851
1878
  content.reset();
@@ -1859,13 +1886,14 @@ export function enterAltScreen() {
1859
1886
  // 重画 repaintViewport 防抖(下面 timer),避免连续拖动闪烁;但行号/区域必须立即正确。
1860
1887
  const g = getGeo(footerH);
1861
1888
  const total = content.totalRows();
1889
+ const committed = content.committedRows();
1862
1890
  // 缩小:contentRow > 新 bottom → 钳到新 bottom
1863
1891
  if (contentRow > g.contentBottom)
1864
1892
  contentRow = g.contentBottom;
1865
- // 放大:contentRow < 新 bottom 且回尾(offset=0)→ 推进到 min(total+1, bottom)
1866
- // total+1 是合法「待写位」;用 total 会把续写位拉回最后一行,首次 contentWrite 覆盖 banner
1893
+ // 放大:contentRow < 新 bottom 且回尾(offset=0)→ 推进到 min(committed+1, bottom)
1894
+ // committed+1 是合法「待写位」;用 total+1 hasCurrent=true(breakRow 后)时会多跳一行。
1867
1895
  if (scrollOffset === 0 && contentRow < g.contentBottom) {
1868
- contentRow = Math.min(total + 1, g.contentBottom);
1896
+ contentRow = Math.min(committed + 1, g.contentBottom);
1869
1897
  }
1870
1898
  if (frameRow && frameRow > g.contentBottom)
1871
1899
  frameRow = g.contentBottom;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {