mocode-ai 1.2.0 → 1.2.2

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
@@ -14,6 +14,12 @@
14
14
  import { ui } from './theme.js';
15
15
  import { t } from '../i18n/index.js';
16
16
  import { truncateAnsi } from './render.js';
17
+ /** entry 是否已拿到结果。历史回放构造的 entry 无 done 字段,退化按内容判定。 */
18
+ function isEntryDone(e) {
19
+ return e.done === true || !!e.resultSummary || !!e.diffBlock || !!e.failed;
20
+ }
21
+ /** 子 agent 运行中的专属图标:与完成态的实心 ● 区分,也区别于父层「探索」运行态的 ◇。 */
22
+ const RUNNING_GLYPH = '◐';
17
23
  const batches = new Map();
18
24
  /** 绝对行索引 → 所属 batch id(仅记录 summary 行;用于鼠标点击反查)。
19
25
  * buffer 行数变化时本表可能漂移——但只在 insertAfter/deleteFrom 后由本模块同步更新,
@@ -23,6 +29,13 @@ const absLineToBatchId = new Map();
23
29
  const absLineToEntry = new Map();
24
30
  /** 已展开第一层工具列表的 batch id。 */
25
31
  const expandedBatches = new Set();
32
+ /** tool_call id → 所属 batch(主侧每次 onToolHeader 都登记)。
33
+ * 结果按 id 归位(并行时 currentBatchId 会漂移,按 id 查才不串批);
34
+ * 子 agent 建批时据此反查父批,把子批摘要行插到父批正下方。reset() 统一清理。 */
35
+ const callToBatch = new Map();
36
+ /** sub-agent 组:tool_call id → 在组容器批 entries 中的序号。
37
+ * spawn.ts 建子批时据此把子批摘要行插到正确的 └─ sub-agent 行下方。reset() 统一清理。 */
38
+ const groupChildIndexByCall = new Map();
26
39
  /** 展开时完整输出的最大行数;超出截断,避免巨型输出撑爆 viewport。 */
27
40
  const MAX_EXPAND_LINES = 200;
28
41
  /** 自洽行允许的最大显示宽(= 终端 cols)。buffer 行超 cols 会被终端 auto-wrap,
@@ -59,57 +72,125 @@ export function reset() {
59
72
  absLineToBatchId.clear();
60
73
  absLineToEntry.clear();
61
74
  expandedBatches.clear();
75
+ callToBatch.clear();
76
+ groupChildIndexByCall.clear();
62
77
  }
63
- /** 新建一个 batch(在 agent 拿到第一条 onToolHeader 时调)。返回 id。 */
64
- export function beginBatch() {
78
+ /** 新建一个 batch(在 agent 拿到第一条 onToolHeader 时调)。返回 id。
79
+ * label 可选:自定义摘要行标签(子 agent 批用),缺省按完成态取 agent.tools*。
80
+ * opts.indent/parentId:子批嵌套渲染(摘要行缩进 + 插到父批下方)。
81
+ * opts.groupParent:组容器批(并行 sub-agent 共享顶层摘要行)。
82
+ * opts.groupChildIndex:本批作为组子批时在父 entries 中的序号(插入锚点用)。 */
83
+ export function beginBatch(label, opts) {
65
84
  const id = `b${++_idCounter}`;
66
- batches.set(id, { id, summaryAbsIdx: -1, entries: [], expandedEntries: new Set(), startedAt: Date.now() });
85
+ batches.set(id, {
86
+ id,
87
+ summaryAbsIdx: -1,
88
+ entries: [],
89
+ expandedEntries: new Set(),
90
+ renderedCount: 0,
91
+ startedAt: Date.now(),
92
+ label,
93
+ indent: opts?.indent,
94
+ parentId: opts?.parentId && batches.has(opts.parentId) ? opts.parentId : undefined,
95
+ groupParent: opts?.groupParent ?? false,
96
+ groupChildIndex: opts?.groupChildIndex,
97
+ running: opts?.running ?? false,
98
+ });
67
99
  return id;
68
100
  }
69
- /** 记一条工具调用(在 onToolHeader 时调,与 setEntryResult 配对;entries 顺序 = agent 调用顺序)。 */
70
- export function recordCall(id, name, callSummary) {
101
+ /** 登记 tool_call id 所属 batch。结果回填按 id 归位(并行时 currentBatchId 会漂移),
102
+ * 子 agent 也据此反查父批。 */
103
+ export function bindCall(callId, batchId) {
104
+ if (callId)
105
+ callToBatch.set(callId, batchId);
106
+ }
107
+ /** tool_call id → 所属 batch id;未登记返 null。 */
108
+ export function batchIdForCall(callId) {
109
+ if (!callId)
110
+ return null;
111
+ const id = callToBatch.get(callId);
112
+ return id && batches.has(id) ? id : null;
113
+ }
114
+ /** 记一条工具调用(在 onToolHeader 时调,与 setEntryResult 配对;entries 顺序 = agent 调用顺序)。
115
+ * callId 可选:组容器批据此把 sub-agent 调用归到 entries 的固定序号(供子批插入锚点反查)。 */
116
+ export function recordCall(id, name, callSummary, callId) {
71
117
  const b = batches.get(id);
72
118
  if (!b)
73
119
  return;
74
120
  // 已完成的累计探索后又追加工具:恢复进行中,待新结果返回再完成。
75
121
  b.finishedAt = undefined;
76
122
  b.entries.push({ name, callSummary, resultSummary: '', diffBlock: null });
123
+ if (callId && b.groupParent)
124
+ groupChildIndexByCall.set(callId, b.entries.length - 1);
125
+ }
126
+ /** 查询某 sub-agent 调用在所属组容器批 entries 中的序号(供 spawn.ts 建子批时定锚点)。 */
127
+ export function getGroupChildIndex(callId) {
128
+ if (!callId)
129
+ return 0;
130
+ const v = groupChildIndexByCall.get(callId);
131
+ return v == null ? 0 : v;
132
+ }
133
+ /** 批是否所有 entry 都已拿到结果(空批视为完成)。 */
134
+ export function isBatchComplete(id) {
135
+ const b = batches.get(id);
136
+ if (!b)
137
+ return false;
138
+ return b.entries.length > 0 && b.entries.every(isEntryDone);
77
139
  }
78
140
  /** 记一条工具结果(diff 块或单行 preview);agent 在 onToolResult 时调,匹配最后一条未填的 entry。
79
- * fullOutput:工具原始完整输出(纯文本),展开时显示;mutation 工具的 diff 块已自含无需传。 */
80
- export function recordResult(id, name, resultSummary, diffBlock, fullOutput, failed = false) {
141
+ * fullOutput:工具原始完整输出(纯文本),展开时显示;mutation 工具的 diff 块已自含无需传。
142
+ * callId:组容器批用,直接把结果填到对应 entry(避免多个 sub-agent 同名时反向匹配错位) */
143
+ export function recordResult(id, name, resultSummary, diffBlock, fullOutput, failed = false, callId) {
81
144
  const b = batches.get(id);
82
145
  if (!b || b.entries.length === 0)
83
146
  return;
147
+ // 组容器批:优先按 callId 定位 entry,防止多个同名 sub-agent 结果互相填错位置。
148
+ if (callId && b.groupParent) {
149
+ const idx = groupChildIndexByCall.get(callId);
150
+ if (idx != null && idx < b.entries.length && !isEntryDone(b.entries[idx])) {
151
+ const e = b.entries[idx];
152
+ e.resultSummary = resultSummary;
153
+ e.diffBlock = diffBlock;
154
+ e.fullOutput = fullOutput;
155
+ e.failed = failed;
156
+ e.done = true;
157
+ if (b.entries.every(isEntryDone))
158
+ b.finishedAt = Date.now();
159
+ return;
160
+ }
161
+ }
84
162
  // 反向找最后一条同名的 entry 填结果;同名工具一批多次调用时正向遍历更安全——用 lastIndexOf 同名回退
85
163
  for (let i = b.entries.length - 1; i >= 0; i--) {
86
- if (b.entries[i].name === name && !b.entries[i].resultSummary) {
164
+ if (b.entries[i].name === name && !isEntryDone(b.entries[i])) {
87
165
  b.entries[i].resultSummary = resultSummary;
88
166
  b.entries[i].diffBlock = diffBlock;
89
167
  b.entries[i].fullOutput = fullOutput;
90
168
  b.entries[i].failed = failed;
91
- if (b.entries.every((e) => e.resultSummary || e.diffBlock || e.failed))
169
+ b.entries[i].done = true;
170
+ if (b.entries.every(isEntryDone))
92
171
  b.finishedAt = Date.now();
93
172
  return;
94
173
  }
95
174
  }
96
175
  // 兜底:无匹配则填最后一条
97
176
  const last = b.entries[b.entries.length - 1];
98
- if (!last.resultSummary) {
177
+ if (!isEntryDone(last)) {
99
178
  last.resultSummary = resultSummary;
100
179
  last.diffBlock = diffBlock;
101
180
  last.fullOutput = fullOutput;
102
181
  last.failed = failed;
103
- if (b.entries.every((e) => e.resultSummary || e.diffBlock || e.failed))
182
+ last.done = true;
183
+ if (b.entries.every(isEntryDone))
104
184
  b.finishedAt = Date.now();
105
185
  }
106
186
  }
107
187
  // ── 摘要行文本生成 ──
108
188
  /** 把 entry 列表压缩成一行摘要。 */
109
189
  function buildSummaryLine(record, live = false) {
190
+ const prefix = record.indent ?? '';
110
191
  const entries = record.entries;
111
192
  if (entries.length === 0) {
112
- return ` ${ui.dim}│${ui.reset} ${ui.bold}${ui.accent}◇${ui.reset} ${ui.dim}No tools${ui.reset}`;
193
+ return `${prefix} ${ui.dim}│${ui.reset} ${ui.bold}${ui.accent}◇${ui.reset} ${ui.dim}No tools${ui.reset}`;
113
194
  }
114
195
  // N>1:同类合并 "read_file 3, glob 1, grep 1"
115
196
  const counts = new Map();
@@ -118,15 +199,31 @@ function buildSummaryLine(record, live = false) {
118
199
  const parts = [];
119
200
  for (const [n, c] of counts)
120
201
  parts.push(`${n} ${c}`);
121
- const completed = entries.filter((e) => e.resultSummary || e.diffBlock || e.failed).length;
202
+ const completed = entries.filter(isEntryDone).length;
122
203
  const failedCount = entries.filter((e) => e.failed).length;
123
204
  // 工具本身完成就立即显示完成态,不等待整轮正文流完/onDone。
124
205
  // 单项失败不代表整批失败:执行中优先展示进度;完成后区分部分失败与全部失败。
125
206
  const finished = completed >= entries.length;
126
207
  const allFailed = finished && failedCount === entries.length;
127
208
  const partiallyFailed = finished && failedCount > 0 && !allFailed;
128
- const symbol = !finished ? '◇' : allFailed ? '×' : partiallyFailed ? '!' : '●';
129
- const color = !finished ? ui.accent : allFailed ? ui.red : partiallyFailed ? ui.yellow : ui.green;
209
+ const symbol = record.running
210
+ ? RUNNING_GLYPH
211
+ : !finished
212
+ ? '◇'
213
+ : allFailed
214
+ ? '×'
215
+ : partiallyFailed
216
+ ? '!'
217
+ : '●';
218
+ const color = record.running
219
+ ? ui.accent
220
+ : !finished
221
+ ? ui.accent
222
+ : allFailed
223
+ ? ui.red
224
+ : partiallyFailed
225
+ ? ui.yellow
226
+ : ui.green;
130
227
  const label = !finished
131
228
  ? t('agent.toolsRunning')
132
229
  : allFailed
@@ -137,7 +234,8 @@ function buildSummaryLine(record, live = false) {
137
234
  const elapsed = record.finishedAt
138
235
  ? ` ${elapsedMs < 100 ? '<0.1s' : `${(elapsedMs / 1000).toFixed(1)}s`}`
139
236
  : '';
140
- return ` ${ui.bold}${color}${symbol}${ui.reset} ${label}${progress}${elapsed} ${ui.dim}${parts.join(' ')}${ui.reset}`;
237
+ const displayLabel = record.label ?? label;
238
+ return `${prefix} ${ui.bold}${color}${symbol}${ui.reset} ${displayLabel}${progress}${elapsed} ${ui.dim}${parts.join(' ')}${ui.reset}`;
141
239
  }
142
240
  // ── 展开/折叠 ──
143
241
  /** 把 batch 的详情行展开成自洽行数组(供 layout.contentInsertAfter 走 mid-buffer 插入)。
@@ -177,13 +275,13 @@ function buildEntryDetailLines(e, indent = ' ') {
177
275
  }
178
276
  return lines;
179
277
  }
180
- /** 第一层只展示有哪些调用及其简短结果,不展开完整输出。 */
181
- function buildExpandedLines(entries) {
278
+ /** 第一层只展示有哪些调用及其简短结果,不展开完整输出。extraIndent 供子批嵌套加深缩进。 */
279
+ function buildExpandedLines(entries, extraIndent = '') {
182
280
  return entries.map((e, index) => {
183
281
  const result = e.resultSummary ? ` ${ui.gray}↳ ${e.resultSummary}${ui.reset}` : '';
184
282
  const branch = index === entries.length - 1 ? '└─' : '├─';
185
283
  const failure = e.failed ? `${ui.red}×${ui.reset} ` : '';
186
- return sanitizeRow(` ${ui.dim}${branch}${ui.reset} ${failure}${ui.accent}${e.name}${ui.reset} ${ui.dim}${e.callSummary}${ui.reset}${result}`);
284
+ return sanitizeRow(`${extraIndent} ${ui.dim}${branch}${ui.reset} ${failure}${ui.accent}${e.name}${ui.reset} ${ui.dim}${e.callSummary}${ui.reset}${result}`);
187
285
  });
188
286
  }
189
287
  function entryDetailIndent(entries, index) {
@@ -201,6 +299,22 @@ export function endBatch(id, layout) {
201
299
  absLineToBatchId.set(b.summaryAbsIdx, b.id);
202
300
  return;
203
301
  }
302
+ // 子批尚未落盘(组容器折叠期间被隐藏 / 折叠后才新建)。绝不能 contentWrite 到 buffer 末尾:
303
+ // 那会在正文区留下一条游离的子 agent 摘要行,父批再展开时就变成「多出来的第三条」。
304
+ if (b.parentId) {
305
+ const parent = batches.get(b.parentId);
306
+ if (parent?.groupParent) {
307
+ // 父批折叠中:不渲染,等 expand() 统一恢复(那时会用最新状态重建摘要行)。
308
+ if (!expandedBatches.has(parent.id) || parent.summaryAbsIdx < 0)
309
+ return;
310
+ // 父批已展开:插到自己的 └─ sub-agent 行下方。
311
+ const anchor = findParentEntryAbsLine(parent.id, b.groupChildIndex ?? 0) ?? parent.summaryAbsIdx;
312
+ layout.contentInsertAfter(anchor, [sanitizeRow(buildSummaryLine(b))]);
313
+ b.summaryAbsIdx = anchor + 1;
314
+ absLineToBatchId.set(b.summaryAbsIdx, b.id);
315
+ return;
316
+ }
317
+ }
204
318
  const summary = buildSummaryLine(b);
205
319
  // 写摘要行(以 \n 收尾;contentWrite 会 breakRow 让其成为完整物理行)
206
320
  layout.contentWrite(summary + '\n');
@@ -217,6 +331,37 @@ export function showLiveBatch(id, layout) {
217
331
  return;
218
332
  const summary = buildSummaryLine(b, true);
219
333
  if (b.summaryAbsIdx < 0) {
334
+ // 子批:摘要行插到父批已渲染块的正下方(而非 buffer 末尾),
335
+ // 让「子 agent 的工具明细」始终跟在自己的父调用行下——并行派发时才不串行。
336
+ const parent = b.parentId ? batches.get(b.parentId) : undefined;
337
+ if (parent && parent.summaryAbsIdx >= 0 && layout.contentInsertAfter) {
338
+ // 组容器父批处于折叠态时,子批摘要行先不渲染;等父批展开时由 expand 统一恢复,
339
+ // 避免子批摘要残留在父批明细区、再次展开后出现重复行。
340
+ if (parent.groupParent && !expandedBatches.has(parent.id)) {
341
+ b.summaryAbsIdx = -1;
342
+ return;
343
+ }
344
+ let anchor;
345
+ if (parent.groupParent && b.groupChildIndex != null && expandedBatches.has(parent.id)) {
346
+ // 组容器已展开:子 agent 工具批插到第 groupChildIndex 个 └─ sub-agent 行下方。
347
+ // 不能用固定偏移 summaryAbsIdx+1+childIndex——前面兄弟子批的内容会把它后面的
348
+ // entry 行整体下移,固定偏移会错位;用 absLineToEntry 登记的真实绝对索引。
349
+ let entryAbs = parent.summaryAbsIdx + 1 + b.groupChildIndex;
350
+ for (const [idx, target] of absLineToEntry) {
351
+ if (target.batchId === parent.id && target.entryIndex === b.groupChildIndex) {
352
+ entryAbs = idx;
353
+ break;
354
+ }
355
+ }
356
+ anchor = entryAbs;
357
+ }
358
+ else {
359
+ anchor = parent.summaryAbsIdx + (expandedBatches.has(parent.id) ? parent.renderedCount : 0);
360
+ }
361
+ layout.contentInsertAfter(anchor, [sanitizeRow(summary)], false);
362
+ b.summaryAbsIdx = anchor + 1;
363
+ return;
364
+ }
220
365
  layout.contentWrite(summary + '\n');
221
366
  b.summaryAbsIdx = Math.max(0, layout.totalRows() - 2);
222
367
  // 首条摘要通过增量 contentWrite 落屏时,markdown→普通内容的边界可能只更新了
@@ -241,6 +386,61 @@ export function findBatchByAbsLine(absLine) {
241
386
  export function isExpanded(id) {
242
387
  return expandedBatches.has(id);
243
388
  }
389
+ /** 展开 batch 第一层(逐条工具调用)。供子 agent 实时运行态:执行中逐条展示。
390
+ * live=true 时不锚定视口(实时输出跟随底部),区别于鼠标点击展开(保持视口不跳)。 */
391
+ export function expandBatch(id, layout, live = false) {
392
+ const b = batches.get(id);
393
+ if (b && !expandedBatches.has(id))
394
+ expand(b, layout, live);
395
+ }
396
+ /**
397
+ * 展开态下**追加渲染**新增的明细行(子 agent 逐条追加工具时用)。
398
+ * 只插入 entries[renderedCount, ...) 中尚未渲染的条目,绝不重建——运行态下
399
+ * 并行多个批时,重建会按 entries.length 删除,误删其它子 agent 的批行。
400
+ * 未展开则 no-op。
401
+ */
402
+ export function refreshBatchExpanded(id, layout) {
403
+ const b = batches.get(id);
404
+ if (!b || !expandedBatches.has(id))
405
+ return;
406
+ if (b.summaryAbsIdx < 0)
407
+ return; // 摘要行未落盘(父组容器折叠中):无处可挂,等展开时统一渲染
408
+ if (b.entries.length <= b.renderedCount)
409
+ return;
410
+ const newEntries = b.entries.slice(b.renderedCount);
411
+ const lines = buildExpandedLines(newEntries, b.indent ?? '');
412
+ // 实时追加:不锚定视口,让新明细行自然出现在屏底。
413
+ // 组容器批的 entry 与子批摘要行交错,新 entry 必须插在当前块末尾,
414
+ // 不能简单用 summaryAbsIdx+renderedCount(否则 entry 会插到前一个子批摘要行之前)。
415
+ let anchor = b.summaryAbsIdx + b.renderedCount;
416
+ if (b.groupParent) {
417
+ let maxIdx = b.summaryAbsIdx;
418
+ for (const [idx, target] of absLineToEntry) {
419
+ if (target.batchId === b.id && idx > maxIdx)
420
+ maxIdx = idx;
421
+ }
422
+ for (const child of batches.values()) {
423
+ if (child.parentId === b.id && child.summaryAbsIdx >= 0) {
424
+ let childEnd = child.summaryAbsIdx;
425
+ if (expandedBatches.has(child.id)) {
426
+ childEnd += child.renderedCount;
427
+ for (const j of child.expandedEntries) {
428
+ childEnd += buildEntryDetailLines(child.entries[j], entryDetailIndent(child.entries, j)).length;
429
+ }
430
+ }
431
+ if (childEnd > maxIdx)
432
+ maxIdx = childEnd;
433
+ }
434
+ }
435
+ anchor = maxIdx;
436
+ }
437
+ layout.contentInsertAfter(anchor, lines, false);
438
+ // 登记新增明细行的点击命中(按实际插入位置)
439
+ for (let i = 0; i < newEntries.length; i++) {
440
+ absLineToEntry.set(anchor + 1 + i, { batchId: b.id, entryIndex: b.renderedCount + i });
441
+ }
442
+ b.renderedCount = b.entries.length;
443
+ }
244
444
  /**
245
445
  * 切换 batch 展开/折叠;无变化时 no-op。
246
446
  * 折叠:从 buffer 删详情行(mid-buffer delete);
@@ -259,13 +459,38 @@ export function toggleBatch(id, layout) {
259
459
  expand(b, layout);
260
460
  }
261
461
  }
262
- function expand(b, layout) {
263
- const lines = buildExpandedLines(b.entries);
264
- layout.contentInsertAfter(b.summaryAbsIdx, lines);
462
+ function expand(b, layout, live = false) {
463
+ if (b.summaryAbsIdx < 0)
464
+ return; // 摘要行未落盘时展开会把明细插到 buffer 头部
465
+ const lines = buildExpandedLines(b.entries, b.indent ?? '');
466
+ layout.contentInsertAfter(b.summaryAbsIdx, lines, !live);
265
467
  expandedBatches.add(b.id);
468
+ b.renderedCount = b.entries.length;
266
469
  for (let i = 0; i < b.entries.length; i++) {
267
470
  absLineToEntry.set(b.summaryAbsIdx + 1 + i, { batchId: b.id, entryIndex: i });
268
471
  }
472
+ // 组容器批展开时:把之前被折叠隐藏的子批摘要行重新插回对应 entry 下方,
473
+ // 否则子批摘要行会留在父批摘要行之后、造成明细重复/错位。
474
+ if (b.groupParent) {
475
+ const children = [...batches.values()]
476
+ .filter((x) => x.parentId === b.id)
477
+ .sort((a, b) => (a.groupChildIndex ?? 0) - (b.groupChildIndex ?? 0));
478
+ for (const child of children) {
479
+ const entryAbs = findParentEntryAbsLine(b.id, child.groupChildIndex ?? 0);
480
+ const anchor = entryAbs ?? b.summaryAbsIdx + b.renderedCount;
481
+ const summary = buildSummaryLine(child, true);
482
+ layout.contentInsertAfter(anchor, [sanitizeRow(summary)], !live);
483
+ child.summaryAbsIdx = anchor + 1;
484
+ absLineToBatchId.set(child.summaryAbsIdx, child.id);
485
+ }
486
+ }
487
+ }
488
+ function findParentEntryAbsLine(parentId, entryIndex) {
489
+ for (const [idx, target] of absLineToEntry) {
490
+ if (target.batchId === parentId && target.entryIndex === entryIndex)
491
+ return idx;
492
+ }
493
+ return null;
269
494
  }
270
495
  /** mutation 独占 batch 收尾后立即展示其调用概要和 diff。 */
271
496
  export function expandSingleEntryFully(id, layout) {
@@ -285,12 +510,47 @@ export function expandSingleEntryFully(id, layout) {
285
510
  absLineToEntry.set(b.summaryAbsIdx + 1, { batchId: id, entryIndex: 0 });
286
511
  }
287
512
  function collapse(b, layout) {
288
- let lineCount = b.entries.length;
513
+ // 只删除实际渲染的明细行(renderedCount),而非 entries.length——运行态下
514
+ // entries 可能多于已渲染行,按 entries.length 会多删并行批量子 agent 的行。
515
+ let lineCount = b.renderedCount;
289
516
  for (const i of b.expandedEntries) {
290
517
  lineCount += buildEntryDetailLines(b.entries[i], entryDetailIndent(b.entries, i)).length;
291
518
  }
519
+ // 组容器批折叠时:一并移除嵌套子批的摘要行(及其已展开详情),
520
+ // 否则父批再次展开后子批摘要仍残留在明细区,出现重复行。
521
+ if (b.groupParent) {
522
+ const children = [...batches.values()].filter((x) => x.parentId === b.id);
523
+ for (const child of children) {
524
+ // 未落盘的子批(父批折叠期间新建)在 buffer 里没有对应行,计进 lineCount 会多删相邻正文。
525
+ if (child.summaryAbsIdx < 0) {
526
+ child.renderedCount = 0;
527
+ child.expandedEntries.clear();
528
+ expandedBatches.delete(child.id);
529
+ continue;
530
+ }
531
+ lineCount += 1; // 子批摘要行本身
532
+ if (expandedBatches.has(child.id)) {
533
+ // 子批自身展开时,它的第一层明细行(renderedCount)也在父批块内,必须一并计入,
534
+ // 否则删少了会留下孤儿明细行。
535
+ lineCount += child.renderedCount;
536
+ for (const j of child.expandedEntries) {
537
+ lineCount += buildEntryDetailLines(child.entries[j], entryDetailIndent(child.entries, j)).length;
538
+ }
539
+ child.expandedEntries.clear();
540
+ expandedBatches.delete(child.id);
541
+ }
542
+ child.renderedCount = 0;
543
+ absLineToBatchId.delete(child.summaryAbsIdx);
544
+ child.summaryAbsIdx = -1;
545
+ for (const [idx, target] of absLineToEntry) {
546
+ if (target.batchId === child.id)
547
+ absLineToEntry.delete(idx);
548
+ }
549
+ }
550
+ }
292
551
  layout.contentDeleteFrom(b.summaryAbsIdx + 1, lineCount);
293
552
  expandedBatches.delete(b.id);
553
+ b.renderedCount = 0;
294
554
  b.expandedEntries.clear();
295
555
  for (const [idx, target] of absLineToEntry) {
296
556
  if (target.batchId === b.id)
@@ -301,11 +561,27 @@ function collapse(b, layout) {
301
561
  export function findEntryByAbsLine(absLine) {
302
562
  return absLineToEntry.get(absLine) ?? null;
303
563
  }
304
- /** 第二层:只展开/折叠某一个工具的完整输出。 */
564
+ /** 第二层:只展开/折叠某一个工具的完整输出。
565
+ * 特殊地,组容器批(groupParent)下的 sub-agent entry 行点击时,
566
+ * 切换的是对应子 Agent 批(child batch)的展开/折叠,而不是 entry 自身详情。 */
305
567
  export function toggleEntry(batchId, entryIndex, layout) {
306
568
  const b = batches.get(batchId);
307
569
  if (!b || !expandedBatches.has(batchId))
308
570
  return;
571
+ // 组容器批的 entry 对应一个子 Agent 批;点击 entry 行应展开/折叠该 entry
572
+ // 自身的详情(即子 agent 返回的完整文本输出)。子 agent 的工具调用列表由点击
573
+ // 子批自己的摘要行(● 子 Agent 完成 ...)来控制。
574
+ if (b.groupParent) {
575
+ // 如果该 sub-agent entry 没有可展开的详情,fallback 到 toggle 子批工具列表。
576
+ const details = buildEntryDetailLines(b.entries[entryIndex], entryDetailIndent(b.entries, entryIndex));
577
+ if (details.length === 0) {
578
+ const child = [...batches.values()].find((x) => x.parentId === batchId && x.groupChildIndex === entryIndex);
579
+ if (child) {
580
+ toggleBatch(child.id, layout);
581
+ }
582
+ return;
583
+ }
584
+ }
309
585
  let headerIdx = -1;
310
586
  for (const [idx, target] of absLineToEntry) {
311
587
  if (target.batchId === batchId && target.entryIndex === entryIndex)
@@ -369,7 +645,19 @@ export function shiftBatchesAfter(absIdx, delta) {
369
645
  b.summaryAbsIdx = Math.max(0, b.summaryAbsIdx + delta);
370
646
  }
371
647
  }
372
- // ── history 回放支持 ──
648
+ /** 更新 batch 摘要行标签(子 agent 批:运行中→完成/失败)。 */
649
+ export function setBatchLabel(id, label) {
650
+ const b = batches.get(id);
651
+ if (b)
652
+ b.label = label;
653
+ }
654
+ /** 设置/清除运行态标志:running=true 时摘要行用「运行中」专属图标,收尾时置 false。 */
655
+ export function setBatchRunning(id, running) {
656
+ const b = batches.get(id);
657
+ if (b)
658
+ b.running = running;
659
+ }
660
+ /** history 回放支持 ── */
373
661
  /** 把已构造好的 BatchEntry[] 落成可切换摘要行(用于 renderHistory 回放)。
374
662
  * 含 mutation(write_file/edit_file)时整批展开;普通批次保留与实时 flushToolBatch 相同的空行边界。 */
375
663
  export function writeSummaryOnly(entries, layout) {
package/dist/ui/layout.js CHANGED
@@ -10,6 +10,11 @@ import { renderMarkdown } from './markdown.js';
10
10
  import { t } from '../i18n/index.js';
11
11
  // ── 内部状态 ──
12
12
  let active = false;
13
+ /** 是否处于全屏 TUI(alt screen)激活态。非 TTY / 嵌入宿主(host)下为 false。
14
+ * 子 agent 等异步路径据此判断能否把中间过程实时写入主内容区。 */
15
+ export function isTuiActive() {
16
+ return active;
17
+ }
13
18
  // ── 裸 console 防御:第三方库(如 openai SDK)可能用 console.log 直写 stdout,
14
19
  // 在 RUNNING 态会落到光标所在的底栏输入框,污染输入。进入 TUI 后把 console.*
15
20
  // 劫持到 contentWrite,统一进内容区(运行态下 contentWrite 末尾会把真光标归位输入框),
@@ -587,8 +592,12 @@ export function rewindContent(rowsToRewind) {
587
592
  *
588
593
  * 续写位(contentRow):若原写头在插入点之后,前移 lines.length,保持相对位置;
589
594
  * 若原写头 ≤ after,不变(新行在写头之后)。非 TTY 直接调 content.insertAfter。
595
+ *
596
+ * keepViewport:鼠标点击展开时 true(视口锚定原位,详情在下方展开,屏幕不跳);
597
+ * 子 agent 实时嵌套渲染时传 false —— 那是"新内容"而非"回看展开",必须跟随屏底,
598
+ * 否则每插一行就把视口冻住 1 行,子 agent 跑起来后主内容区看着像卡住不动。
590
599
  */
591
- export function contentInsertAfter(after, lines) {
600
+ export function contentInsertAfter(after, lines, keepViewport = true) {
592
601
  if (!active || lines.length === 0)
593
602
  return;
594
603
  const g = getGeo();
@@ -616,7 +625,7 @@ export function contentInsertAfter(after, lines) {
616
625
  // 而不是自动跳到展开内容底部(用户体验:点击摘要行,视口不动,详情在下方展开)。
617
626
  // 关键:插入点绝对行 = after;插入前视口尾行绝对行 = totalBefore - 1;
618
627
  // 插入后要让原视口尾行仍在屏底 → scrollOffset = 插入后新增的、在原视口尾行之后的行数。
619
- if (!scrolled && after < totalBefore) {
628
+ if (keepViewport && !scrolled && after < totalBefore) {
620
629
  // 插入点在原缓冲内(非追加到末尾),计算需要滚动的偏移量
621
630
  const insertedAfterViewport = after >= (totalBefore - g.contentBottom);
622
631
  if (insertedAfterViewport) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {