@trim21/personal-pi-extensions 0.1.631 → 0.1.634

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.631",
3
+ "version": "0.1.634",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Claude Code 风格的精确替换:只做精确字符串匹配(无模糊策略)。匹配在 CRLF 归一后
3
+ * 进行,写回时恢复原文行尾;new_string 为空且 old_string 不以换行结尾时,连同
4
+ * 紧随其后的换行一起删除,避免留下空行。
5
+ *
6
+ * 只处理非空 `old_string` 的替换:空 `old_string`(创建 / 填充文件)与
7
+ * `old_string === new_string` 的判定在调用方(带工具语义,涉及写盘与提示文案)。
8
+ *
9
+ * 抛错文案是工具契约的一部分(Claude Code 兼容),改动前先看
10
+ * test/claude-code-tools.test.ts 的断言。
11
+ */
12
+ export function applyExactEdit(
13
+ original: string,
14
+ oldString: string,
15
+ newString: string,
16
+ replaceAll: boolean,
17
+ ): string {
18
+ // CRLF 规范化后匹配(old_string 不需要带 \r),写回时恢复原行尾
19
+ const crlfCount = (original.match(/\r\n/g) ?? []).length;
20
+ const lfCount = (original.match(/(?<!\r)\n/g) ?? []).length;
21
+ const lineEnding = crlfCount > lfCount ? "\r\n" : "\n";
22
+ const normalized = original.replaceAll("\r\n", "\n");
23
+ const matches = normalized.split(oldString).length - 1;
24
+ if (matches === 0) {
25
+ throw new Error(`String to replace not found in file.\nString: ${oldString}`);
26
+ }
27
+ if (!replaceAll && matches > 1) {
28
+ throw new Error(
29
+ `Found ${matches} matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.\nString: ${oldString}`,
30
+ );
31
+ }
32
+ // 删除场景(new_string 为空):old_string 不以换行结尾且文件里是
33
+ // "old_string\n" 时连换行一起删,避免留下空行(对齐 Claude Code
34
+ // applyEditToFile 的 stripTrailingNewline 语义)
35
+ let searchString = oldString;
36
+ if (newString === "" && !oldString.endsWith("\n") && normalized.includes(oldString + "\n")) {
37
+ searchString = oldString + "\n";
38
+ }
39
+ // split/join 与函数替换:replacement 含 $ 时不会触发 $& 等特殊语义
40
+ const updated = replaceAll
41
+ ? normalized.split(searchString).join(newString)
42
+ : normalized.replace(searchString, () => newString);
43
+ return lineEnding === "\r\n" ? updated.replaceAll("\n", "\r\n") : updated;
44
+ }
@@ -31,6 +31,7 @@ import type { ToolPendant } from "../lib/pendant.ts";
31
31
  import { createRequestPolicy, type RequestPolicy } from "../lib/request-policy.js";
32
32
  import { guardWriteAccess } from "../lib/write-guard.js";
33
33
  import { didYouMean, resolveToolFilePath } from "./common.js";
34
+ import { applyExactEdit } from "./edit-match.js";
34
35
  import { convertLeadingTabsToSpaces } from "./edit-utils.js";
35
36
 
36
37
  const SAMPLE_BYTES = 4096;
@@ -170,47 +171,6 @@ export function formatReadOutput(
170
171
  return { text, totalLines };
171
172
  }
172
173
 
173
- function countMatches(content: string, needle: string): number {
174
- if (needle === "") {
175
- return 0;
176
- }
177
- let count = 0;
178
- let index = 0;
179
- while ((index = content.indexOf(needle, index)) !== -1) {
180
- count++;
181
- index += needle.length;
182
- }
183
- return count;
184
- }
185
-
186
- export function exactReplace(
187
- content: string,
188
- oldString: string,
189
- newString: string,
190
- replaceAll = false,
191
- ): string {
192
- if (oldString === newString) {
193
- throw new Error("No changes to apply: old_string and new_string are identical.");
194
- }
195
- if (oldString === "") {
196
- throw new Error("old_string must not be empty.");
197
- }
198
- const matches = countMatches(content, oldString);
199
- if (matches === 0) {
200
- throw new Error("String to replace not found in file.");
201
- }
202
- if (!replaceAll && matches > 1) {
203
- throw new Error(
204
- `Found ${matches} matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, provide more context to make old_string unique.`,
205
- );
206
- }
207
- if (replaceAll) {
208
- return content.split(oldString).join(newString);
209
- }
210
- const index = content.indexOf(oldString);
211
- return content.slice(0, index) + newString + content.slice(index + oldString.length);
212
- }
213
-
214
174
  export function registerFileTools(
215
175
  pi: ExtensionAPI,
216
176
  state: ReadsState,
@@ -483,37 +443,7 @@ export function registerFileTools(
483
443
  await access(filePath, constants.R_OK | constants.W_OK);
484
444
  signal?.throwIfAborted();
485
445
  const original = content.toString("utf8");
486
-
487
- // CRLF 规范化后匹配(old_string 不需要带 \r),写回时恢复原行尾
488
- const crlfCount = (original.match(/\r\n/g) ?? []).length;
489
- const lfCount = (original.match(/(?<!\r)\n/g) ?? []).length;
490
- const lineEnding = crlfCount > lfCount ? "\r\n" : "\n";
491
- const normalized = original.replaceAll("\r\n", "\n");
492
- const matches = normalized.split(oldString).length - 1;
493
- if (matches === 0) {
494
- throw new Error(`String to replace not found in file.\nString: ${oldString}`);
495
- }
496
- if (!replaceAll && matches > 1) {
497
- throw new Error(
498
- `Found ${matches} matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.\nString: ${oldString}`,
499
- );
500
- }
501
- // 删除场景(new_string 为空):old_string 不以换行结尾且文件里是
502
- // "old_string\n" 时连换行一起删,避免留下空行(对齐 Claude Code
503
- // applyEditToFile 的 stripTrailingNewline 语义)
504
- let searchString = oldString;
505
- if (
506
- newString === "" &&
507
- !oldString.endsWith("\n") &&
508
- normalized.includes(oldString + "\n")
509
- ) {
510
- searchString = oldString + "\n";
511
- }
512
- // split/join 与函数替换:replacement 含 $ 时不会触发 $& 等特殊语义
513
- const updated = replaceAll
514
- ? normalized.split(searchString).join(newString)
515
- : normalized.replace(searchString, () => newString);
516
- const restored = lineEnding === "\r\n" ? updated.replaceAll("\n", "\r\n") : updated;
446
+ const restored = applyExactEdit(original, oldString, newString, replaceAll);
517
447
  await writeFile(filePath, restored, "utf8");
518
448
  const snapshot = snapshotOf(restored);
519
449
  state.reads.set(key, snapshot);
@@ -65,7 +65,11 @@ import {
65
65
 
66
66
  import type { LspServerHandle } from "./adapter.js";
67
67
  import { LANGUAGE_EXTENSIONS } from "./language.js";
68
- import { editFilePaths, stabilityAcceptable, trackStability } from "./rename.js";
68
+ import {
69
+ DEFAULT_RENAME_VERIFICATION_TIMING,
70
+ type RenameVerificationTiming,
71
+ verifyRenameCoverage,
72
+ } from "./rename.js";
69
73
  import type { FileChange, FileChangeType } from "./watcher.js";
70
74
 
71
75
  /** LSP WatchKind 位掩码(FileSystemWatcher.kind,缺省 create|change|delete)。 */
@@ -108,66 +112,11 @@ export class LspMethodNotSupportedError extends Error {
108
112
  }
109
113
  }
110
114
 
111
- /**
112
- * rename edit 未覆盖 references 看到的全部文件:服务器索引可能仍在后台加载。
113
- * 抛出时发生在写盘之前,整个 rename 无副作用,可稍后重试。
114
- */
115
- export class RenameIncompleteError extends Error {
116
- readonly missing: readonly string[];
117
- readonly extra: readonly string[];
118
- constructor(missing: readonly string[], extra: readonly string[] = []) {
119
- const parts: string[] = [];
120
- if (missing.length > 0) {
121
- parts.push(
122
- `textDocument/references found the symbol in ${missing.length} file(s) ` +
123
- `that the rename edit does not cover (${missing.join(", ")})`,
124
- );
125
- }
126
- if (extra.length > 0) {
127
- parts.push(
128
- `the rename edit touches ${extra.length} file(s) ` +
129
- `that textDocument/references did not report (${extra.join(", ")})`,
130
- );
131
- }
132
- if (parts.length === 0) {
133
- parts.push("the references result has not been stable long enough to trust");
134
- }
135
- super(
136
- `LSP rename incomplete: ${parts.join("; ")}. ` +
137
- `The server index may still be loading; nothing was modified, retry shortly.`,
138
- );
139
- this.missing = missing;
140
- this.extra = extra;
141
- }
142
- }
143
-
144
- /**
145
- * rename 覆盖校验的轮询节奏。budgetMs 是 references 收敛 + 重试的总预算;
146
- * 测试可临时缩小以缩短等待。
147
- */
148
- export const renameVerificationTiming = {
149
- pollMs: 400,
150
- budgetMs: 15_000,
151
- /** ContentModified(-32801) 重试上限:服务器处理期间文档被修改,重发请求即可。 */
152
- contentModifiedRetries: 3,
153
- /** 接受结果所需的最少连续一致采样次数。 */
154
- settleSamples: 3,
155
- /** 就绪已证实(拿到过当前版本的诊断结论)时的稳定窗口下限(ms)。 */
156
- stableFloorReadyMs: 400,
157
- /**
158
- * 就绪未证实(栅栏只是等满预算放行)时的稳定窗口下限(ms)。服务器加载
159
- * 项目期间 references 只覆盖已发现的文件,残缺答案能连续多次一致(假稳定),
160
- * 短窗口会把它误判成最终结果——CI 实测漏改跨文件引用,故要求 references
161
- * 文件集合至少持续一致这么久才接受。
162
- */
163
- stableFloorUnreadyMs: 4_000,
164
- };
165
-
166
115
  /**
167
116
  * ContentModified 语义:请求处理期间 salsa 数据库被文件变更修改,服务器请
168
117
  * 客户端重发请求。重发无副作用,直接重试;仅连续超限才向上抛,避免无限循环。
169
118
  */
170
- async function retryOnContentModified<T>(fn: () => Promise<T>): Promise<T> {
119
+ async function retryOnContentModified<T>(fn: () => Promise<T>, retries: number): Promise<T> {
171
120
  for (let attempt = 0; ; attempt++) {
172
121
  try {
173
122
  return await fn();
@@ -175,7 +124,7 @@ async function retryOnContentModified<T>(fn: () => Promise<T>): Promise<T> {
175
124
  if (!(error instanceof ResponseError && error.code === LSPErrorCodes.ContentModified)) {
176
125
  throw error;
177
126
  }
178
- if (attempt >= renameVerificationTiming.contentModifiedRetries) {
127
+ if (attempt >= retries) {
179
128
  throw error;
180
129
  }
181
130
  }
@@ -348,6 +297,8 @@ export interface CreateInput {
348
297
  initializeTimeoutMs?: number;
349
298
  /** 驻留文档上限(LRU 容量,缺省 32);超过时淘汰最久未使用并 didClose。 */
350
299
  maxOpenDocuments?: number;
300
+ /** 覆盖 rename 覆盖校验的轮询节奏(缺省见 DEFAULT_RENAME_VERIFICATION_TIMING)。 */
301
+ renameVerificationTiming?: Partial<RenameVerificationTiming>;
351
302
  }
352
303
 
353
304
  export interface LspClient {
@@ -573,6 +524,10 @@ export async function create(input: CreateInput): Promise<LspClient> {
573
524
  input.diagnosticsRequestTimeoutMs ?? clientDefaults.diagnosticsRequestTimeoutMs;
574
525
  const initializeTimeoutMs = input.initializeTimeoutMs ?? clientDefaults.initializeTimeoutMs;
575
526
  const maxOpenDocuments = input.maxOpenDocuments ?? clientDefaults.maxOpenDocuments;
527
+ const timing: RenameVerificationTiming = {
528
+ ...DEFAULT_RENAME_VERIFICATION_TIMING,
529
+ ...input.renameVerificationTiming,
530
+ };
576
531
 
577
532
  const connection = createProtocolConnection(
578
533
  new StreamMessageReader(input.server.process.stdout),
@@ -1377,7 +1332,10 @@ export async function create(input: CreateInput): Promise<LspClient> {
1377
1332
  signal?: AbortSignal,
1378
1333
  ): Promise<R> => {
1379
1334
  try {
1380
- return await retryOnContentModified(() => sendAbortableRequest(type, message, signal));
1335
+ return await retryOnContentModified(
1336
+ () => sendAbortableRequest(type, message, signal),
1337
+ timing.contentModifiedRetries,
1338
+ );
1381
1339
  } catch (error) {
1382
1340
  if (error instanceof ResponseError && error.code === ErrorCodes.MethodNotFound) {
1383
1341
  throw new LspMethodNotSupportedError(input.serverID, type.method);
@@ -1477,32 +1435,20 @@ export async function create(input: CreateInput): Promise<LspClient> {
1477
1435
  const notRenameable = () =>
1478
1436
  new RenameNotPossibleError(`LSP server "${input.serverID}" cannot rename at ${at}`);
1479
1437
 
1480
- // references 前置 + rename 双向校验:LSP 没有标准化的"索引完成"信号,
1481
- // 服务器(如 tsserver)可能在项目加载完成前回答,导致 rename 漏掉
1482
- // 尚未入索引的文件。对策分三层:
1483
- // 1. 稳定窗口:references 文件集合连续 settleSamples 次一致、且持续
1484
- // 一致超过稳定下限(就绪未证实用长窗口)才认为收敛,防止"服务器
1485
- // 根本还没发现某文件"时残缺答案的假稳定被误判为最终结果;
1486
- // 2. 覆盖校验(missing):references 报告的文件必须都被 rename edit
1487
- // 覆盖,缺失说明服务器索引落后,抛 RenameIncompleteError;
1488
- // 3. 一致性校验(extra):rename 触及的文件超出已收敛的 references
1489
- // 集合,说明两次请求之间项目覆盖在增长(rename 晚于 references,
1490
- // 索引仍在加载),此时 rename 的结果本身不可信——回到 references
1491
- // 轮询等重新收敛,再重发 rename 复检;预算耗尽仍不一致时抛
1492
- // RenameIncompleteError——调用方尚未写盘,整个操作无副作用。
1493
- // 服务器不支持 references(MethodNotFound)时跳过校验,信任服务器,
1494
- // 与编辑器行为一致。
1438
+ // references 前置 + rename 双向校验见 verifyRenameCoverage(三层策略的说明在那边)。
1495
1439
  const referencesRequest = () =>
1496
- retryOnContentModified(() =>
1497
- sendAbortableRequest(
1498
- ReferencesRequest.type,
1499
- {
1500
- textDocument: { uri },
1501
- position,
1502
- context: { includeDeclaration: true },
1503
- },
1504
- request.signal,
1505
- ),
1440
+ retryOnContentModified(
1441
+ () =>
1442
+ sendAbortableRequest(
1443
+ ReferencesRequest.type,
1444
+ {
1445
+ textDocument: { uri },
1446
+ position,
1447
+ context: { includeDeclaration: true },
1448
+ },
1449
+ request.signal,
1450
+ ),
1451
+ timing.contentModifiedRetries,
1506
1452
  );
1507
1453
 
1508
1454
  const toPaths = (locations: { uri: string }[] | null): Set<string> =>
@@ -1514,16 +1460,18 @@ export async function create(input: CreateInput): Promise<LspClient> {
1514
1460
 
1515
1461
  const sendRename = async (): Promise<WorkspaceEdit | null> => {
1516
1462
  try {
1517
- return await retryOnContentModified(() =>
1518
- sendAbortableRequest(
1519
- RenameRequest.type,
1520
- {
1521
- textDocument: { uri },
1522
- position,
1523
- newName: request.newName,
1524
- },
1525
- request.signal,
1526
- ),
1463
+ return await retryOnContentModified(
1464
+ () =>
1465
+ sendAbortableRequest(
1466
+ RenameRequest.type,
1467
+ {
1468
+ textDocument: { uri },
1469
+ position,
1470
+ newName: request.newName,
1471
+ },
1472
+ request.signal,
1473
+ ),
1474
+ timing.contentModifiedRetries,
1527
1475
  );
1528
1476
  } catch (error) {
1529
1477
  if (error instanceof ResponseError && error.code === ErrorCodes.MethodNotFound) {
@@ -1537,12 +1485,14 @@ export async function create(input: CreateInput): Promise<LspClient> {
1537
1485
  if (hasPrepareProvider) {
1538
1486
  let prepared: PrepareRenameResult | null;
1539
1487
  try {
1540
- prepared = await retryOnContentModified(() =>
1541
- sendAbortableRequest(
1542
- PrepareRenameRequest.type,
1543
- { textDocument: { uri }, position },
1544
- request.signal,
1545
- ),
1488
+ prepared = await retryOnContentModified(
1489
+ () =>
1490
+ sendAbortableRequest(
1491
+ PrepareRenameRequest.type,
1492
+ { textDocument: { uri }, position },
1493
+ request.signal,
1494
+ ),
1495
+ timing.contentModifiedRetries,
1546
1496
  );
1547
1497
  } catch (error) {
1548
1498
  if (error instanceof ResponseError && error.code === ErrorCodes.MethodNotFound) {
@@ -1572,63 +1522,18 @@ export async function create(input: CreateInput): Promise<LspClient> {
1572
1522
  return placeholder === undefined ? { edit } : { edit, placeholder };
1573
1523
  }
1574
1524
 
1575
- const minStableMs = indexReady
1576
- ? renameVerificationTiming.stableFloorReadyMs
1577
- : renameVerificationTiming.stableFloorUnreadyMs;
1578
- const deadline = Date.now() + renameVerificationTiming.budgetMs;
1579
- let stability = trackStability({
1580
- previous: undefined,
1581
- paths: toPaths(locations),
1582
- now: Date.now(),
1525
+ const edit = await verifyRenameCoverage({
1526
+ indexReady,
1527
+ timing,
1528
+ initialPaths: toPaths(locations),
1529
+ refetchPaths: async () => toPaths(await referencesRequest()),
1530
+ sendRename: async () => (await sendRename()) ?? null,
1531
+ sleep: sleepWithSignal,
1532
+ now: () => Date.now(),
1533
+ notRenameable,
1534
+ signal: request.signal,
1583
1535
  });
1584
- for (;;) {
1585
- request.signal?.throwIfAborted();
1586
- const now = Date.now();
1587
- const stable = stabilityAcceptable(stability, {
1588
- now,
1589
- minSamples: renameVerificationTiming.settleSamples,
1590
- minStableMs,
1591
- });
1592
- const expired = now >= deadline;
1593
- if (stable || expired) {
1594
- const edit = await sendRename();
1595
- if (!edit) {
1596
- throw notRenameable();
1597
- }
1598
- const editPaths = editFilePaths(edit);
1599
- const missing: string[] = [];
1600
- const extra: string[] = [];
1601
- for (const path of stability.paths) {
1602
- if (!editPaths.has(path)) {
1603
- missing.push(path);
1604
- }
1605
- }
1606
- for (const path of editPaths) {
1607
- if (!stability.paths.has(path)) {
1608
- extra.push(path);
1609
- }
1610
- }
1611
- if (missing.length === 0 && extra.length === 0) {
1612
- if (stable) {
1613
- return placeholder === undefined ? { edit } : { edit, placeholder };
1614
- }
1615
- // 双向一致但稳定窗口未达标:索引可能仍在加载、残缺答案假稳定,
1616
- // 宁可报可重试的不完整错误,也不把残缺结果当成功写盘。
1617
- throw new RenameIncompleteError([], []);
1618
- }
1619
- if (expired || missing.length > 0) {
1620
- throw new RenameIncompleteError(missing, extra);
1621
- }
1622
- // rename 报出 references 没有的文件:references 快照已过时,
1623
- // 继续轮询到重新收敛后再重发 rename 复检(预算耗尽则向上抛)。
1624
- }
1625
- await sleepWithSignal(renameVerificationTiming.pollMs, request.signal);
1626
- stability = trackStability({
1627
- previous: stability,
1628
- paths: toPaths(await referencesRequest()),
1629
- now: Date.now(),
1630
- });
1631
- }
1536
+ return placeholder === undefined ? { edit } : { edit, placeholder };
1632
1537
  },
1633
1538
  async definition(request: InspectPositionRequest): Promise<InspectLocation[]> {
1634
1539
  const { uri, position } = await preparePositionRequest(request);
@@ -6,7 +6,9 @@
6
6
  * - lineCandidates:character 缺省时在一行内按词边界枚举候选位置,
7
7
  * 供逐候选探测消歧;
8
8
  * - canonicalizeEdit:把两次 rename 的结果归一化成稳定字符串,
9
- * 比较它们是否指向同一个符号(同名歧义消解)。
9
+ * 比较它们是否指向同一个符号(同名歧义消解);
10
+ * - verifyRenameCoverage:等 references 的稳定窗口收敛,再双向校验 rename edit
11
+ * 的覆盖(missing / extra),不满足时抛 RenameIncompleteError。
10
12
  *
11
13
  * 位置语义按 LSP 规范:0-based 行列,character 为 UTF-16 code unit,
12
14
  * 行内容不含行结束符(CRLF 的 \r 不计入列号)。
@@ -243,6 +245,177 @@ export function stabilityAcceptable(
243
245
  return run.samples >= input.minSamples && input.now - run.since >= input.minStableMs;
244
246
  }
245
247
 
248
+ /**
249
+ * rename edit 未覆盖 references 看到的全部文件:服务器索引可能仍在后台加载。
250
+ * 抛出时发生在写盘之前,整个 rename 无副作用,可稍后重试。
251
+ */
252
+ export class RenameIncompleteError extends Error {
253
+ readonly missing: readonly string[];
254
+ readonly extra: readonly string[];
255
+ constructor(missing: readonly string[], extra: readonly string[] = []) {
256
+ const parts: string[] = [];
257
+ if (missing.length > 0) {
258
+ parts.push(
259
+ `textDocument/references found the symbol in ${missing.length} file(s) ` +
260
+ `that the rename edit does not cover (${missing.join(", ")})`,
261
+ );
262
+ }
263
+ if (extra.length > 0) {
264
+ parts.push(
265
+ `the rename edit touches ${extra.length} file(s) ` +
266
+ `that textDocument/references did not report (${extra.join(", ")})`,
267
+ );
268
+ }
269
+ if (parts.length === 0) {
270
+ parts.push("the references result has not been stable long enough to trust");
271
+ }
272
+ super(
273
+ `LSP rename incomplete: ${parts.join("; ")}. ` +
274
+ `The server index may still be loading; nothing was modified, retry shortly.`,
275
+ );
276
+ this.missing = missing;
277
+ this.extra = extra;
278
+ }
279
+ }
280
+
281
+ /**
282
+ * rename 覆盖校验的轮询节奏。budgetMs 是 references 收敛 + 重试的总预算;
283
+ * 缺省见 DEFAULT_RENAME_VERIFICATION_TIMING,client 与测试按需覆盖。
284
+ */
285
+ export interface RenameVerificationTiming {
286
+ pollMs: number;
287
+ budgetMs: number;
288
+ /** ContentModified(-32801) 重试上限:服务器处理期间文档被修改,重发请求即可。 */
289
+ contentModifiedRetries: number;
290
+ /** 接受结果所需的最少连续一致采样次数。 */
291
+ settleSamples: number;
292
+ /** 就绪已证实(拿到过当前版本的诊断结论)时的稳定窗口下限(ms)。 */
293
+ stableFloorReadyMs: number;
294
+ /**
295
+ * 就绪未证实(栅栏只是等满预算放行)时的稳定窗口下限(ms)。服务器加载
296
+ * 项目期间 references 只覆盖已发现的文件,残缺答案能连续多次一致(假稳定),
297
+ * 短窗口会把它误判成最终结果——CI 实测漏改跨文件引用,故要求 references
298
+ * 文件集合至少持续一致这么久才接受。
299
+ */
300
+ stableFloorUnreadyMs: number;
301
+ }
302
+
303
+ /** rename 覆盖校验的缺省节奏(每个 client 可用创建参数局部覆盖)。 */
304
+ export const DEFAULT_RENAME_VERIFICATION_TIMING: RenameVerificationTiming = {
305
+ pollMs: 400,
306
+ budgetMs: 15_000,
307
+ contentModifiedRetries: 3,
308
+ settleSamples: 3,
309
+ stableFloorReadyMs: 400,
310
+ stableFloorUnreadyMs: 4_000,
311
+ };
312
+
313
+ /** verifyRenameCoverage 的输入:依赖全部注入,收敛序列与预算可直接构造。 */
314
+ export interface RenameCoverageOptions {
315
+ /** 就绪栅栏:是否已证实拿到当前版本的诊断结论(决定稳定窗口下限)。 */
316
+ indexReady: boolean;
317
+ timing: RenameVerificationTiming;
318
+ /** 进入校验前已采到的那一份 references 文件集合。 */
319
+ initialPaths: ReadonlySet<string>;
320
+ /** 后续采样:再请求一次 references 并给出文件集合。 */
321
+ refetchPaths: () => Promise<ReadonlySet<string>>;
322
+ /** 发一次 rename;返回 null 表示服务器拒绝重命名。 */
323
+ sendRename: () => Promise<WorkspaceEdit | null>;
324
+ /** 等待一次轮询间隔。 */
325
+ sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
326
+ /** 当前时间戳(ms)。 */
327
+ now: () => number;
328
+ /** 构造「该位置不可重命名」错误:消息含 serverID 与位置,由调用方提供。 */
329
+ notRenameable: () => Error;
330
+ signal?: AbortSignal;
331
+ }
332
+
333
+ /**
334
+ * 等 references 收敛并双向校验 rename 覆盖,返回 edit;不满足覆盖要求时抛
335
+ * RenameIncompleteError。
336
+ *
337
+ * references 前置 + rename 双向校验:LSP 没有标准化的"索引完成"信号,
338
+ * 服务器(如 tsserver)可能在项目加载完成前回答,导致 rename 漏掉
339
+ * 尚未入索引的文件。对策分三层:
340
+ * 1. 稳定窗口:references 文件集合连续 settleSamples 次一致、且持续
341
+ * 一致超过稳定下限(就绪未证实用长窗口)才认为收敛,防止"服务器
342
+ * 根本还没发现某文件"时残缺答案的假稳定被误判为最终结果;
343
+ * 2. 覆盖校验(missing):references 报告的文件必须都被 rename edit
344
+ * 覆盖,缺失说明服务器索引落后,抛 RenameIncompleteError;
345
+ * 3. 一致性校验(extra):rename 触及的文件超出已收敛的 references
346
+ * 集合,说明两次请求之间项目覆盖在增长(rename 晚于 references,
347
+ * 索引仍在加载),此时 rename 的结果本身不可信——回到 references
348
+ * 轮询等重新收敛,再重发 rename 复检;预算耗尽仍不一致时抛
349
+ * RenameIncompleteError——调用方尚未写盘,整个操作无副作用。
350
+ *
351
+ * 首次采样由调用方发起(initialPaths),本函数只负责后续的 refetchPaths
352
+ * 采样,所以请求次数与调用方内联轮询时逐次一致;服务器不支持
353
+ * references(MethodNotFound)时由调用方跳过本校验,信任服务器。
354
+ *
355
+ * 判定顺序是行为的一部分:双向一致但稳定窗口未达标时抛的是
356
+ * RenameIncompleteError([], [])(残缺答案假稳定),而非带上 missing 的错误。
357
+ */
358
+ export async function verifyRenameCoverage(options: RenameCoverageOptions): Promise<WorkspaceEdit> {
359
+ const {
360
+ indexReady,
361
+ timing,
362
+ initialPaths,
363
+ refetchPaths,
364
+ sendRename,
365
+ sleep,
366
+ now,
367
+ notRenameable,
368
+ signal,
369
+ } = options;
370
+ const minStableMs = indexReady ? timing.stableFloorReadyMs : timing.stableFloorUnreadyMs;
371
+ const deadline = now() + timing.budgetMs;
372
+ let stability = trackStability({ previous: undefined, paths: initialPaths, now: now() });
373
+ for (;;) {
374
+ signal?.throwIfAborted();
375
+ const at = now();
376
+ const stable = stabilityAcceptable(stability, {
377
+ now: at,
378
+ minSamples: timing.settleSamples,
379
+ minStableMs,
380
+ });
381
+ const expired = at >= deadline;
382
+ if (stable || expired) {
383
+ const edit = await sendRename();
384
+ if (!edit) {
385
+ throw notRenameable();
386
+ }
387
+ const editPaths = editFilePaths(edit);
388
+ const missing: string[] = [];
389
+ const extra: string[] = [];
390
+ for (const path of stability.paths) {
391
+ if (!editPaths.has(path)) {
392
+ missing.push(path);
393
+ }
394
+ }
395
+ for (const path of editPaths) {
396
+ if (!stability.paths.has(path)) {
397
+ extra.push(path);
398
+ }
399
+ }
400
+ if (missing.length === 0 && extra.length === 0) {
401
+ if (stable) {
402
+ return edit;
403
+ }
404
+ // 双向一致但稳定窗口未达标:索引可能仍在加载、残缺答案假稳定,
405
+ // 宁可报可重试的不完整错误,也不把残缺结果当成功写盘。
406
+ throw new RenameIncompleteError([], []);
407
+ }
408
+ if (expired || missing.length > 0) {
409
+ throw new RenameIncompleteError(missing, extra);
410
+ }
411
+ // rename 报出 references 没有的文件:references 快照已过时,
412
+ // 继续轮询到重新收敛后再重发 rename 复检(预算耗尽则向上抛)。
413
+ }
414
+ await sleep(timing.pollMs, signal);
415
+ stability = trackStability({ previous: stability, paths: await refetchPaths(), now: now() });
416
+ }
417
+ }
418
+
246
419
  /**
247
420
  * 一行内 `symbol` 的候选位置(0-based,列相对行首):
248
421
  * - `character` 缺省:枚举行内与 `symbol` 相同的词出现位置;
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Progress state machine for the `spawn_agent` tool.
3
+ *
4
+ * The parent sees a rolling log: `tool: <name>` lines for tool calls and
5
+ * `text: <content>` lines for completed text blocks, keeping the last
6
+ * `MAX_PROGRESS_LINES` lines. Consecutive tool calls are merged into a single
7
+ * `tool:` line (`read x 2, glob`) and the merged content is folded to the
8
+ * first/last 9 chars joined by `…`, so a burst of tool calls or a long text
9
+ * block does not flood the window. Only a text block starts a new line —
10
+ * thinking is a transient status line, so it does not break the merge across
11
+ * turn boundaries. Line content is sanitized first: markdown marker characters
12
+ * are stripped and whitespace (including newlines) is collapsed to single
13
+ * spaces, so one log entry is always exactly one rendered line.
14
+ *
15
+ * The rendered panel is the window, then an optional transient
16
+ * `thinking ( N chars )` line while the model is thinking, then a fixed footer
17
+ * line: the subagent name as a code span (`` `scout` ``) followed by the live
18
+ * usage stats when there are any. The thinking line and the footer ride
19
+ * outside the rolling window, so the footer is never trimmed. The model name
20
+ * on the footer is the model the session actually uses, which differs from the
21
+ * frontmatter string when the declared model resolved to nothing and the SDK
22
+ * fell back to a default.
23
+ *
24
+ * State is held in the closure of a `createSubagentProgress` instance, one per
25
+ * run. The `noteX` methods only mutate state and return nothing; pushing the
26
+ * rendered text to the host (`onUpdate`) is the caller's job, so the caller
27
+ * decides when a state change is worth a refresh.
28
+ */
29
+
30
+ /**
31
+ * Progress log keeps only the most recent lines (rolling window).
32
+ * 展开后面板最多 6 行内容:4 行日志 + 可能的一行瞬态 thinking + 固定的 metadata 行。
33
+ */
34
+ const MAX_PROGRESS_LINES = 4;
35
+ /** Progress line content (without the `tool:` / `text:` prefix) is capped at 21 chars; longer text is folded to the first/last 9 chars joined by ` … `. */
36
+ const MAX_PROGRESS_CHARS_PER_LINE = 21;
37
+ /**
38
+ * 进度内容会被 pi 按 markdown 渲染,这些标记字符会改变显示效果(代码块、粗体、
39
+ * 链接、标题等),因此在进日志前统一删掉。
40
+ */
41
+ const PROGRESS_MARKDOWN_MARKERS_RE = /[`*_~[\]<>#|]/g;
42
+
43
+ /** 进度面板要展示的运行统计(`runAgent` 的 `result.usage`)。 */
44
+ export interface ProgressUsage {
45
+ turns: number;
46
+ cost: number;
47
+ contextTokens: number;
48
+ }
49
+
50
+ /** 一次子代理运行的进度状态机:改状态用 `noteX`,取文本用 `render`。 */
51
+ export interface SubagentProgress {
52
+ /** 工具调用开始:合并进当前 `tool:` 行。 */
53
+ noteToolCall(rawName: string): void;
54
+ /** 一个完成的 text 块:作为 `text:` 行写入并打断工具合并。 */
55
+ noteTextBlock(content: string): void;
56
+ /** thinking 开始:打开瞬态行(计数从 0 开始)。 */
57
+ thinkingStart(): void;
58
+ /** thinking 增量:累加字符数。 */
59
+ thinkingDelta(length: number): void;
60
+ /** thinking 结束:关闭瞬态行。 */
61
+ thinkingEnd(): void;
62
+ /** 渲染面板:滚动窗口 + 瞬态 thinking 行 + footer。 */
63
+ render(usage: ProgressUsage, model?: string): string;
64
+ }
65
+
66
+ /**
67
+ * Fold over-long progress line content: keep the first/last 9 chars joined by
68
+ * ` … ` (space, ellipsis, space), so the folded line never exceeds
69
+ * `MAX_PROGRESS_CHARS_PER_LINE` chars (9 + 3 + 9 = 21). Shorter text is
70
+ * returned as-is.
71
+ */
72
+ function foldProgressLine(text: string): string {
73
+ if (text.length <= MAX_PROGRESS_CHARS_PER_LINE) {
74
+ return text;
75
+ }
76
+ const keep = Math.floor((MAX_PROGRESS_CHARS_PER_LINE - 3) / 2);
77
+ return `${text.slice(0, keep)} … ${text.slice(-keep)}`;
78
+ }
79
+
80
+ /**
81
+ * 进度行是「单行内容 + markdown 渲染」:内容里的换行会打乱按行滚动的窗口,
82
+ * markdown 标记会改变渲染效果。先删掉标记字符,再把换行/制表符/连续空格折成
83
+ * 单个空格并去掉首尾空白,保证一条日志恒为一行。
84
+ */
85
+ function sanitizeProgressLine(text: string): string {
86
+ return text.replaceAll(PROGRESS_MARKDOWN_MARKERS_RE, "").replaceAll(/\s+/g, " ").trim();
87
+ }
88
+
89
+ /**
90
+ * token 计数的可读格式。进度面板 footer 与 spawn_agent 的输出截断提示共用,
91
+ * 改动会同时影响两处。
92
+ */
93
+ export function formatTokens(count: number): string {
94
+ if (count < 1000) {
95
+ return count.toString();
96
+ }
97
+ if (count < 10_000) {
98
+ return `${(count / 1000).toFixed(1)}k`;
99
+ }
100
+ if (count < 1_000_000) {
101
+ return `${Math.round(count / 1000)}k`;
102
+ }
103
+ return `${(count / 1_000_000).toFixed(1)}M`;
104
+ }
105
+
106
+ function formatUsageStats(usage: ProgressUsage, model?: string): string {
107
+ const parts: string[] = [];
108
+ if (usage.turns) {
109
+ parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
110
+ }
111
+ if (usage.cost) {
112
+ parts.push(`$${usage.cost.toFixed(4)}`);
113
+ }
114
+ if (usage.contextTokens > 0) {
115
+ parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
116
+ }
117
+ if (model) {
118
+ parts.push(model);
119
+ }
120
+ return parts.join(" ");
121
+ }
122
+
123
+ function toolSegment(name: string, count: number): string {
124
+ return count > 1 ? `${name} x ${count}` : name;
125
+ }
126
+
127
+ /**
128
+ * Create the progress state machine for one subagent run.
129
+ *
130
+ * `maxLines` only controls the rolling window of log lines — the transient
131
+ * thinking line and the footer ride outside it. Defaults to
132
+ * `MAX_PROGRESS_LINES`.
133
+ */
134
+ export function createSubagentProgress(options: {
135
+ /** 面板标题(子代理名)。 */
136
+ name: string;
137
+ /** 滚动窗口行数,缺省 4。 */
138
+ maxLines?: number;
139
+ }): SubagentProgress {
140
+ const maxLines = options.maxLines ?? MAX_PROGRESS_LINES;
141
+ let logLines: string[] = [];
142
+ // 工具调用行合并:连续的 tool_execution_start 事件合并在同一 `tool:` 行
143
+ // (如 `tool: read x 2, glob`),相同工具名连续出现时计为 `name x N`,
144
+ // 不同名按调用顺序罗列;只有写入日志行的事件(text 块)打断合并,thinking
145
+ // 不写日志行、也不打断合并。
146
+ let toolLineSegments: string[] = [];
147
+ let toolLine: { name: string; count: number } | undefined;
148
+ // 思考中状态:thinkingStart 打开、thinkingDelta 累计字符数、thinkingEnd
149
+ // 关闭;非 undefined 时 render 在 footer 上方插一行瞬态 thinking 状态。
150
+ let thinkingChars: number | undefined;
151
+
152
+ function pushLogLine(line: string) {
153
+ logLines.push(line);
154
+ if (logLines.length > maxLines) {
155
+ logLines = logLines.slice(-maxLines);
156
+ }
157
+ // 写进日志的新行都会打断工具调用合并,下一批调用另起一行。
158
+ toolLine = undefined;
159
+ }
160
+
161
+ function appendToolLine(rawName: string) {
162
+ const name = sanitizeProgressLine(rawName);
163
+ const firstInBatch = toolLine === undefined;
164
+ if (toolLine === undefined) {
165
+ toolLineSegments = [];
166
+ toolLine = { name, count: 1 };
167
+ } else if (toolLine.name === name) {
168
+ toolLine.count++;
169
+ } else {
170
+ toolLineSegments.push(toolSegment(toolLine.name, toolLine.count));
171
+ toolLine = { name, count: 1 };
172
+ }
173
+ const parts = [...toolLineSegments, toolSegment(toolLine.name, toolLine.count)].join(", ");
174
+ const line = `tool: ${foldProgressLine(parts)}`;
175
+ if (firstInBatch) {
176
+ logLines.push(line);
177
+ if (logLines.length > maxLines) {
178
+ logLines = logLines.slice(-maxLines);
179
+ }
180
+ } else {
181
+ logLines[logLines.length - 1] = line;
182
+ }
183
+ }
184
+
185
+ return {
186
+ noteToolCall: appendToolLine,
187
+ noteTextBlock(content) {
188
+ pushLogLine(`text: ${foldProgressLine(sanitizeProgressLine(content))}`);
189
+ },
190
+ thinkingStart() {
191
+ thinkingChars = 0;
192
+ },
193
+ thinkingDelta(length) {
194
+ thinkingChars = (thinkingChars ?? 0) + length;
195
+ },
196
+ thinkingEnd() {
197
+ thinkingChars = undefined;
198
+ },
199
+ render(usage, model) {
200
+ // 最后一行固定是「子代理名 + 运行中统计」:名字用 code span 标出,进度流里
201
+ // 一眼能看出属于哪个 subagent;usage 与它同行,TUI 始终能看到实时 token 开销。
202
+ // 这行位于滚动窗口之外,因此永远不会被挤掉。
203
+ const usageLine = formatUsageStats(usage, model);
204
+ const name = sanitizeProgressLine(options.name);
205
+ const footer = usageLine ? `\`${name}\` ${usageLine}` : `\`${name}\``;
206
+ const lines = [...logLines];
207
+ if (thinkingChars !== undefined) {
208
+ lines.push(`thinking ( ${thinkingChars} chars )`);
209
+ }
210
+ lines.push(footer);
211
+ return lines.join("\n");
212
+ },
213
+ };
214
+ }
@@ -12,8 +12,8 @@
12
12
  * returns its final output to the parent model. Progress is streamed through
13
13
  * `onUpdate`, the same channel the built-in bash tool uses for live output.
14
14
  * Progress is a rolling log: `tool: <name>` lines for tool calls and
15
- * `text: <content>` lines for completed text blocks, keeping the last
16
- * `MAX_PROGRESS_LINES` lines. Consecutive tool calls are merged into a
15
+ * `text: <content>` lines for completed text blocks, keeping only the last few
16
+ * lines (`MAX_PROGRESS_LINES` in spawn-agent-progress.ts). Consecutive tool calls are merged into a
17
17
  * single `tool:` line (`read x 2, glob`) and over-long line content is
18
18
  * folded to the first/last 9 chars joined by `…`, so a burst of tool calls
19
19
  * or a long text block does not flood the window; only a text block starts
@@ -79,6 +79,7 @@ import {
79
79
  formatAgentList,
80
80
  loadSpawnAgentConfig,
81
81
  } from "./spawn-agent-agents.js";
82
+ import { createSubagentProgress, formatTokens } from "./spawn-agent-progress.js";
82
83
 
83
84
  // ── constants ────────────────────────────────────────────────────────────────
84
85
 
@@ -96,18 +97,6 @@ export const SUBAGENT_DEFAULT_SANDBOX: BwrapConfig = completeBwrapConfig({
96
97
  fs: { mode: "readonly" },
97
98
  network: { mode: "block" },
98
99
  });
99
- /**
100
- * Progress log keeps only the most recent lines (rolling window).
101
- * 展开后面板最多 6 行内容:4 行日志 + 可能的一行瞬态 thinking + 固定的 metadata 行。
102
- */
103
- const MAX_PROGRESS_LINES = 4;
104
- /** Progress line content (without the `tool:` / `text:` prefix) is capped at 21 chars; longer text is folded to the first/last 9 chars joined by ` … `. */
105
- const MAX_PROGRESS_CHARS_PER_LINE = 21;
106
- /**
107
- * 进度内容会被 pi 按 markdown 渲染,这些标记字符会改变显示效果(代码块、粗体、
108
- * 链接、标题等),因此在进日志前统一删掉。
109
- */
110
- const PROGRESS_MARKDOWN_MARKERS_RE = /[`*_~[\]<>#|]/g;
111
100
  /** 错误消息里 stderr 的展示上限。 */
112
101
  const MAX_STDERR_ERROR_BYTES = 4 * 1024;
113
102
  /** 全局默认配置:~/.pi/agent/spawn-agent.json,字段可被 frontmatter 覆盖。 */
@@ -231,59 +220,6 @@ export function formatSubagentError(result: SubagentResult): { reason: string; m
231
220
  return { reason, message: parts.length > 0 ? parts.join("\n") : "(no output)" };
232
221
  }
233
222
 
234
- /**
235
- * Fold over-long progress line content: keep the first/last 9 chars joined by
236
- * ` … ` (space, ellipsis, space), so the folded line never exceeds
237
- * `MAX_PROGRESS_CHARS_PER_LINE` chars (9 + 3 + 9 = 21). Shorter text is
238
- * returned as-is.
239
- */
240
- function foldProgressLine(text: string): string {
241
- if (text.length <= MAX_PROGRESS_CHARS_PER_LINE) {
242
- return text;
243
- }
244
- const keep = Math.floor((MAX_PROGRESS_CHARS_PER_LINE - 3) / 2);
245
- return `${text.slice(0, keep)} … ${text.slice(-keep)}`;
246
- }
247
-
248
- /**
249
- * 进度行是「单行内容 + markdown 渲染」:内容里的换行会打乱按行滚动的窗口,
250
- * markdown 标记会改变渲染效果。先删掉标记字符,再把换行/制表符/连续空格折成
251
- * 单个空格并去掉首尾空白,保证一条日志恒为一行。
252
- */
253
- function sanitizeProgressLine(text: string): string {
254
- return text.replaceAll(PROGRESS_MARKDOWN_MARKERS_RE, "").replaceAll(/\s+/g, " ").trim();
255
- }
256
-
257
- function formatTokens(count: number): string {
258
- if (count < 1000) {
259
- return count.toString();
260
- }
261
- if (count < 10_000) {
262
- return `${(count / 1000).toFixed(1)}k`;
263
- }
264
- if (count < 1_000_000) {
265
- return `${Math.round(count / 1000)}k`;
266
- }
267
- return `${(count / 1_000_000).toFixed(1)}M`;
268
- }
269
-
270
- function formatUsageStats(usage: UsageStats, model?: string): string {
271
- const parts: string[] = [];
272
- if (usage.turns) {
273
- parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
274
- }
275
- if (usage.cost) {
276
- parts.push(`$${usage.cost.toFixed(4)}`);
277
- }
278
- if (usage.contextTokens > 0) {
279
- parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
280
- }
281
- if (model) {
282
- parts.push(model);
283
- }
284
- return parts.join(" ");
285
- }
286
-
287
223
  /** 子 agent 结果的折叠面板 markdown:父 agent 的 prompt 与父 agent 看到的结果。 */
288
224
  function formatPendantMarkdown(task: string, response: string): string {
289
225
  return `# prompt:\n${task.trim()}\n# response\n${response.trim()}`;
@@ -375,10 +311,6 @@ export function resolveModel(
375
311
 
376
312
  type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
377
313
 
378
- function toolSegment(name: string, count: number): string {
379
- return count > 1 ? `${name} x ${count}` : name;
380
- }
381
-
382
314
  /** The subset of AgentSession runAgent relies on (injectable for tests). */
383
315
  export interface SubagentSession {
384
316
  agent: { state: { messages: AgentMessage[] } };
@@ -478,65 +410,11 @@ export async function runAgent(
478
410
  // fallback 到默认模型,回显配置原文会掩盖这次 fallback。
479
411
  result.model = session.model?.id;
480
412
 
481
- let logLines: string[] = [];
482
- // 工具调用行合并:连续的 tool_execution_start 事件合并在同一 `tool:` 行
483
- // (如 `tool: read x 2, glob`),相同工具名连续出现时计为 `name x N`,
484
- // 不同名按调用顺序罗列;只有写入日志行的事件(text 块)打断合并,thinking
485
- // 不写日志行、也不打断合并。
486
- let toolLineSegments: string[] = [];
487
- let toolLine: { name: string; count: number } | undefined;
488
- // 思考中状态:thinking_start 打开、thinking_delta 累计字符数、thinking_end
489
- // 关闭;非 undefined 时 emitUpdate 在 footer 上方插一行瞬态 thinking 状态。
490
- let thinkingChars: number | undefined;
491
-
492
- const pushLogLine = (line: string) => {
493
- logLines.push(line);
494
- if (logLines.length > MAX_PROGRESS_LINES) {
495
- logLines = logLines.slice(-MAX_PROGRESS_LINES);
496
- }
497
- // 写进日志的新行都会打断工具调用合并,下一批调用另起一行。
498
- toolLine = undefined;
499
- };
500
-
501
- const appendToolLine = (rawName: string) => {
502
- const name = sanitizeProgressLine(rawName);
503
- const firstInBatch = toolLine === undefined;
504
- if (toolLine === undefined) {
505
- toolLineSegments = [];
506
- toolLine = { name, count: 1 };
507
- } else if (toolLine.name === name) {
508
- toolLine.count++;
509
- } else {
510
- toolLineSegments.push(toolSegment(toolLine.name, toolLine.count));
511
- toolLine = { name, count: 1 };
512
- }
513
- const parts = [...toolLineSegments, toolSegment(toolLine.name, toolLine.count)].join(", ");
514
- const line = `tool: ${foldProgressLine(parts)}`;
515
- if (firstInBatch) {
516
- logLines.push(line);
517
- if (logLines.length > MAX_PROGRESS_LINES) {
518
- logLines = logLines.slice(-MAX_PROGRESS_LINES);
519
- }
520
- } else {
521
- logLines[logLines.length - 1] = line;
522
- }
523
- emitUpdate();
524
- };
413
+ const progress = createSubagentProgress({ name: result.agent });
525
414
 
526
415
  const emitUpdate = () => {
527
- // 最后一行固定是「子代理名 + 运行中统计」:名字用 code span 标出,进度流里
528
- // 一眼能看出属于哪个 subagent;usage 与它同行,TUI 始终能看到实时 token 开销。
529
- // 这行位于滚动窗口之外,因此永远不会被挤掉。
530
- const usageLine = formatUsageStats(result.usage, result.model);
531
- const name = sanitizeProgressLine(result.agent);
532
- const footer = usageLine ? `\`${name}\` ${usageLine}` : `\`${name}\``;
533
- const lines = [...logLines];
534
- if (thinkingChars !== undefined) {
535
- lines.push(`thinking ( ${thinkingChars} chars )`);
536
- }
537
- lines.push(footer);
538
416
  onUpdate?.({
539
- content: [{ type: "text", text: lines.join("\n") }],
417
+ content: [{ type: "text", text: progress.render(result.usage, result.model) }],
540
418
  details: {},
541
419
  });
542
420
  };
@@ -550,24 +428,24 @@ export async function runAgent(
550
428
  const delta = event.assistantMessageEvent;
551
429
  switch (delta.type) {
552
430
  case "text_end": {
553
- pushLogLine(`text: ${foldProgressLine(sanitizeProgressLine(delta.content))}`);
431
+ progress.noteTextBlock(delta.content);
554
432
  emitUpdate();
555
433
  break;
556
434
  }
557
435
  case "thinking_start": {
558
436
  // thinking 不产生日志行,因此不打断工具行合并:跨轮次的连续工具调用
559
437
  // 仍累加到同一 `tool:` 行。
560
- thinkingChars = 0;
438
+ progress.thinkingStart();
561
439
  emitUpdate();
562
440
  break;
563
441
  }
564
442
  case "thinking_delta": {
565
- thinkingChars = (thinkingChars ?? 0) + delta.delta.length;
443
+ progress.thinkingDelta(delta.delta.length);
566
444
  emitUpdate();
567
445
  break;
568
446
  }
569
447
  case "thinking_end": {
570
- thinkingChars = undefined;
448
+ progress.thinkingEnd();
571
449
  emitUpdate();
572
450
  break;
573
451
  }
@@ -577,7 +455,8 @@ export async function runAgent(
577
455
  break;
578
456
  }
579
457
  case "tool_execution_start": {
580
- appendToolLine(event.toolName);
458
+ progress.noteToolCall(event.toolName);
459
+ emitUpdate();
581
460
  break;
582
461
  }
583
462
  case "message_end": {