pi-cc-extensions 1.0.9 → 1.0.10

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.
@@ -0,0 +1,556 @@
1
+ import {
2
+ CustomMessageComponent,
3
+ ExtensionRunner,
4
+ type MessageRenderer,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ Box,
8
+ truncateToWidth,
9
+ visibleWidth,
10
+ wrapTextWithAnsi,
11
+ type Component,
12
+ } from "@earendil-works/pi-tui";
13
+ import { config } from "../../config/config.ts";
14
+ import { formatTokenCountWithUnit } from "../../utils/format.ts";
15
+ import { walkComponentTree } from "../../utils/component-tree.ts";
16
+ import {
17
+ patchRegistry,
18
+ PROTOTYPE_ORIGINAL_KEY,
19
+ SUBAGENT_NOTIFICATION_RENDERER_PATCH as RENDERER_PATCH_KEY,
20
+ } from "../../utils/patch-keys.ts";
21
+ import { sanitizeToolResultText } from "../../utils/tool-result-sanitize.ts";
22
+ import type { GroupDisplayItem } from "./grouping.ts";
23
+ import { showMoreHintText } from "./names.ts";
24
+ import { formatToolDuration, toolViewportWidth } from "./result.ts";
25
+ import { TOOL_CARD_BG_SLOT } from "./tool-state.ts";
26
+
27
+ /**
28
+ * pi-subagents 的 `subagent-notification`:只换展示,不动消息本身。
29
+ * agent 一停下(完成、到轮数上限收尾、被停、中止或出错)就发这条通知,所以标题
30
+ * 不说「完成」,结局写在行里。单独出现时渲染为可展开的一行;挨着工具时由分组
31
+ * 模块经显示适配器并入工具组。一条通知可能带多个 agent(`others`),每个 agent 一行。
32
+ */
33
+
34
+ export const SUBAGENT_NOTIFICATION_TYPE = "subagent-notification";
35
+ export const AGENT_TOOL_NAME = "Agent";
36
+ const FINISHED_LABEL = "Subagent Finished";
37
+
38
+ type Fg = (color: string, text: string) => string;
39
+
40
+ /** 通知里一个 agent 的结束记录。payload 里每个字段都可能缺失,缺的就不显示。 */
41
+ type FinishedSubagent = {
42
+ id: string;
43
+ description: string;
44
+ status: string;
45
+ failed: boolean;
46
+ stats: string[];
47
+ /** pi-subagents 存下的结果预览,本身可能已被截断。 */
48
+ resultPreview: string;
49
+ error: string;
50
+ /** 保存时的 transcript 路径,文件可能已不在。 */
51
+ outputFile: string;
52
+ };
53
+
54
+ function isRecord(value: unknown): value is Record<string, unknown> {
55
+ return typeof value === "object" && value !== null && !Array.isArray(value);
56
+ }
57
+
58
+ function text(value: unknown): string {
59
+ return typeof value === "string" ? sanitizeToolResultText(value).trim() : "";
60
+ }
61
+
62
+ /**
63
+ * pi-subagents 拿不到的统计也照样写 0(turnCount 恒为 `?? 0`,未完成的 durationMs、
64
+ * 没有定价的 totalCost 都是 0),payload 里分不出「测得 0」和「没测」,0 一律当缺失。
65
+ */
66
+ function measured(value: unknown): number | undefined {
67
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
68
+ }
69
+
70
+ function plural(count: number, unit: string): string {
71
+ return `${count} ${unit}${count === 1 ? "" : "s"}`;
72
+ }
73
+
74
+ /** 估算花费:带 `~`,一美元以下最多四位小数。 */
75
+ function formatCost(cost: number): string {
76
+ if (cost < 0.0001) return "<$0.0001";
77
+ if (cost >= 1) return `~$${cost.toFixed(2)}`;
78
+ const rounded = Number(cost.toFixed(4));
79
+ const decimals = (String(rounded).split(".")[1] ?? "").length;
80
+ return `~$${rounded.toFixed(Math.max(2, decimals))}`;
81
+ }
82
+
83
+ /** 统计口径:0 一律当缺失(pi-subagents 对未测字段写 0,无法区分)。
84
+ * 字段名兼容两套:通知 payload 用 totalTokens/totalCost;工具结果 details 用 tokens。
85
+ */
86
+ export function measuredStats(details: Record<string, unknown>): string[] {
87
+ const stats: string[] = [];
88
+ const turns = measured(details.turnCount);
89
+ if (turns) stats.push(plural(turns, "turn"));
90
+ const toolUses = measured(details.toolUses);
91
+ if (toolUses) stats.push(plural(toolUses, "tool use"));
92
+ const tokens = measured(details.totalTokens) ?? measured(details.tokens);
93
+ if (tokens) stats.push(formatTokenCountWithUnit(tokens));
94
+ const cost = measured(details.totalCost);
95
+ if (cost) stats.push(formatCost(cost));
96
+ const duration = measured(details.durationMs);
97
+ if (duration) stats.push(formatToolDuration(duration));
98
+ return stats;
99
+ }
100
+
101
+ /** 状态分类:stopped 是用户主动停,不算失败;error/aborted 才是真失败。 */
102
+ export function isFailedSubagentStatus(status: string): boolean {
103
+ return status === "error" || status === "aborted";
104
+ }
105
+
106
+ /** stopped 用中性图标;其它非 completed 的非失败状态用 ✓。 */
107
+ export function subagentStatusIcon(status: string, failed: boolean): string {
108
+ if (failed) return "✗";
109
+ if (status === "stopped") return "⚠";
110
+ return "✓";
111
+ }
112
+
113
+ /** 结果行/统计行里 status 的展示叫法,与通知行 outcomeText 同一口径。 */
114
+ export function agentStatusLabel(status: string): string {
115
+ return status === "steered" ? "completed (steered)" : status;
116
+ }
117
+
118
+ function toFinishedSubagent(details: Record<string, unknown>): FinishedSubagent {
119
+ const status = text(details.status);
120
+ const error = text(details.error);
121
+ return {
122
+ id: text(details.id),
123
+ description: text(details.description),
124
+ status,
125
+ // stopped 是用户主动停(不是失败),error/aborted 才用 ✗。
126
+ // 没有 status 时只能看有没有错误正文;有 status 就以它为准。
127
+ failed: status ? isFailedSubagentStatus(status) : Boolean(error),
128
+ stats: measuredStats(details),
129
+ resultPreview: text(details.resultPreview),
130
+ error,
131
+ outputFile: text(details.outputFile),
132
+ };
133
+ }
134
+
135
+ /** 通知里的全部 agent:首个 + `others`。details 缺失时仍是一行没有附加信息的结束行。 */
136
+ function finishedSubagents(details: unknown): FinishedSubagent[] {
137
+ const first = isRecord(details) ? details : {};
138
+ const others = Array.isArray(first.others) ? first.others.filter(isRecord) : [];
139
+ return [first, ...others].map(toFinishedSubagent);
140
+ }
141
+
142
+ /** 通知消息不会再变:按消息对象解析一次,分组每帧拿到的是同一批记录。 */
143
+ const subagentsByMessage = new WeakMap<object, FinishedSubagent[]>();
144
+
145
+ function messageSubagents(message: any): FinishedSubagent[] {
146
+ if (!message || typeof message !== "object") return finishedSubagents(undefined);
147
+ let subagents = subagentsByMessage.get(message);
148
+ if (!subagents) {
149
+ subagents = finishedSubagents(message.details);
150
+ subagentsByMessage.set(message, subagents);
151
+ }
152
+ return subagents;
153
+ }
154
+
155
+ /**
156
+ * 结局的叫法,与 pi-subagents 自己的渲染器一致。steered 是到了轮数软上限、被要求收尾后
157
+ * 按时结束(不是 steer_subagent),算完成,但输出可能不全。
158
+ */
159
+ function outcomeText(status: string): string {
160
+ return status === "steered" ? "completed (steered)" : status;
161
+ }
162
+
163
+ function subagentTitle(subagent: FinishedSubagent, fg: Fg): string {
164
+ return subagent.id
165
+ ? `${fg("toolTitle", FINISHED_LABEL)} ${fg("muted", subagent.id)}`
166
+ : fg("toolTitle", FINISHED_LABEL);
167
+ }
168
+
169
+ /**
170
+ * 折叠行正文(不含状态图标):标题 + agent id + 结局 + 可用统计。单独行与分组行共用。
171
+ * 普通完成不写结局(✓ 已经说明);没有 status 时也不写。
172
+ */
173
+ function subagentSummary(subagent: FinishedSubagent, width: number, fg: Fg): string {
174
+ const outcome =
175
+ subagent.status && subagent.status !== "completed"
176
+ ? ` ${fg("dim", "·")} ${fg(subagent.failed ? "error" : "warning", outcomeText(subagent.status))}`
177
+ : "";
178
+ const stats = subagent.stats.map((stat) => ` ${fg("dim", "·")} ${fg("dim", stat)}`).join("");
179
+ return truncateToWidth(`${subagentTitle(subagent, fg)}${outcome}${stats}`, width, "…");
180
+ }
181
+
182
+ const DETAIL_INDENT = " ";
183
+ /** 段落正文的左栏;最后一段改用 DETAIL_INDENT(与工具展开卡的 └ 后缩进一致)。 */
184
+ const SECTION_RAIL = "│ ";
185
+
186
+ /** 着色之外还需要粗体:工具展开卡的小标题是 accent + bold。 */
187
+ type Styler = {
188
+ fg: Fg;
189
+ bold(text: string): string;
190
+ };
191
+
192
+ function stylerFor(theme: any): Styler {
193
+ return {
194
+ fg: themeFg(theme),
195
+ bold: (text) => (typeof theme?.bold === "function" ? theme.bold(text) : text),
196
+ };
197
+ }
198
+
199
+ /** 一段正文:保留原有换行,超宽折行;边框由 detailRows 加。 */
200
+ function sectionBody(value: string, width: number, color: string, styler: Styler): string[] {
201
+ return value
202
+ .split("\n")
203
+ .flatMap((line) => (line ? wrapTextWithAnsi(line, Math.max(1, width)) : [""]))
204
+ .map((line) => (line ? styler.fg(color, line) : ""));
205
+ }
206
+
207
+ /** 展开卡里的一个段落:标题走 accent 粗体,正文自己带颜色。 */
208
+ type DetailSection = { heading: string; body: string[] };
209
+
210
+ /**
211
+ * 展开详情里的段落:任务说明、状态、结果预览、错误、transcript。payload 里缺的段落不出现。
212
+ * 正文宽度不含边框:调用方已扣掉 `│ ` 占的两列。
213
+ */
214
+ function subagentSections(
215
+ subagent: FinishedSubagent,
216
+ width: number,
217
+ styler: Styler,
218
+ ): DetailSection[] {
219
+ const sections: DetailSection[] = [];
220
+ // 说明自己成段而不是标题副行:副行没有边框,会把展开卡的 `│` 辅助线截断。
221
+ if (subagent.description) {
222
+ sections.push({
223
+ heading: "Description",
224
+ body: sectionBody(subagent.description, width, "text", styler),
225
+ });
226
+ }
227
+ if (subagent.status) {
228
+ const color = subagent.failed ? "error" : subagent.status === "stopped" ? "warning" : "muted";
229
+ const body = sectionBody(outcomeText(subagent.status), width, color, styler);
230
+ if (subagent.stats.length) {
231
+ body.push(...sectionBody(subagent.stats.join(" · "), width, "dim", styler));
232
+ }
233
+ sections.push({ heading: "Status", body });
234
+ }
235
+ if (subagent.resultPreview) {
236
+ sections.push({
237
+ heading: "Saved result preview",
238
+ body: sectionBody(subagent.resultPreview, width, "toolOutput", styler),
239
+ });
240
+ }
241
+ if (subagent.error) {
242
+ sections.push({ heading: "Error", body: sectionBody(subagent.error, width, "error", styler) });
243
+ }
244
+ if (subagent.outputFile) {
245
+ sections.push({
246
+ heading: "Transcript",
247
+ body: sectionBody(subagent.outputFile, width, "dim", styler),
248
+ });
249
+ }
250
+ return sections;
251
+ }
252
+
253
+ /**
254
+ * 展开详情(标题行之后的部分):用工具展开卡同一套边框——`├`/`└` 角标 + accent 粗体小标题,
255
+ * 正文跟在 `│ ` 后,最后一段改用两格缩进,段落之间留一条 `│`。
256
+ * 只展示保存下来的内容:不调用工具、不读 transcript 文件。
257
+ */
258
+ function subagentDetailRows(subagent: FinishedSubagent, width: number, styler: Styler): string[] {
259
+ // width 是这一行可用的总列数(含段落边框);正文再扣掉 `│ ` 两列。
260
+ const room = Math.max(1, width - SECTION_RAIL.length);
261
+ const rows: string[] = [];
262
+ const sections = subagentSections(subagent, room, styler);
263
+ sections.forEach((section, index) => {
264
+ const last = index === sections.length - 1;
265
+ if (index) rows.push(styler.fg("dim", "│"));
266
+ const bodyPrefix = last ? DETAIL_INDENT : SECTION_RAIL;
267
+ const heading = wrapTextWithAnsi(styler.fg("accent", styler.bold(section.heading)), room);
268
+ heading.forEach((line, lineIndex) =>
269
+ rows.push(`${styler.fg("dim", lineIndex ? bodyPrefix : `${last ? "└" : "├"} `)}${line}`),
270
+ );
271
+ for (const line of section.body) {
272
+ rows.push(
273
+ line ? `${styler.fg("dim", bodyPrefix)}${line}` : styler.fg("dim", last ? "" : "│"),
274
+ );
275
+ }
276
+ });
277
+ return rows;
278
+ }
279
+
280
+ /** 每行折进 width,空行原样保留;已折过的行不变。Box 不折也不截超宽行,分组面板只截不折,先在这里折好。 */
281
+ function wrapRows(rows: string[], width: number): string[] {
282
+ return rows.flatMap((row) => (row ? wrapTextWithAnsi(row, Math.max(1, width)) : [""]));
283
+ }
284
+
285
+ function statusIcon(subagent: FinishedSubagent, fg: Fg): string {
286
+ const icon = subagentStatusIcon(subagent.status, subagent.failed);
287
+ if (subagent.failed) return fg("error", icon);
288
+ if (icon === "⚠") return fg("warning", icon);
289
+ return fg("success", icon);
290
+ }
291
+
292
+ /**
293
+ * 鼠标层单独展开/收起这一行的状态,按消息记:CustomMessageComponent 每次重建(invalidate、
294
+ * 换主题)都会重新调用渲染器,状态不能只放在视图实例上。记下当时的全局状态(Ctrl+O 等经
295
+ * CustomMessageComponent.setExpanded 传进来的 options.expanded),全局状态一变就作废,
296
+ * 以最后一次操作为准。
297
+ */
298
+ const localExpansion = new WeakMap<object, { base: boolean; expanded: boolean }>();
299
+
300
+ function resolveExpanded(message: object, base: boolean): boolean {
301
+ const local = localExpansion.get(message);
302
+ if (local?.base === base) return local.expanded;
303
+ localExpansion.delete(message);
304
+ return base;
305
+ }
306
+
307
+ class FinishedSubagentView implements Component {
308
+ private readonly message: object;
309
+ private readonly subagents: FinishedSubagent[];
310
+ private readonly theme: any;
311
+ private readonly fg: Fg;
312
+ private readonly pad: number;
313
+ private readonly base: boolean;
314
+ private _expanded: boolean;
315
+ /** fullscreen 悬停在展开提示上(鼠标层经 setHintHovered 设置),提示文字提亮。 */
316
+ private hintHovered = false;
317
+
318
+ constructor(message: object, theme: any, pad: number, base: boolean) {
319
+ this.message = message;
320
+ this.subagents = messageSubagents(message);
321
+ this.theme = theme;
322
+ this.fg = (color, value) => theme.fg(color, value);
323
+ this.pad = pad;
324
+ this.base = base;
325
+ this._expanded = resolveExpanded(message, base);
326
+ }
327
+
328
+ get expanded(): boolean {
329
+ return this._expanded;
330
+ }
331
+
332
+ /** 鼠标层的单击展开/收起(与消息卡同一条路径)。 */
333
+ setExpanded(expanded: boolean): void {
334
+ this._expanded = expanded;
335
+ localExpansion.set(this.message, { base: this.base, expanded });
336
+ }
337
+
338
+ render(width: number): string[] {
339
+ return this._expanded ? this.renderExpanded(width) : this.renderCollapsed(width);
340
+ }
341
+
342
+ private renderCollapsed(width: number): string[] {
343
+ const fg = this.fg;
344
+ const rowWidth = toolViewportWidth(width);
345
+ const indent = " ".repeat(this.pad);
346
+ const hint = ` ${fg("dim", "·")} ${fg(this.hintHovered ? "text" : "dim", showMoreHintText())}`;
347
+ const last = this.subagents.length - 1;
348
+ return this.subagents.map((subagent, index) => {
349
+ const prefix = `${indent}${statusIcon(subagent, fg)} `;
350
+ const tail = index === last ? hint : "";
351
+ const room = Math.max(0, rowWidth - visibleWidth(prefix) - visibleWidth(tail));
352
+ // 窄到放不下提示时连提示一起截(与分组卡一致),Ctrl+O 仍能展开。
353
+ return truncateToWidth(
354
+ `${prefix}${subagentSummary(subagent, room, fg)}${tail}`,
355
+ rowWidth,
356
+ "",
357
+ );
358
+ });
359
+ }
360
+
361
+ /** 展开卡:与工具展开卡同一套底色,有一个 agent 失败就整卡用失败底色。 */
362
+ private renderExpanded(width: number): string[] {
363
+ const fg = this.fg;
364
+ const styler = stylerFor(this.theme);
365
+ const failed = this.subagents.some((subagent) => subagent.failed);
366
+ const slot = TOOL_CARD_BG_SLOT[failed ? "error" : "success"];
367
+ const box = new Box(this.pad, 1, (line) => this.theme.bg(slot, line));
368
+ const subagents = this.subagents;
369
+ box.addChild({
370
+ // 标题行自己折;详情已按内宽折过,不能再折,否则 `│` 会掉到下一行。
371
+ render: (inner: number) => {
372
+ const rows: string[] = [];
373
+ subagents.forEach((subagent, index) => {
374
+ if (index) rows.push("");
375
+ rows.push(
376
+ ...wrapRows([`${statusIcon(subagent, fg)} ${subagentTitle(subagent, fg)}`], inner),
377
+ );
378
+ rows.push(...subagentDetailRows(subagent, inner - SECTION_RAIL.length, styler));
379
+ });
380
+ return rows;
381
+ },
382
+ invalidate() {},
383
+ });
384
+ return box.render(width);
385
+ }
386
+
387
+ setHintHovered(hovered: boolean): void {
388
+ this.hintHovered = hovered;
389
+ }
390
+
391
+ invalidate(): void {}
392
+ }
393
+
394
+ /**
395
+ * 单独出现的 subagent 通知行。鼠标层把它当消息卡:悬停提示提亮、单击提示展开、
396
+ * 单击展开卡收起,都经 expanded / setExpanded / setHintHovered 这几个钩子。
397
+ */
398
+ export function isSubagentNotificationView(value: unknown): boolean {
399
+ return value instanceof FinishedSubagentView;
400
+ }
401
+
402
+ /** `subagent-notification` 的消息渲染器:单独出现时的结束行。 */
403
+ export const renderSubagentNotification: MessageRenderer = (message, options, theme) =>
404
+ new FinishedSubagentView(message, theme, Math.max(0, options.outputPad), options.expanded);
405
+
406
+ /** 挂在 transcript 上的 subagent 通知(Pi 的 CustomMessageComponent)。 */
407
+ export function isSubagentNotificationComponent(value: unknown): boolean {
408
+ return (
409
+ value instanceof CustomMessageComponent &&
410
+ (value as any).message?.customType === SUBAGENT_NOTIFICATION_TYPE
411
+ );
412
+ }
413
+
414
+ type RendererLookup = (this: unknown, customType: string) => MessageRenderer | undefined;
415
+
416
+ type RendererLookupPatch = {
417
+ active: boolean;
418
+ /** 按当前 mode 画一条通知;upstream 是原查找结果。 */
419
+ render(
420
+ upstream: MessageRenderer | undefined,
421
+ ...args: Parameters<MessageRenderer>
422
+ ): Component | undefined;
423
+ dispose(): void;
424
+ };
425
+
426
+ /**
427
+ * CustomMessageComponent 存下查找结果,每次 rebuild 都重新调它:mode 在调用时才看,
428
+ * 也不绑死发出它的补丁——/reload 后转给当前补丁(新模块的配置与渲染),补丁撤掉后回到原生。
429
+ */
430
+ function modeAwareRenderer(upstream: MessageRenderer | undefined): MessageRenderer {
431
+ const renderer: MessageRenderer = (message, options, theme) => {
432
+ const patch = patchRegistry.get<RendererLookupPatch>(RENDERER_PATCH_KEY);
433
+ return patch?.active
434
+ ? patch.render(upstream, message, options, theme)
435
+ : upstream?.(message, options, theme);
436
+ };
437
+ (renderer as any)[RENDERER_PATCH_KEY] = true;
438
+ return renderer;
439
+ }
440
+
441
+ /**
442
+ * Pi 每种 customType 只用最先加载的扩展注册的渲染器(ExtensionRunner.getMessageRenderer):
443
+ * 注册争不过先加载的 pi-subagents,先加载时又会遮住它,off 就还原不了它的原生视图。
444
+ * 所以不注册,改在查找处给 subagent-notification 换上按 mode 分派的渲染器:on/compact
445
+ * 画本插件的结束行,off 交回原查找结果(pi-subagents 的渲染器;没有就返回 undefined,
446
+ * 由 Pi 画通用卡片)。其他 customType 原样返回。返回 dispose(/reload 链安全)。
447
+ */
448
+ export function installSubagentNotificationRenderer(): () => void {
449
+ patchRegistry.get<RendererLookupPatch>(RENDERER_PATCH_KEY)?.dispose();
450
+ const prototype = ExtensionRunner.prototype as any;
451
+ let original: RendererLookup = prototype.getMessageRenderer;
452
+ // 上游 pi 如果改掉了这个方法名(版本升级后可能移除),返回 undefined,让 Pi
453
+ // 画通用自定义卡片兜底,而不是崩在 `undefined.call`。
454
+ if (typeof original !== "function") return () => {};
455
+ // 最外层还是失效的旧补丁(所有权槽已丢)就拆开,不在它外面再套一层;被别人包着的不动。
456
+ while ((original as any)[RENDERER_PATCH_KEY] && (original as any)[PROTOTYPE_ORIGINAL_KEY]) {
457
+ original = (original as any)[PROTOTYPE_ORIGINAL_KEY];
458
+ if (typeof original !== "function") return () => {};
459
+ }
460
+ const installed: RendererLookup = function (this: unknown, customType) {
461
+ const upstream = original.call(this, customType);
462
+ if (!patch.active || customType !== SUBAGENT_NOTIFICATION_TYPE) return upstream;
463
+ // 内层还有一个本插件补丁时它已经分派过了。
464
+ return (upstream as any)?.[RENDERER_PATCH_KEY] ? upstream : modeAwareRenderer(upstream);
465
+ };
466
+ (installed as any)[RENDERER_PATCH_KEY] = true;
467
+ (installed as any)[PROTOTYPE_ORIGINAL_KEY] = original;
468
+ const patch: RendererLookupPatch = {
469
+ active: true,
470
+ render: (upstream, message, options, theme) =>
471
+ config.mode === "off"
472
+ ? upstream?.(message, options, theme)
473
+ : renderSubagentNotification(message, options, theme),
474
+ dispose() {
475
+ if (!patch.active) return;
476
+ patch.active = false;
477
+ // 被别人包在里面时撤不下来:留着失效直通,不动对方的包装。
478
+ if (prototype.getMessageRenderer === installed) {
479
+ prototype.getMessageRenderer = original;
480
+ }
481
+ patchRegistry.dispose(RENDERER_PATCH_KEY, patch);
482
+ },
483
+ };
484
+ prototype.getMessageRenderer = installed;
485
+ patchRegistry.install(RENDERER_PATCH_KEY, patch);
486
+ return patch.dispose;
487
+ }
488
+
489
+ /**
490
+ * 让已挂载的通知按当前 mode 重建(/ccstyle on|compact|off 切换后、session_start 之后)。
491
+ * /reload 与 /resume 先重建 transcript 再发 session_start,那批卡存的是补丁装上前的
492
+ * 原查找结果:补丁在位时给它们换上按 mode 分派的渲染器,原结果留作上游。
493
+ */
494
+ export function refreshSubagentNotifications(root: unknown): void {
495
+ const active = patchRegistry.get<RendererLookupPatch>(RENDERER_PATCH_KEY)?.active === true;
496
+ walkComponentTree(root, (value) => {
497
+ if (!isSubagentNotificationComponent(value)) return;
498
+ if (active && !value.customRenderer?.[RENDERER_PATCH_KEY]) {
499
+ value.customRenderer = modeAwareRenderer(value.customRenderer);
500
+ }
501
+ // CustomMessageComponent.invalidate 会 rebuild,重新调用渲染器。
502
+ value.invalidate?.();
503
+ return false;
504
+ });
505
+ }
506
+
507
+ function themeFg(theme: any): Fg {
508
+ return (color, value) => theme?.fg?.(color, value) ?? value;
509
+ }
510
+
511
+ type ExpandedRowsCache = { width: number; theme: unknown; rows: string[] };
512
+
513
+ function subagentItem(subagent: FinishedSubagent): GroupDisplayItem {
514
+ let cache: ExpandedRowsCache | undefined;
515
+ return {
516
+ name: SUBAGENT_NOTIFICATION_TYPE,
517
+ label: FINISHED_LABEL,
518
+ status: subagent.failed ? "error" : "success",
519
+ summary: (width, theme) => subagentSummary(subagent, width, themeFg(theme)),
520
+ expandedRows(width, theme) {
521
+ if (cache?.width === width && cache.theme === theme) return cache.rows;
522
+ const styler = stylerFor(theme);
523
+ // 首行接在 `├ ✓ ` 之后占 4 列;续行前缀(`│ `)由分组再加 2 列,详情自己又带 2 列边框。
524
+ // 分组只截不折,所以这里先把行折好。末尾空行把详情和下一项隔开。
525
+ const room = Math.max(1, width - 4);
526
+ const rows = [
527
+ ...wrapRows([subagentTitle(subagent, styler.fg)], width - 4),
528
+ ...subagentDetailRows(subagent, room, styler),
529
+ "",
530
+ ];
531
+ cache = { width, theme, rows };
532
+ return rows;
533
+ },
534
+ // 主题是稳定代理,换主题后身份不变:靠分组 invalidate 丢掉带旧颜色的行。
535
+ invalidate() {
536
+ cache = undefined;
537
+ },
538
+ };
539
+ }
540
+
541
+ const itemsBySubagents = new WeakMap<FinishedSubagent[], GroupDisplayItem[]>();
542
+
543
+ /**
544
+ * 显示适配器:分组里每个 agent 是一项(计入组头计数,但不是工具执行,不重复统计用量)。
545
+ * 不是 subagent 通知时返回 undefined。
546
+ */
547
+ export function subagentNotificationItems(component: unknown): GroupDisplayItem[] | undefined {
548
+ if (!isSubagentNotificationComponent(component)) return undefined;
549
+ const subagents = messageSubagents((component as any).message);
550
+ let items = itemsBySubagents.get(subagents);
551
+ if (!items) {
552
+ items = subagents.map(subagentItem);
553
+ itemsBySubagents.set(subagents, items);
554
+ }
555
+ return items;
556
+ }
@@ -30,6 +30,17 @@ export function oneLine(value: unknown, max = 96): string {
30
30
  return text.length > max ? `${text.slice(0, max - 1)}…` : text;
31
31
  }
32
32
 
33
+ /** token 计数带单位:保留一位小数,与通知 fixture 口径一致。 */
34
+ export function formatTokenCountWithUnit(value: number): string {
35
+ const count =
36
+ value < 1000
37
+ ? `${value}`
38
+ : value < 1_000_000
39
+ ? `${(value / 1000).toFixed(1)}k`
40
+ : `${(value / 1_000_000).toFixed(1)}M`;
41
+ return `${count} token`;
42
+ }
43
+
33
44
  /** 把一行文本补齐到固定可见宽度,超宽时截断并追加省略号。 */
34
45
  export function padLine(text: string, width: number): string {
35
46
  const truncated = truncateToWidth(text, width, "…");
@@ -34,6 +34,10 @@ export const WRITE_OWNERSHIP_SLOT = Symbol.for("pi.ccstyle.write-ownership");
34
34
 
35
35
  // ── 消息组件补丁 ──
36
36
  export const MESSAGE_DISPLAY_PATCH = Symbol.for("pi.ccstyle.message-display-patch");
37
+ // 槽位存当前补丁;同一 symbol 也标在补丁装上的查找方法与它发出的渲染器上。
38
+ export const SUBAGENT_NOTIFICATION_RENDERER_PATCH = Symbol.for(
39
+ "pi.ccstyle.subagent-notification-renderer-patch",
40
+ );
37
41
 
38
42
  // ── 工具分组 ──
39
43
  export const TOOL_GROUPING_PATCH_KEY = Symbol.for("pi.ccstyle.tool-grouping-patch");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-extensions",
3
- "version": "1.0.9",
3
+ "version": "1.0.10",
4
4
  "license": "MIT",
5
5
  "description": "Claude Code-style TUI output with some personal touches and handy utilities.",
6
6
  "type": "module",