mocode-ai 1.2.0 → 1.2.1

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.
@@ -554,7 +554,7 @@ export async function runAgentCore(opts) {
554
554
  for (const tc of batch)
555
555
  hooks.onToolHeader?.(tc);
556
556
  hooks.onToolStart?.(batch[0].name);
557
- const started = batch.map((tc) => executeToolOutcome(tc.name, tc.arguments, signal));
557
+ const started = batch.map((tc) => executeToolOutcome(tc.name, tc.arguments, signal, { callId: tc.id }));
558
558
  for (let k = 0; k < batch.length; k++) {
559
559
  const tc = batch[k];
560
560
  const outcome = await started[k];
@@ -627,6 +627,7 @@ export async function runAgentCore(opts) {
627
627
  const started = entries.map((entry) => entry.denied
628
628
  ? Promise.resolve(entry.denied)
629
629
  : executeToolOutcome(entry.tc.name, entry.tc.arguments, signal, {
630
+ callId: entry.tc.id,
630
631
  onLockAcquired: (lockedArgs) => {
631
632
  entry.diff = readDiffContext(entry.tc, lockedArgs);
632
633
  },
@@ -715,6 +716,7 @@ export async function runAgentCore(opts) {
715
716
  let diff = readDiffContext(tc, mutationParsed);
716
717
  hooks.onToolStart?.(tc.name);
717
718
  const outcome = await executeToolOutcome(tc.name, tc.arguments, signal, {
719
+ callId: tc.id,
718
720
  onLockAcquired: (lockedArgs) => {
719
721
  if (mutationParsed)
720
722
  diff = readDiffContext(tc, lockedArgs);
@@ -7,6 +7,7 @@ import { Spinner } from '../ui/spinner.js';
7
7
  import { summarizeToolCall, summarizeToolResult, truncateDisplay, fmtElapsed, } from '../ui/render.js';
8
8
  import { renderFileChange } from '../ui/diff.js';
9
9
  import * as layout from '../ui/layout.js';
10
+ import * as content from '../ui/content.js';
10
11
  import * as batch from '../ui/batch.js';
11
12
  import { beginTurn } from '../rollback/index.js';
12
13
  import { config } from '../config/index.js';
@@ -17,6 +18,27 @@ import { isToolErrorOutput } from '../tools/result.js';
17
18
  import { appendCurrentSessionTraceEvent } from '../session/index.js';
18
19
  /** 当前 turn 的 batch id(runAgent 内闭包变量;一条 turn 一轮 tool batch 结束即清空)。 */
19
20
  let currentBatchId = null;
21
+ /** 当前活跃的 sub-agent 组容器批 id(同一轮并行派发的多个 sub-agent 合并到这一个顶层摘要行下)。
22
+ * null 表示当前没有进行中的子 agent 组。 */
23
+ let subAgentGroupId = null;
24
+ /** sub-agent 组收口后,与后续内容(普通工具/正文)之间欠一条分隔空行。
25
+ * 由 flushToolBatch / onText 在合适位置补,尾部已空则不再叠。 */
26
+ let subAgentGroupPendingSeparator = false;
27
+ /** 派生子 agent 的工具名。它的调用要独占一批:子 agent 的实时工具明细会挂到这一行下面,
28
+ * 并行派发多个子 agent 时,每个子 agent 才有自己可归属的摘要行。 */
29
+ const SUB_AGENT_TOOL = 'sub-agent';
30
+ /** 缓冲尾部是否已经是空白行(去掉 ANSI 后无可见字符)。用于避免 sub-agent 分隔空行叠成两行。 */
31
+ function isLastContentRowBlank() {
32
+ // 用 committedRows 而不是 totalRows:hasCurrent 那行是未提交的光标等待位,
33
+ // 永远空白,不能把它当作“已经有一条空行分隔”。
34
+ const committed = content.committedRows();
35
+ if (committed === 0)
36
+ return false;
37
+ const line = content.lineAt(committed - 1);
38
+ if (line === null)
39
+ return false;
40
+ return line.replace(/\x1b\[[0-9;]*m/g, '').trim().length === 0;
41
+ }
20
42
  let turnFileChanges = [];
21
43
  function lineDelta(oldText, newText) {
22
44
  const before = oldText ? oldText.split('\n') : [];
@@ -68,20 +90,82 @@ function firstLineOf(ui) {
68
90
  * 重构后改为累积到 BatchRenderer,onToolBatchEnd 时统一打摘要行;
69
91
  * 展开/折叠由 BatchRenderer + 鼠标 release 决定,本函数不再直接写屏。 */
70
92
  function writeToolHeader(tc) {
71
- // 改文件工具是 batch 屏障:先收尾之前的普通工具,确保 mutation 永远独占一批。
72
- if (isMutationTool(tc.name))
93
+ if (tc.name === SUB_AGENT_TOOL) {
94
+ // sub-agent:同一轮并行派发的多个 sub-agent 合并进同一个「组容器批」——
95
+ // 顶层只有一个 ● 探索 N ... sub-agent N 摘要行,下面按 └─ sub-agent {...} 逐条
96
+ // 列出每个子 agent 调用,各自子批(实时工具)更深一层缩进。
97
+ // 注意:sub-agent header 到来时**不要** flushToolBatch,否则会把正在进行的组容器批收口。
98
+ if (subAgentGroupId == null) {
99
+ // 首个 sub-agent 调用前:若前面已经有一个普通工具批在跑,先收口它并补分隔空行,
100
+ // 避免普通批摘要行与 sub-agent 组摘要行粘在一起。
101
+ if (currentBatchId)
102
+ flushToolBatch();
103
+ // 首个 sub-agent 调用:建组容器批(顶层)。label 缺省=「探索」,entries 累计各 sub-agent 调用。
104
+ subAgentGroupId = batch.beginBatch(undefined, { groupParent: true });
105
+ batch.recordCall(subAgentGroupId, tc.name, summarizeToolCall(tc.name, tc.arguments), tc.id);
106
+ batch.bindCall(tc.id, subAgentGroupId);
107
+ batch.showLiveBatch(subAgentGroupId, layout);
108
+ // 立即展开第一组 └─ sub-agent 行(运行态不锚定视口),让子 agent 调用马上可见。
109
+ batch.expandBatch(subAgentGroupId, layout, true);
110
+ }
111
+ else {
112
+ // 同一轮并行派发的后续 sub-agent:向组追加 entry,实时刷新进度。
113
+ batch.recordCall(subAgentGroupId, tc.name, summarizeToolCall(tc.name, tc.arguments), tc.id);
114
+ batch.bindCall(tc.id, subAgentGroupId);
115
+ // 组已展开:追加新的 └─ sub-agent 行(插到已渲染 entry 之后),并刷新顶层摘要行计数。
116
+ batch.refreshBatchExpanded(subAgentGroupId, layout);
117
+ batch.showLiveBatch(subAgentGroupId, layout);
118
+ }
119
+ return;
120
+ }
121
+ // sub-agent 组还在跑但普通/mutation 工具先来了:收口 sub-agent 组,并补一条分隔空行。
122
+ if (subAgentGroupId) {
73
123
  flushToolBatch();
74
- if (!currentBatchId)
75
- currentBatchId = batch.beginBatch();
76
- batch.recordCall(currentBatchId, tc.name, summarizeToolCall(tc.name, tc.arguments));
77
- // 第一条工具开始时立即落摘要;后续调用加入同一 batch,并原地刷新计数。
78
- batch.showLiveBatch(currentBatchId, layout);
124
+ }
125
+ if (isMutationTool(tc.name)) {
126
+ // mutation 永远独占一批(diff 要紧跟调用行)。先收口当前普通批。
127
+ if (currentBatchId)
128
+ flushToolBatch();
129
+ const id = batch.beginBatch();
130
+ currentBatchId = id;
131
+ batch.bindCall(tc.id, id);
132
+ batch.recordCall(id, tc.name, summarizeToolCall(tc.name, tc.arguments));
133
+ batch.showLiveBatch(id, layout);
134
+ }
135
+ else {
136
+ // 普通工具合并到 currentBatchId;同轮并行或连续无正文的工具轮次共享一个摘要行。
137
+ const id = currentBatchId ??= batch.beginBatch();
138
+ // 结果按 tool_call id 归位:并行执行时 currentBatchId 会漂移,只按“当前批”回填会漏填,
139
+ // 摘要行就永远停在 ◇(用户实测:子 agent 跑完主侧菱形没变成实心圆)。
140
+ batch.bindCall(tc.id, id);
141
+ batch.recordCall(id, tc.name, summarizeToolCall(tc.name, tc.arguments));
142
+ // 第一条工具开始时立即落摘要;后续调用加入同一 batch,并原地刷新计数。
143
+ batch.showLiveBatch(id, layout);
144
+ }
79
145
  }
80
146
  /** 渲染工具结果:mutation 成功走 diff 块(行号 + 语法高亮);其余走一行 preview。
81
147
  * 同 writeToolHeader,改为累积到 BatchRenderer(只缓存字符串,不写屏)。 */
82
148
  function writeToolResult(tc, output, parsed, preWriteOld, editStartLine) {
83
- if (!currentBatchId)
149
+ // 优先按 tool_call id 反查所属批(并行 / sub-agent 组时 currentBatchId 已不是它)。
150
+ const batchId = batch.batchIdForCall(tc.id) ?? currentBatchId;
151
+ if (!batchId)
84
152
  return;
153
+ if (tc.name === SUB_AGENT_TOOL) {
154
+ // sub-agent 调用只是组容器批的一个 entry:结果回填到对应 entry 即可,
155
+ // 无需独立批。全部 entry 完成后收口组容器批(落完成态 + 开放点击展开),
156
+ // 并与后续内容约定一条分隔空行。
157
+ const preview = summarizeToolResult(tc.name, output);
158
+ batch.recordResult(batchId, tc.name, preview, null, output, isToolErrorOutput(output), tc.id);
159
+ if (batch.isBatchComplete(batchId)) {
160
+ batch.endBatch(batchId, layout);
161
+ subAgentGroupId = null;
162
+ subAgentGroupPendingSeparator = true;
163
+ }
164
+ else {
165
+ batch.showLiveBatch(batchId, layout);
166
+ }
167
+ return;
168
+ }
85
169
  let diff = null;
86
170
  if ((tc.name === 'edit_file' || tc.name === 'write_file') && parsed && !isToolErrorOutput(output)) {
87
171
  const oldText = tc.name === 'edit_file' ? String(parsed.old_string ?? '') : preWriteOld;
@@ -102,16 +186,42 @@ function writeToolResult(tc, output, parsed, preWriteOld, editStartLine) {
102
186
  });
103
187
  }
104
188
  const preview = diff ? '' : summarizeToolResult(tc.name, output);
105
- batch.recordResult(currentBatchId, tc.name, preview, diff, output, isToolErrorOutput(output));
106
- batch.showLiveBatch(currentBatchId, layout);
189
+ batch.recordResult(batchId, tc.name, preview, diff, output, isToolErrorOutput(output));
190
+ batch.showLiveBatch(batchId, layout);
107
191
  // mutation 结果(成功 diff 或错误输出)立即可见,并阻止后续普通工具并入这一批。
108
192
  if (isMutationTool(tc.name))
109
- flushToolBatch(true);
193
+ finishStandaloneBatch(batchId, true);
194
+ }
195
+ /** 收尾一个独占批(mutation / sub-agent)。按 id 收尾而不是按 currentBatchId:
196
+ * 同一轮里 mutation 与 sub-agent 混排时,后者的 header 已经把前者切走,
197
+ * 只认 currentBatchId 会漏掉 diff 展开、把摘要永久留在未完成态。endBatch 幂等。 */
198
+ function finishStandaloneBatch(id, expandSingleEntry) {
199
+ if (currentBatchId === id)
200
+ currentBatchId = null;
201
+ batch.endBatch(id, layout);
202
+ if (expandSingleEntry)
203
+ batch.expandSingleEntryFully(id, layout);
204
+ // mutation 独占批收尾:分隔空行由 flushToolBatch 负责(见 subAgentGroupPendingSeparator);
205
+ // 不再有 sub-agent 独占批——sub-agent 已合并进组容器批。
110
206
  }
111
207
  /** 将跨 LLM 工具轮次累计的调用写入内容区;正文开始或整个 turn 收尾时才切批。 */
112
208
  function flushToolBatch(expandSingleEntry = false) {
113
- if (!currentBatchId)
209
+ if (!currentBatchId) {
210
+ // 异常路径:组容器批尚未收口(理论上最后一个 sub-agent 结果时已收口),这里补收口。
211
+ if (subAgentGroupId) {
212
+ const id = subAgentGroupId;
213
+ subAgentGroupId = null;
214
+ batch.endBatch(id, layout);
215
+ subAgentGroupPendingSeparator = true;
216
+ }
217
+ // 组 / 工具块与后续内容之间补一条分隔空行(尾部已空则不叠,避免空两行)。
218
+ if (subAgentGroupPendingSeparator) {
219
+ if (!isLastContentRowBlank())
220
+ layout.contentWrite('\n');
221
+ subAgentGroupPendingSeparator = false;
222
+ }
114
223
  return;
224
+ }
115
225
  const id = currentBatchId;
116
226
  currentBatchId = null;
117
227
  batch.endBatch(id, layout);
@@ -120,6 +230,7 @@ function flushToolBatch(expandSingleEntry = false) {
120
230
  // 普通批:endBatch 留了 1 个 hasCurrent 空行,break 一次把它提交为分隔空行。
121
231
  // mutation 自动展开:expandSingleEntryFully 自己补 separator (\n),这里不能再补 \n,
122
232
  // 不然 diff 后面就会出现两条空白行。
233
+ subAgentGroupPendingSeparator = false;
123
234
  if (!expandSingleEntry)
124
235
  layout.contentWrite('\n');
125
236
  }
@@ -144,6 +255,8 @@ onContextUpdate) {
144
255
  beginTurn(truncateDisplay(firstLineOf(userInput), 40));
145
256
  layout.contentMode(); // 防御性:运行态光标归输入框光标位供 IME 锚定(enterRunningMode 已置,这里兜底)
146
257
  currentBatchId = null; // 新 turn 清旧 batch id(防上 turn 残留)
258
+ subAgentGroupId = null;
259
+ subAgentGroupPendingSeparator = false;
147
260
  turnFileChanges = [];
148
261
  // spinner:状态行最前面转圈(思考中 / 生成 / 执行 工具时,状态栏 lead 位显帧 + 文字)。
149
262
  // 经 setStatus 注入状态行(spinnerFrame + statusText),composeStatus 把帧 + 文字放 lead 位;
@@ -164,9 +277,11 @@ onContextUpdate) {
164
277
  onText: (s) => {
165
278
  // 纯空白 chunk 在视觉上不是正文:既不切 batch,也不写入 markdown 缓冲。
166
279
  // 部分兼容后端会在连续工具轮次间流出 " " / "\n",若据此切批会漏掉首个工具。
167
- if (currentBatchId && s.trim().length === 0)
280
+ // sub-agent 独占批不占 currentBatchId,但同样处在“工具块刚结束”的边界上。
281
+ const inToolBlock = currentBatchId !== null || subAgentGroupId !== null || subAgentGroupPendingSeparator;
282
+ if (inToolBlock && s.trim().length === 0)
168
283
  return;
169
- const followsToolBatch = currentBatchId !== null;
284
+ const followsToolBatch = inToolBlock;
170
285
  // batch 收尾已经统一留了一条空白行。部分后端会把下一段正文以 \n / \n\n
171
286
  // 开头发来;去掉这些“边界换行”,避免与 UI 分隔叠成两条空白行。
172
287
  const visible = followsToolBatch ? s.replace(/^(?:[ \t]*\r?\n)+/, '') : s;
@@ -1,9 +1,10 @@
1
- // 子 agent 封装:runAgentCore + 静默 hooks。独立 history / 可受限工具子集 / 低步数上限。
1
+ // 子 agent 封装:runAgentCore + (TUI 激活时)实时渲染 hooks。独立 history / 可受限工具子集 / 低步数上限。
2
2
  // 供 task 工具(主 agent 派生子任务)调用——子 agent 的最终摘要回灌主 history。
3
3
  //
4
4
  // 与主 agent 的区别:
5
- // - 不写主屏(layout.contentWrite):中间过程(流式正文 / 工具头 / diff)缓冲到内部字符串,
6
- // 结束返回给 task 工具(task 把它当 tool 结果回灌主 history,主 agent 据此继续)
5
+ // - 主屏渲染可选:全屏 TUI 激活时(layout.isTuiActive()),把子 agent 内部工具调用实时写入
6
+ // 主内容区并复用 batch 折叠机制(运行态逐条展开,执行完自动折叠回单行摘要,鼠标可点开查看);
7
+ // TUI 未激活(host 嵌入 / 非 TTY)时保持纯静默——中间过程只缓冲进 transcript,不写主屏。
7
8
  // - 独立 history:不共享主对话,避免子任务的工具噪声污染主上下文。
8
9
  // - 紧凑系统提示:不复制主 agent 的 memory/skills/项目快照;由 context 传入已知事实。
9
10
  // - 工具子集:写任务默认继承主 Agent 工具(仅禁止递归 task);只读模式按安全语义移除写工具。
@@ -15,8 +16,13 @@ import { chatTools } from '../llm/index.js';
15
16
  import { buildMocodeCorePrompt, config, isSubAgentEnabled } from '../config/index.js';
16
17
  import { effectiveSystemPrompt } from '../skills/index.js';
17
18
  import { ui } from '../ui/theme.js';
19
+ import * as layout from '../ui/layout.js';
20
+ import { isTuiActive } from '../ui/layout.js';
21
+ import * as batch from '../ui/batch.js';
22
+ import { isToolErrorOutput } from '../tools/result.js';
18
23
  import { runAgentCore } from './core.js';
19
24
  import { summarizeToolCall, summarizeToolResult, truncateDisplay } from '../ui/render.js';
25
+ import { t } from '../i18n/index.js';
20
26
  import { createContextState } from '../session/compact.js';
21
27
  import { inOverlay, mergeSubAgentChangeSet } from '../agents/coordinator.js';
22
28
  /** 子 agent 系统提示后缀:角色与约束。 */
@@ -84,6 +90,61 @@ export async function spawnAgent(opts) {
84
90
  buf.push(s);
85
91
  transcript += s;
86
92
  };
93
+ // 主屏实时渲染(子 agent 透明化):TUI 激活时把子 agent 内部步骤实时写入主内容区,
94
+ // 复用 batch 折叠机制(mouse 点击摘要行可展开/收起)。TUI 未激活(host/非 TTY)时
95
+ // 保持纯静默——只缓冲 transcript,不写屏,兼容嵌入宿主。
96
+ const live = isTuiActive();
97
+ let liveBatchId = null;
98
+ const liveLayout = () => ({
99
+ contentWrite: (s) => layout.contentWrite(s),
100
+ contentReplaceLine: (absIdx, line) => layout.contentReplaceLine(absIdx, line),
101
+ contentInsertAfter: (after, lines, keepViewport) => layout.contentInsertAfter(after, lines, keepViewport),
102
+ contentDeleteFrom: (startIdx, n) => layout.contentDeleteFrom(startIdx, n),
103
+ totalRows: () => layout.totalRows(),
104
+ repaintViewport: () => layout.repaintViewport(),
105
+ isScrolled: () => layout.isScrolled(),
106
+ });
107
+ /** 本批是否挂在主侧调用行下(挂上了就由主侧负责分隔空行,自己不能往 buffer 末尾追加)。 */
108
+ let nested = false;
109
+ const ensureLiveBatch = () => {
110
+ if (!live || liveBatchId)
111
+ return;
112
+ // 主侧 sub-agent 组容器批 = 父批;本子 agent 的工具批挂在其下并更深一层缩进,
113
+ // 与同组其它子 agent 各归各的 └─ sub-agent 行(通过 groupChildIndex 定锚点)。
114
+ const parentId = batch.batchIdForCall(opts.callId) ?? undefined;
115
+ nested = parentId != null;
116
+ const childIndex = parentId ? batch.getGroupChildIndex(opts.callId) : undefined;
117
+ liveBatchId = batch.beginBatch(t('subagent.running'), {
118
+ parentId,
119
+ indent: parentId ? ' ' : undefined,
120
+ groupChildIndex: childIndex,
121
+ running: true,
122
+ });
123
+ batch.showLiveBatch(liveBatchId, liveLayout());
124
+ // 默认折叠:只显示一行「子 Agent 运行中 · glob X read_file Y」,不展开明细;
125
+ // 用户点击摘要行后才展开看具体工具调用。
126
+ };
127
+ const finishLiveBatch = (status) => {
128
+ if (!live || !liveBatchId)
129
+ return;
130
+ const id = liveBatchId;
131
+ liveBatchId = null;
132
+ if (status)
133
+ batch.setBatchLabel(id, status === 'complete'
134
+ ? t('subagent.complete')
135
+ : status === 'failed' ? t('subagent.failed') : t('subagent.running'));
136
+ // 收尾:清除运行态标志,摘要行图标从「运行中 ◐」切回完成态 ●。
137
+ batch.setBatchRunning(id, false);
138
+ batch.endBatch(id, liveLayout());
139
+ // 子 agent 完成后自动折叠:endBatch 登记了点击,但默认保持展开态;
140
+ // 这里显式折叠回单行摘要,让执行完的批自动收起(用户可再点开)。
141
+ if (status === 'complete' && batch.isExpanded(id))
142
+ batch.toggleBatch(id, liveLayout());
143
+ // 嵌套批的分隔空行由主侧 flushToolBatch 统一补;这里再写会往 buffer 末尾插孤儿空行
144
+ // (并行子 agent 各写一条,块尾堆出空白)。
145
+ if (!nested)
146
+ layout.contentWrite('\n');
147
+ };
87
148
  let lastChar = '';
88
149
  const hooks = {
89
150
  onText: (s) => {
@@ -101,11 +162,24 @@ export async function spawnAgent(opts) {
101
162
  onToolHeader: (tc) => {
102
163
  const summary = summarizeToolCall(tc.name, tc.arguments);
103
164
  writeBuf(` ● ${tc.name} ${summary}\n`);
165
+ // 实时写入主内容区:子 agent 的每次工具调用累计到摘要行计数。
166
+ ensureLiveBatch();
167
+ if (liveBatchId) {
168
+ batch.recordCall(liveBatchId, tc.name, summary);
169
+ // 默认折叠,只刷新摘要行计数(glob/read_file 数量),不展开明细列表。
170
+ batch.showLiveBatch(liveBatchId, liveLayout());
171
+ }
104
172
  },
105
173
  onToolResult: (tc, output) => {
106
174
  const preview = summarizeToolResult(tc.name, output);
107
175
  if (preview)
108
176
  writeBuf(` ↳ ${preview}\n`);
177
+ if (liveBatchId) {
178
+ // 子 agent 批无 diff(写任务走 overlay,最终由主 agent 合并);空 preview 用占位
179
+ // 标记 entry 已完成,避免折叠后摘要仍显示"运行中"。
180
+ batch.recordResult(liveBatchId, tc.name, preview || t('toolSummary.noOutput'), null, output, isToolErrorOutput(output));
181
+ batch.showLiveBatch(liveBatchId, liveLayout());
182
+ }
109
183
  },
110
184
  onTextEnd: () => {
111
185
  if (lastChar && lastChar !== '\n') {
@@ -113,16 +187,30 @@ export async function spawnAgent(opts) {
113
187
  lastChar = '\n';
114
188
  }
115
189
  },
116
- onToolBatchEnd: () => writeBuf('\n'),
190
+ onToolBatchEnd: () => {
191
+ writeBuf('\n');
192
+ // 子 agent 一轮工具调用结束:仍在运行(可能还有后续轮次),仅推进实时摘要,
193
+ // 不折叠——等 onDone 整体完成才自动收起。
194
+ if (liveBatchId)
195
+ batch.showLiveBatch(liveBatchId, liveLayout());
196
+ },
117
197
  onNoReply: () => writeBuf(`${ui.dim}(无回复)${ui.reset}\n`),
118
- onMaxSteps: () => writeBuf(` ● 达到最大步数(${maxSteps}),子 agent 停止。\n`),
198
+ onMaxSteps: () => {
199
+ writeBuf(` ● 达到最大步数(${maxSteps}),子 agent 停止。\n`);
200
+ finishLiveBatch('failed');
201
+ },
119
202
  onDone: (elapsedMs, usage) => {
120
203
  const tok = usage && usage.totalTokens
121
204
  ? ` · ${usage.totalTokens} tokens${usage.cachedTokens ? ` ↻${usage.cachedTokens} cached` : ''}`
122
205
  : '';
123
206
  writeBuf(` ✻ 子 agent 耗时 ${(elapsedMs / 1000).toFixed(1)}s${tok}\n`);
207
+ finishLiveBatch('complete');
208
+ },
209
+ onAbort: () => {
210
+ // 中断:子 agent 未完成,收尾批(不折叠——用户可能想看中断前做了什么)。
211
+ finishLiveBatch('aborted');
124
212
  },
125
- // onStepStart / onChatDone / onToolStart / onToolDone / onAbort:子 agent 静默,无需 spinner / 中断渲染。
213
+ // onStepStart / onChatDone / onToolStart / onToolDone:子 agent 静默,无需 spinner 渲染。
126
214
  // abort 还原(history 还原 + 模式还原)由 core 的 abortRestore 处理,hooks 只管展示。
127
215
  };
128
216
  // 每个子 agent 独享统计/预算状态。不能保存再恢复模块级单例:多个 task 并发时
@@ -84,7 +84,7 @@ const DEFAULT_VOICE = `## Voice
84
84
  - Give technical recommendations with brief trade-off reasoning when choices exist.
85
85
  - Focus on useful information. Avoid unnecessary greetings, apologies, repetition, or filler.
86
86
  - Match the user's style and language while staying task-focused.
87
- - For long operations, briefly state the plan and expected result. Avoid step-by-step narration.
87
+ - Work quietly: jump straight into tool calls without announcing them; reserve visible text for the final answer and truly important mid-task findings. No step-by-step narration.
88
88
  - State assumptions and ask when uncertain. Do not guess.`;
89
89
  /** 解析用户自定义声音:persona.md 文件优先(项目级 > 全局),其次 env MOCODE_PERSONA。无则返回 ''。 */
90
90
  function readPersonaFile() {
@@ -287,7 +287,7 @@ ${buildCodegraphSection()}
287
287
  ${buildWorkDisciplineSection(inferModelFamily(config.model))}
288
288
 
289
289
  ## Tool policy
290
- - During tool-calling turns, stay silent unless something important enough must reach the user otherwise just call the tool and let it run.
290
+ - Silent Execution: invoke tools directly without preamble. Output visible text ONLY for the final answer and critical mid-task findings. Strictly no step-by-step narration (no "let me…", "让我先…", "now checking…" between calls).
291
291
  - Go directly to a known path or symbol; use discovery tools only when the location is unknown.
292
292
  - Edit against a FRESH read: before any edit_file/write_file, call read_file on the exact path and copy both its latest hash and the exact target text. Never reconstruct old_string from a grep/summary/diff — those lose whitespace and indentation and cause edit failures.
293
293
  - A read_file hash from before a compaction, session resume, edit conflict, or external change is STALE and will be rejected — re-read rather than reuse an old hash.
@@ -209,6 +209,9 @@ const zhCN = {
209
209
  'task.interrupted': '子 agent 被中断,未完成。',
210
210
  'task.noSummary': '子 agent 完成但未返回文本摘要(可能只调了工具或达到步数上限)。',
211
211
  'task.summaryTruncated': '…(子 agent 摘要已截断 {count} 字符)',
212
+ 'subagent.running': '子 Agent 运行中',
213
+ 'subagent.complete': '子 Agent 完成',
214
+ 'subagent.failed': '子 Agent 失败',
212
215
  'subagent.status': '子 Agent:{state}',
213
216
  'subagent.stateOn': '开启',
214
217
  'subagent.stateOff': '关闭',
@@ -461,6 +464,9 @@ const en = {
461
464
  'task.interrupted': 'The sub-agent was interrupted before completion.',
462
465
  'task.noSummary': 'The sub-agent completed without a text summary (it may only have used tools or reached its step limit).',
463
466
  'task.summaryTruncated': '…(sub-agent summary truncated by {count} characters)',
467
+ 'subagent.running': 'Sub-agent running',
468
+ 'subagent.complete': 'Sub-agent complete',
469
+ 'subagent.failed': 'Sub-agent failed',
464
470
  'subagent.status': 'Sub-agent: {state}',
465
471
  'subagent.stateOn': 'enabled',
466
472
  'subagent.stateOff': 'disabled',
@@ -276,9 +276,12 @@ function readPlanStatusFromNotes() {
276
276
  if (!title)
277
277
  return null;
278
278
  const total = (section.match(/^\s*-\s*\[[ xX]\]\s*\d+\./gm) || []).length;
279
- const done = (section.match(/^\s*-\s*\[[xX]\]\s*\d+\./gm) || []).length;
280
- const current = section.match(/^\s*-\s*\[ \]\s*\d+\.\s*(.+)$/m)?.[1].trim();
281
- const summary = `plan: ${title} (${done}/${total})`;
279
+ const currentMatch = section.match(/^\s*-\s*\[ \]\s*(\d+)\.\s*(.+)$/m);
280
+ const current = currentMatch?.[2].trim();
281
+ // 括号进度 = 「当前执行到的步骤序号/总数」(执行第 1 步显示 1/3),与 `▸ 当前步` 后缀自洽;
282
+ // 旧语义 done/total 会永远慢一拍(执行第 2 步显示 1/3)。全勾选时显示 total/total(通常已自动结算为 Done,chip 消失)。
283
+ const activeNo = currentMatch ? Number(currentMatch[1]) : total;
284
+ const summary = `plan: ${title} (${activeNo}/${total})`;
282
285
  // mtime 让“相同内容被重写为一项新计划”也能重新出现,而不被旧轮次误抑制。
283
286
  const fingerprint = `${sessionId}\0${fs.statSync(p).mtimeMs}\0${section}`;
284
287
  return { fingerprint, summary: current ? `${summary} ▸ ${current}` : summary };
@@ -41,7 +41,12 @@ expected_hash is required (sha256 from read_file artifact header) and must match
41
41
  line_end: { type: 'integer', description: 'Line-range mode: end line (1-based, inclusive). Mutually exclusive with old_string.' },
42
42
  expected_hash: { type: 'string', description: 'sha256 hash from the latest read_file artifact header (sha256:<64 hex>).' },
43
43
  },
44
- required: ['path', 'new_string', 'expected_hash'],
44
+ // 注意:expected_hash 故意不放进 schema 的 required。
45
+ // 原因:缺失时若被 AJV 在 schema 层拦下,只会得到泛化的
46
+ // "缺少必填字段 expected_hash",模型据此只补该字段、反而丢掉 path,形成乒乓失败。
47
+ // 改为只在 execute 内校验(见下方 normalizeContentHash 检查),缺失时返回富指导信息,
48
+ // 明确要求先 read_file 并复制最新 hash——与 write_file 的设计保持一致。
49
+ required: ['path', 'new_string'],
45
50
  },
46
51
  async execute(args, ctx) {
47
52
  const file = String(args.path);
@@ -63,7 +63,8 @@ export const subAgentTool = {
63
63
  const writeSet = Array.isArray(args.writeSet) ? args.writeSet.map(String) : undefined;
64
64
  const context = typeof args.context === 'string' ? args.context.slice(0, 4000) : undefined;
65
65
  // 透传主 agent 的 abort signal:主 Ctrl+C 树杀子 agent(chat abort + 工具 abort)。
66
- const result = await spawnAgent({ prompt, tools, maxSteps, signal: ctx?.signal, mode, writeSet, context });
66
+ // callId 透传:子 agent 实时渲染据此挂靠主侧对应批次(并行派发时各行归位)
67
+ const result = await spawnAgent({ prompt, tools, maxSteps, signal: ctx?.signal, mode, writeSet, context, callId: ctx?.callId });
67
68
  let output;
68
69
  if (!result.completed) {
69
70
  output = `[SubAgentResult status=${result.status} tokens=${result.usage.totalTokens} readSet=${JSON.stringify(result.readSet)} changeSet=${result.changeSet?.id ?? 'none'} verification=not-run]\n${result.summary ?? t('task.interrupted')}`;
@@ -153,7 +153,7 @@ async function executeToolOnce(tool, args, signal, opts) {
153
153
  : null;
154
154
  let raw;
155
155
  try {
156
- raw = await tool.execute(args, { signal });
156
+ raw = await tool.execute(args, { signal, callId: opts?.callId });
157
157
  }
158
158
  finally {
159
159
  if (pathCapture)
@@ -37,10 +37,15 @@ function compile(schema) {
37
37
  cache.set(schema, result);
38
38
  return result;
39
39
  }
40
- function formatErrors(errors) {
40
+ function formatErrors(errors, schema) {
41
41
  if (!errors?.length)
42
42
  return '参数不符合 JSON Schema';
43
- return errors.slice(0, 5).map((error) => {
43
+ // 收集所有「缺少必填字段」涉及的属性名,用于在文末一次性列出完整必填签名,
44
+ // 避免模型只补齐报错点名的一个字段、下一次又把别的字段弄丢(乒乓失败)。
45
+ const missing = errors
46
+ .filter((e) => e.keyword === 'required')
47
+ .map((e) => String(e.params.missingProperty ?? '?'));
48
+ const body = errors.slice(0, 5).map((error) => {
44
49
  const location = error.instancePath || '/';
45
50
  if (error.keyword === 'required') {
46
51
  const property = String(error.params.missingProperty ?? '?');
@@ -52,6 +57,16 @@ function formatErrors(errors) {
52
57
  }
53
58
  return `${location} ${error.message ?? error.keyword}`;
54
59
  }).join('; ');
60
+ let hint = '';
61
+ const required = schema?.required ?? [];
62
+ const properties = schema?.properties ?? {};
63
+ if (missing.length > 0 && required.length > 0) {
64
+ const lines = required
65
+ .map((k) => `- ${k}: ${properties[k]?.description ?? '(无描述)'}`)
66
+ .join('\n');
67
+ hint = `。请一次性补齐全部必填参数,不要在重试时只补其中一部分:\n${lines}`;
68
+ }
69
+ return body + hint;
55
70
  }
56
71
  /** Validate after tool-local normalization; AJV itself never coerces or mutates arguments. */
57
72
  export function validateToolArguments(tool, args) {
@@ -85,6 +100,6 @@ export function validateToolArguments(tool, args) {
85
100
  return {
86
101
  valid: false,
87
102
  code: 'INVALID_ARGUMENTS',
88
- message: formatErrors(compiled.validate.errors),
103
+ message: formatErrors(compiled.validate.errors, tool.parameters),
89
104
  };
90
105
  }
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.1",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {