mocode-ai 0.5.4 → 0.5.5

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/ui/batch.js CHANGED
@@ -17,29 +17,26 @@ const batches = new Map();
17
17
  * buffer 行数变化时本表可能漂移——但只在 insertAfter/deleteFrom 后由本模块同步更新,
18
18
  * 并保持 buffer 当前状态对应。 */
19
19
  const absLineToBatchId = new Map();
20
- /** 已展开的 batch id;默认空(全折叠);layout.mouse release 点击摘要行时切。
21
- * 含 mutation 的 batch 不在此 set——它们走 forceExpanded 永远展开,与 toggle 隔离。 */
20
+ /** 第一层展开后,工具概要行的绝对索引 → 对应 entry。 */
21
+ const absLineToEntry = new Map();
22
+ /** 已展开第一层工具列表的 batch id。 */
22
23
  const expandedBatches = new Set();
23
- const expandedEntries = new Map();
24
- /** mutation 工具名集合(写盘操作);与 src/agent/core.ts 的 isMutationTool 同步,本模块独立持有
25
- * 避免 ui → agent 反向依赖。 */
26
- const MUTATION_TOOLS = new Set(['write_file', 'edit_file']);
27
- function isMutationTool(name) {
28
- return MUTATION_TOOLS.has(name);
29
- }
30
24
  /** 展开时完整输出的最大行数;超出截断,避免巨型输出撑爆 viewport。 */
31
25
  const MAX_EXPAND_LINES = 200;
26
+ export function isMutationToolName(name) {
27
+ return name === 'write_file' || name === 'edit_file';
28
+ }
32
29
  /** 通知 buffer 整体清空(clearContent / exitAltScreen / 新一轮 turn)——本模块状态同步归零。 */
33
30
  export function reset() {
34
31
  batches.clear();
35
32
  absLineToBatchId.clear();
33
+ absLineToEntry.clear();
36
34
  expandedBatches.clear();
37
- expandedEntries.clear();
38
35
  }
39
36
  /** 新建一个 batch(在 agent 拿到第一条 onToolHeader 时调)。返回 id。 */
40
37
  export function beginBatch() {
41
38
  const id = `b${++_idCounter}`;
42
- batches.set(id, { id, summaryAbsIdx: -1, entries: [], forceExpanded: false });
39
+ batches.set(id, { id, summaryAbsIdx: -1, entries: [], expandedEntries: new Set() });
43
40
  return id;
44
41
  }
45
42
  /** 记一条工具调用(在 onToolHeader 时调,与 setEntryResult 配对;entries 顺序 = agent 调用顺序)。 */
@@ -80,7 +77,9 @@ function buildSummaryLine(entries) {
80
77
  }
81
78
  if (entries.length === 1) {
82
79
  const e = entries[0];
83
- return ` ${ui.bold}${ui.accent}●${ui.reset} ${ui.accent}${e.name}${ui.reset} ${ui.dim}${e.callSummary}${ui.reset}`;
80
+ // 实时摘要必须稳定保持单行;完整参数放在第一层工具概要中,避免长 JSON 自动折行后
81
+ // 原地刷新只能覆盖最后一条物理行、残留旧摘要前半段。
82
+ return ` ${ui.bold}${ui.accent}●${ui.reset} ${ui.dim}Ran 1 tool · ${e.name} 1${ui.reset}`;
84
83
  }
85
84
  // N>1:同类合并 "read_file 3, glob 1, grep 1"
86
85
  const counts = new Map();
@@ -91,151 +90,57 @@ function buildSummaryLine(entries) {
91
90
  parts.push(`${n} ${c}`);
92
91
  return ` ${ui.bold}${ui.accent}●${ui.reset} ${ui.dim}Ran ${entries.length} tools · ${parts.join(', ')}${ui.reset}`;
93
92
  }
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
- }
153
93
  // ── 展开/折叠 ──
154
94
  /** 把 batch 的详情行展开成自洽行数组(供 layout.contentInsertAfter 走 mid-buffer 插入)。
155
95
  * 每行末尾必须以 \x1B[0m 收尾(SGR 自洽模型),行内允许含 SGR(行末 reset 不影响行内样式),
156
96
  * 但**绝不**带 \n——rows[] 是行数组,不是流输出。 */
157
- function buildExpandedLines(entries, indent = ' ', openEntries) {
97
+ function buildEntryDetailLines(e, indent = ' ') {
158
98
  const lines = [];
159
- for (let entryIndex = 0; entryIndex < entries.length; entryIndex++) {
160
- const e = entries[entryIndex];
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;
164
- if (e.diffBlock) {
165
- // diff 块多行文本(由 renderFileChange 渲染);按 \n 拆成物理行,
166
- // 每行单独入 rows[]。行末 reset 由本函数统一追加(若原行已带 reset,终端合并即可)。
167
- const block = e.diffBlock.endsWith('\n') ? e.diffBlock : e.diffBlock + '\n';
168
- for (const line of block.split('\n')) {
169
- if (line === '' && lines.length > 0 && lines[lines.length - 1] === '')
170
- continue; // 折叠连续空行
171
- if (line === '' && lines.length > 0)
172
- continue; // 跳过首尾空行(diff 头/尾换行)
173
- lines.push(line.endsWith('\x1B[0m') ? line : line + '\x1B[0m');
174
- }
99
+ if (e.diffBlock) {
100
+ // diff 块多行文本(由 renderFileChange 渲染);按 \n 拆成物理行,
101
+ // 每行单独入 rows[]。行末 reset 由本函数统一追加(若原行已带 reset,终端合并即可)。
102
+ const block = e.diffBlock.endsWith('\n') ? e.diffBlock : e.diffBlock + '\n';
103
+ for (const line of block.split('\n')) {
104
+ if (line === '' && lines.length > 0 && lines[lines.length - 1] === '')
105
+ continue; // 折叠连续空行
106
+ if (line === '' && lines.length > 0)
107
+ continue; // 跳过首尾空行(diff 头/尾换行)
108
+ lines.push(line.endsWith('\x1B[0m') ? line : line + '\x1B[0m');
175
109
  }
176
- else if (e.fullOutput) {
177
- // 完整工具输出(纯文本):按行展开,每行缩进 + dim 样式;长输出截断到 MAX_EXPAND_LINES 行
178
- const rawLines = e.fullOutput.split('\n');
179
- const truncated = rawLines.length > MAX_EXPAND_LINES;
180
- const displayLines = truncated ? rawLines.slice(0, MAX_EXPAND_LINES) : rawLines;
181
- for (const line of displayLines) {
182
- lines.push(`${indent}${ui.gray}${line}${ui.reset}\x1B[0m`);
183
- }
184
- if (truncated) {
185
- lines.push(`${indent}${ui.dim}… (${rawLines.length - MAX_EXPAND_LINES} more lines)${ui.reset}\x1B[0m`);
186
- }
110
+ }
111
+ else if (e.fullOutput) {
112
+ // 完整工具输出(纯文本):按行展开,每行缩进 + dim 样式;长输出截断到 MAX_EXPAND_LINES 行
113
+ const rawLines = e.fullOutput.split('\n');
114
+ const truncated = rawLines.length > MAX_EXPAND_LINES;
115
+ const displayLines = truncated ? rawLines.slice(0, MAX_EXPAND_LINES) : rawLines;
116
+ for (const line of displayLines) {
117
+ lines.push(`${indent}${ui.gray}${line}${ui.reset}\x1B[0m`);
187
118
  }
188
- else if (e.resultSummary) {
189
- lines.push(`${indent}${ui.gray}↳ ${e.resultSummary}${ui.reset}\x1B[0m`);
119
+ if (truncated) {
120
+ lines.push(`${indent}${ui.dim}… (${rawLines.length - MAX_EXPAND_LINES} more lines)${ui.reset}\x1B[0m`);
190
121
  }
191
122
  }
123
+ else if (e.resultSummary) {
124
+ lines.push(`${indent}${ui.gray}↳ ${e.resultSummary}${ui.reset}\x1B[0m`);
125
+ }
192
126
  return lines;
193
127
  }
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
- }
128
+ /** 第一层只展示有哪些调用及其简短结果,不展开完整输出。 */
129
+ function buildExpandedLines(entries, indent = ' ') {
130
+ return entries.map((e) => {
131
+ const result = e.resultSummary ? ` ${ui.gray}↳ ${e.resultSummary}${ui.reset}` : '';
132
+ return `${indent}${ui.bold}${ui.accent}●${ui.reset} ${ui.accent}${e.name}${ui.reset} ${ui.dim}${e.callSummary}${ui.reset}${result}\x1B[0m`;
133
+ });
222
134
  }
223
135
  /** 在 batch 收尾时(onToolBatchEnd):写摘要行 + 登记 summaryAbsIdx;若已展开(回放场景)立即插详情。 */
224
136
  export function endBatch(id, layout) {
225
137
  const b = batches.get(id);
226
- if (!b || b.summaryAbsIdx >= 0)
227
- return; // 幂等
228
- // 含 mutation(write_file/edit_file)时强制展开——写盘操作必须让用户看到 diff
229
- b.forceExpanded = b.entries.some((e) => isMutationTool(e.name));
230
- // 单条 mutation 调用:摘要行("● edit_file path")与展开详情头逐字重复,跳过摘要行、只写详情
231
- // (N>1 时摘要行是聚合信息 "Ran N tools · ...",与详情不重复,两者照常都写)。
232
- if (b.forceExpanded && b.entries.length === 1) {
233
- // 无摘要行可当父行,详情头改用顶层 2 空格缩进(与 buildSummaryLine/diff head 对齐,而非嵌套的 4 空格)
234
- const lines = buildExpandedLines(b.entries, ' ');
235
- layout.contentWrite(lines.join('\n') + '\n');
236
- b.summaryAbsIdx = Math.max(0, layout.totalRows() - 1 - lines.length);
138
+ if (!b)
139
+ return;
140
+ if (b.summaryAbsIdx >= 0) {
141
+ layout.contentReplaceLine?.(b.summaryAbsIdx, buildSummaryLine(b.entries));
142
+ // 执行阶段只展示实时摘要;到 endBatch 才开放点击,避免未完成 batch 的第一层列表失步。
237
143
  absLineToBatchId.set(b.summaryAbsIdx, b.id);
238
- expandedBatches.add(b.id); // 已展开;防止 toggleBatch 再次 expand() 造成重复插入
239
144
  return;
240
145
  }
241
146
  const summary = buildSummaryLine(b.entries);
@@ -244,35 +149,31 @@ export function endBatch(id, layout) {
244
149
  // 摘要行绝对索引 = totalRows - 2(hasCurrent 那行是新空行)
245
150
  b.summaryAbsIdx = Math.max(0, layout.totalRows() - 2);
246
151
  absLineToBatchId.set(b.summaryAbsIdx, b.id);
247
- // forceExpanded 时立刻展开(让 diff 在收尾后立即可见,无需点击)
248
- if (b.forceExpanded) {
249
- expand(b, layout);
152
+ }
153
+ /**
154
+ * 工具执行中立即显示/刷新摘要,但暂不登记点击命中;endBatch 收尾后才开放两层展开。
155
+ */
156
+ export function showLiveBatch(id, layout) {
157
+ const b = batches.get(id);
158
+ if (!b)
159
+ return;
160
+ const summary = buildSummaryLine(b.entries);
161
+ if (b.summaryAbsIdx < 0) {
162
+ layout.contentWrite(summary + '\n');
163
+ b.summaryAbsIdx = Math.max(0, layout.totalRows() - 2);
164
+ // 首条摘要通过增量 contentWrite 落屏时,markdown→普通内容的边界可能只更新了
165
+ // buffer/续写位;直到第二个 header 的 contentReplaceLine 或后续正文重绘才完全可见。
166
+ // 立即按 buffer 原子重画,确保慢工具执行期间摘要前的空行已经显示。
167
+ layout.repaintViewport?.();
168
+ }
169
+ else {
170
+ layout.contentReplaceLine(b.summaryAbsIdx, summary);
250
171
  }
251
172
  }
252
173
  /** 绝对行索引 → 命中 batch id(用于鼠标 release 反查);非摘要行返 null。 */
253
174
  export function findBatchByAbsLine(absLine) {
254
175
  return absLineToBatchId.get(absLine) ?? null;
255
176
  }
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
- }
276
177
  /** 当前 batch 是否已展开。 */
277
178
  export function isExpanded(id) {
278
179
  return expandedBatches.has(id);
@@ -283,18 +184,11 @@ export function isExpanded(id) {
283
184
  * 展开:把详情行插入摘要行下方(mid-buffer insert)。
284
185
  * 同步更新 absLineToBatchId 中所有受影响的索引:
285
186
  * - 删除/插入点之后的 batch 摘要行索引相应平移。
286
- * 含 mutation(write_file/edit_file)的 batch 强制展开——不允许折叠(写盘操作必须始终可见)。
287
187
  */
288
188
  export function toggleBatch(id, layout) {
289
189
  const b = batches.get(id);
290
190
  if (!b)
291
191
  return;
292
- if (b.forceExpanded) {
293
- // 写盘操作的 batch 强制展开,toggle 拒绝折叠(用户能看到完整 diff 即用)
294
- if (!expandedBatches.has(id))
295
- expand(b, layout);
296
- return;
297
- }
298
192
  if (expandedBatches.has(id)) {
299
193
  collapse(b, layout);
300
194
  }
@@ -302,34 +196,67 @@ export function toggleBatch(id, layout) {
302
196
  expand(b, layout);
303
197
  }
304
198
  }
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
- }
320
199
  function expand(b, layout) {
321
- const open = expandedEntries.get(b.id) ?? new Set();
322
- expandedEntries.set(b.id, open);
323
- const lines = buildExpandedLines(b.entries, ' ', open);
200
+ const lines = buildExpandedLines(b.entries);
324
201
  layout.contentInsertAfter(b.summaryAbsIdx, lines);
325
202
  expandedBatches.add(b.id);
203
+ for (let i = 0; i < b.entries.length; i++) {
204
+ absLineToEntry.set(b.summaryAbsIdx + 1 + i, { batchId: b.id, entryIndex: i });
205
+ }
206
+ }
207
+ /** mutation 独占 batch 收尾后立即展示其调用概要和 diff。 */
208
+ export function expandSingleEntryFully(id, layout) {
209
+ const b = batches.get(id);
210
+ if (!b || b.entries.length !== 1 || expandedBatches.has(id))
211
+ return;
212
+ const lines = [
213
+ ...buildExpandedLines(b.entries),
214
+ ...buildEntryDetailLines(b.entries[0]),
215
+ ];
216
+ layout.contentInsertAfter(b.summaryAbsIdx, lines);
217
+ expandedBatches.add(id);
218
+ b.expandedEntries.add(0);
219
+ absLineToEntry.set(b.summaryAbsIdx + 1, { batchId: id, entryIndex: 0 });
326
220
  }
327
221
  function collapse(b, layout) {
328
- const open = expandedEntries.get(b.id) ?? new Set();
329
- const lines = buildExpandedLines(b.entries, ' ', open);
330
- layout.contentDeleteFrom(b.summaryAbsIdx + 1, lines.length);
222
+ let lineCount = b.entries.length;
223
+ for (const i of b.expandedEntries)
224
+ lineCount += buildEntryDetailLines(b.entries[i]).length;
225
+ layout.contentDeleteFrom(b.summaryAbsIdx + 1, lineCount);
331
226
  expandedBatches.delete(b.id);
332
- expandedEntries.delete(b.id);
227
+ b.expandedEntries.clear();
228
+ for (const [idx, target] of absLineToEntry) {
229
+ if (target.batchId === b.id)
230
+ absLineToEntry.delete(idx);
231
+ }
232
+ }
233
+ /** 绝对行索引 → 第一层中的具体工具调用。 */
234
+ export function findEntryByAbsLine(absLine) {
235
+ return absLineToEntry.get(absLine) ?? null;
236
+ }
237
+ /** 第二层:只展开/折叠某一个工具的完整输出。 */
238
+ export function toggleEntry(batchId, entryIndex, layout) {
239
+ const b = batches.get(batchId);
240
+ if (!b || !expandedBatches.has(batchId))
241
+ return;
242
+ let headerIdx = -1;
243
+ for (const [idx, target] of absLineToEntry) {
244
+ if (target.batchId === batchId && target.entryIndex === entryIndex)
245
+ headerIdx = idx;
246
+ }
247
+ if (headerIdx < 0)
248
+ return;
249
+ const details = buildEntryDetailLines(b.entries[entryIndex]);
250
+ if (details.length === 0)
251
+ return;
252
+ if (b.expandedEntries.has(entryIndex)) {
253
+ layout.contentDeleteFrom(headerIdx + 1, details.length);
254
+ b.expandedEntries.delete(entryIndex);
255
+ }
256
+ else {
257
+ layout.contentInsertAfter(headerIdx, details);
258
+ b.expandedEntries.add(entryIndex);
259
+ }
333
260
  }
334
261
  /**
335
262
  * 当 buffer 中段插/删 N 行后,所有受影响 batch 的 summaryAbsIdx 需平移。
@@ -354,6 +281,21 @@ export function shiftBatchesAfter(absIdx, delta) {
354
281
  absLineToBatchId.clear();
355
282
  for (const [k, v] of next)
356
283
  absLineToBatchId.set(k, v);
284
+ const nextEntries = new Map();
285
+ // expand() 先插入整组概要行、再登记其命中位置;layout 随后的异步 shift 通知不应把
286
+ // 这批“刚插入”的概要行再次平移。插入单个工具详情时 absIdx 不是 summary,仍正常平移。
287
+ const insertedOverviewBatch = delta > 0
288
+ ? [...batches.values()].find((b) => b.summaryAbsIdx === absIdx && expandedBatches.has(b.id))?.id
289
+ : undefined;
290
+ for (const [idx, target] of absLineToEntry) {
291
+ const isNewOverviewLine = target.batchId === insertedOverviewBatch;
292
+ const newIdx = idx > absIdx && !isNewOverviewLine ? idx + delta : idx;
293
+ if (newIdx >= 0)
294
+ nextEntries.set(newIdx, target);
295
+ }
296
+ absLineToEntry.clear();
297
+ for (const [k, v] of nextEntries)
298
+ absLineToEntry.set(k, v);
357
299
  // 同步每个 batch 的 summaryAbsIdx
358
300
  for (const b of batches.values()) {
359
301
  if (b.summaryAbsIdx > absIdx)
@@ -364,21 +306,14 @@ export function shiftBatchesAfter(absIdx, delta) {
364
306
  /** 把已构造好的 BatchEntry[] 直接落成摘要行(用于 renderHistory 回放;不记录 id 也不需可切换)。
365
307
  * 含 mutation(write_file/edit_file)时整批展开——与实时 endBatch 行为一致。 */
366
308
  export function writeSummaryOnly(entries, layout) {
367
- const hasMutation = entries.some((e) => isMutationTool(e.name));
368
- // 单条 mutation:同 endBatch,摘要行与展开详情头重复,跳过摘要行只写详情
369
- if (hasMutation && entries.length === 1) {
370
- const lines = buildExpandedLines(entries, ' ');
371
- layout.contentWrite(lines.join('\n') + '\n');
309
+ const id = beginBatch();
310
+ const b = batches.get(id);
311
+ if (!b)
372
312
  return;
373
- }
374
- layout.contentWrite(buildSummaryLine(entries) + '\n');
375
- if (hasMutation) {
376
- // 回放时同步展开:重新调 expand 路径需要 BatchRecord,此处直接拼 line 插入
377
- const summaryIdx = Math.max(0, layout.totalRows() - 2);
378
- const lines = buildExpandedLines(entries);
379
- layout.contentInsertAfter(summaryIdx, lines);
380
- // 不入 batches/absLineToBatchId/expandedBatches——回放行不支持点击 toggle(设计取舍:
381
- // 简化模型;若需要支持回放也可展开/折叠,可在 alt-screen 启动时建一张临时映射)
313
+ b.entries = entries;
314
+ endBatch(id, layout);
315
+ if (entries.length === 1 && isMutationToolName(entries[0].name)) {
316
+ expandSingleEntryFully(id, layout);
382
317
  }
383
318
  }
384
319
  let _idCounter = 0;
@@ -141,6 +141,12 @@ export function lineAt(abs) {
141
141
  const all = snapshot();
142
142
  return abs >= 0 && abs < all.length ? all[abs] : null;
143
143
  }
144
+ /** 等长替换一条已提交物理行,供运行中的工具 batch 原地刷新摘要。 */
145
+ export function replaceLine(abs, line) {
146
+ if (abs < 0 || abs >= rows.length)
147
+ return;
148
+ rows[abs] = line.endsWith('\x1B[0m') ? line : line + '\x1B[0m';
149
+ }
144
150
  /**
145
151
  * 找「绝对行索引 < absStart 的最近一条用户消息」的文本(用于滚动回看时的「我刚发的
146
152
  * 请求」sticky banner)。算法:从 absStart - 1 往上扫,识别「用户气泡行」(由 repl
package/dist/ui/layout.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { stdin, stdout } from 'node:process';
2
- import { charWidth, displayWidth, truncateDisplay, truncateDisplayHead, ansiDisplayWidth, wrapByDisplayWidth, fmtElapsed, stripAnsi, sliceByDisplayCol, } from './render.js';
2
+ import { charWidth, displayWidth, truncateDisplay, truncateDisplayHead, truncateAnsi, ansiDisplayWidth, wrapByDisplayWidth, fmtElapsed, stripAnsi, sliceByDisplayCol, } from './render.js';
3
3
  import { ui } from './theme.js';
4
4
  import * as content from './content.js';
5
5
  import * as mouse from './mouse.js';
@@ -47,7 +47,6 @@ let sigwinchHandler = null;
47
47
  // 非 md 写(contentWrite)先 commitMd 收尾(清 segMark,后续写不再被 setLines 截断)。
48
48
  let mdActive = false;
49
49
  let mdBuf = '';
50
- let mdLeadingBlank = false;
51
50
  let selection = null;
52
51
  let selecting = false; // 左键按下中(press→release 之间)
53
52
  let mouseEnabled = true; // 导航菜单(picker)期间置 false:只吞报表不做选区/滚动,防菜单被 viewport 重画覆盖
@@ -313,11 +312,10 @@ export function contentWrite(s) {
313
312
  }
314
313
  }
315
314
  /** 进入 markdown 流式段:标记段起点(layout 续写位 + content.beginSegment),清 accumulator。 */
316
- function beginMdSegment(leadingBlank = false) {
315
+ function beginMdSegment() {
317
316
  segmentStartRow = contentRow;
318
317
  content.beginSegment();
319
318
  mdBuf = '';
320
- mdLeadingBlank = leadingBlank;
321
319
  mdActive = true;
322
320
  }
323
321
  /** 提交 markdown 段:清 accumulator + content.commitSegment(后续非 md 写不再被 setLines 截断)。 */
@@ -326,7 +324,6 @@ function commitMd() {
326
324
  return;
327
325
  mdActive = false;
328
326
  mdBuf = '';
329
- mdLeadingBlank = false;
330
327
  content.commitSegment();
331
328
  }
332
329
  // ── Banner(启动横幅/模式切换横幅)固定顶部行 ──
@@ -406,15 +403,13 @@ export function bannerHeight() {
406
403
  * 物理重画用 repaintViewport(全内容区,原子一次 write 无闪烁)— md 段是缓冲尾,viewport 显尾即显段。
407
404
  * 滚动回看(scrollOffset>0)只更新缓冲不物理写(回尾时显);打字中照常物理写——单次 write 结尾 cup 回输入框,IME 锚定不动。
408
405
  */
409
- export function contentWriteMd(s, leadingBlank = false) {
406
+ export function contentWriteMd(s) {
410
407
  if (!active || !ui.isTTY) {
411
408
  stdout.write(s);
412
409
  return;
413
410
  }
414
411
  if (!mdActive)
415
- beginMdSegment(leadingBlank);
416
- else if (leadingBlank)
417
- mdLeadingBlank = true;
412
+ beginMdSegment();
418
413
  mdBuf += s;
419
414
  const g = getGeo();
420
415
  // 滚动回看冻结(同 contentWrite):scrollOffset>0 时 setLines 替换段会改缓冲行数,若 offset 不变,
@@ -422,12 +417,7 @@ export function contentWriteMd(s, leadingBlank = false) {
422
417
  // offset += delta(可负:setLines 重渲染可能缩行)冻住视图。md 段在尾,窗口在上方,冻结后不重叠。
423
418
  const scrolled = scrollOffset > 0;
424
419
  const totalBefore = scrolled ? content.totalRows() : 0;
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;
420
+ const lines = renderMarkdown(mdBuf, g.cols);
431
421
  content.setLines(lines);
432
422
  const segRows = lines.length;
433
423
  const available = g.contentBottom - segmentStartRow + 1;
@@ -479,8 +469,6 @@ export function clearContent() {
479
469
  scrollLockUntil = 0;
480
470
  mdActive = false;
481
471
  mdBuf = '';
482
- mdLeadingBlank = false;
483
- mdLeadingBlank = false;
484
472
  content.reset();
485
473
  notifyContentReset(); // batch 渲染器同步重置(batch 摘要行索引全部失效)
486
474
  stdout.write(esc.home);
@@ -590,11 +578,15 @@ export function contentDeleteFrom(startIdx, n) {
590
578
  export function totalRows() {
591
579
  return content.totalRows();
592
580
  }
593
- /** 原位替换一条已提交的内容行并重画;供连续工具链更新同一条 batch 摘要。 */
581
+ /** 原地刷新一条内容行(行数不变),用于运行中的工具 batch 更新计数。 */
594
582
  export function contentReplaceLine(absIdx, line) {
595
- content.replaceHead(absIdx, [line.endsWith(ui.reset) ? line : line + ui.reset]);
596
- if (active && scrollOffset === 0)
597
- repaintViewport();
583
+ if (!active)
584
+ return;
585
+ // 原地更新的 batch 摘要可能随追加工具而变长。缓冲区仍把它当作一行,但若直接
586
+ // 交给终端超过 cols,终端会自动折行,造成视觉上多出空行且续写位与缓冲失步。
587
+ // 在写回缓冲前按 ANSI 可见宽度截断,确保“一条逻辑行 = 一条物理行”。
588
+ content.replaceLine(absIdx, truncateAnsi(line, getGeo().cols));
589
+ repaintViewport();
598
590
  }
599
591
  /** 清空内容区时通知 batch 渲染器重置(摘要行映射与展开态)。 */
600
592
  export function notifyContentReset() {
@@ -1153,18 +1145,25 @@ function handleMouseEvent(e) {
1153
1145
  void (async () => {
1154
1146
  try {
1155
1147
  const m = await import('./batch.js');
1156
- const hit = m.findBatchHit(absClick);
1157
- if (hit) {
1158
- const batchLayout = {
1148
+ const entry = m.findEntryByAbsLine(absClick);
1149
+ if (entry) {
1150
+ // 先清选区再改 buffer:contentInsert/Delete 内会立即 repaintViewport;若此时仍保留
1151
+ // 旧绝对行选区,会短暂画出一帧错位高亮,随后二次重画,视觉上就是抖动。
1152
+ selection = null;
1153
+ m.toggleEntry(entry.batchId, entry.entryIndex, {
1159
1154
  contentInsertAfter: (after, lines) => contentInsertAfter(after, lines),
1160
1155
  contentDeleteFrom: (start, n) => contentDeleteFrom(start, n),
1161
- };
1162
- if (hit.entryIndex === null)
1163
- m.toggleBatch(hit.id, batchLayout);
1164
- else
1165
- m.toggleEntry(hit.id, hit.entryIndex, batchLayout);
1156
+ });
1157
+ repaint();
1158
+ return;
1159
+ }
1160
+ const id = m.findBatchByAbsLine(absClick);
1161
+ if (id) {
1166
1162
  selection = null;
1167
- repaintViewport();
1163
+ m.toggleBatch(id, {
1164
+ contentInsertAfter: (after, lines) => contentInsertAfter(after, lines),
1165
+ contentDeleteFrom: (start, n) => contentDeleteFrom(start, n),
1166
+ });
1168
1167
  repaint();
1169
1168
  return;
1170
1169
  }
@@ -1872,7 +1871,6 @@ export function enterAltScreen() {
1872
1871
  scrollLockUntil = 0;
1873
1872
  mdActive = false;
1874
1873
  mdBuf = '';
1875
- mdLeadingBlank = false;
1876
1874
  selection = null;
1877
1875
  selecting = false;
1878
1876
  content.reset();
@@ -574,6 +574,11 @@ function renderMarkdownImpl(text, cols) {
574
574
  flushPara();
575
575
  if (inFence)
576
576
  flushCode(); // EOF 仍 inFence:流式中未闭合 fence → 照常 emit 进行中代码块
577
+ // 独立 markdown 段的外部间距由 agent/batch 边界统一管理。流式后端可能把
578
+ // "\n\n" 与首段正文拆成不同 chunk;仅清洗首个正文 chunk 不够,因为 mdBuf
579
+ // 累积重渲染时这些换行仍会变成段首空行。这里最终兜底,正文段永不自带前导空行。
580
+ while (out.length > 0 && out[0] === '')
581
+ out.shift();
577
582
  // 末尾不留空行:agent onText 后接 onToolCall 的 contentWrite('\n') 会补 1 空行分隔正文与 ● 行;
578
583
  // 若 md 末尾自带空行(段落/代码块后)则叠成 2 空行。裁掉末尾连续空行,让 onToolCall / 轮末
579
584
  // contentWrite('\n') 恰好补 1 行(与改造前 raw 文本行为一致)。块间空行(flushPara/flushCode 中段
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {