pi-cc-extensions 0.9.4 → 0.9.6

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.
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Compact mode:每条含 toolCall 的 assistant message 折叠为一条逐步累加的摘要行
3
3
  * (`Ran for 8s, bash×2, read×2`),edit/write 独立标题行(`✓ write <path> (+25 -0)`),
4
- * 普通工具折叠时不显示独立行;展开(Ctrl+O / fullscreen 点击)在单个工具卡中恢复
5
- * compact-thinking/Pi 原生或专用 renderer。edit/write 复用 mode=on 的 rich diff
4
+ * 普通工具折叠时不显示独立行;展开(Ctrl+O / fullscreen 点击)时助手文本按原生渲染,
5
+ * thinking 与工具卡装进 userMessageBg 面板(卡内恢复 compact-thinking/Pi 原生或专用
6
+ * renderer)。edit/write 复用 mode=on 的 rich diff
6
7
  * 与 `editDiffCollapsedLines` / `writeDiffCollapsedLines` / `expandedPreviewMaxLines`。
7
8
  * Agent/Task 族:调用只进摘要计数,tool 卡始终折叠(避免 pending→完成高度闪动)。
8
9
  * 底部 Agents/Tasks 面板由 pi-subagents/pi-tasks 独立 widget 负责,不经 tool 卡外置。
@@ -35,6 +36,7 @@ import {
35
36
  import { renderRichToolResult } from "./tool/diff/index.ts";
36
37
  import type { WriteExecutionMetadataStore } from "./tool/diff/write-execution.ts";
37
38
  import { isToolCallHovered } from "./mouse/hover.ts";
39
+ import { getToolMouseTui } from "./mouse/scroll.ts";
38
40
  import { insetComponent, renderExpandedToolResult, scheduleAnimation } from "./tool/result.ts";
39
41
  import { paddedBackgroundRow } from "./tool/grouping.ts";
40
42
  import { formatDisplayPath } from "./tool/names.ts";
@@ -50,6 +52,11 @@ import {
50
52
  PROTOTYPE_ORIGINAL_KEY,
51
53
  } from "../utils/patch-keys.ts";
52
54
 
55
+ /** Pi 的 native 组件与扩展可能各持一份 pi-tui,Spacer 不能只靠 instanceof 判。 */
56
+ function isSpacerComponent(value: any): boolean {
57
+ return value instanceof Spacer || value?.constructor?.name === "Spacer";
58
+ }
59
+
53
60
  /** compact 渲染层对 compact-thinking 的只读查询面(不建第二套计时器)。 */
54
61
  export type CompactThinkingQuery = {
55
62
  getMessageThinkingDurationMs(messageTimestamp: number): number | undefined;
@@ -320,14 +327,18 @@ function editWriteExpandedCard(theme: any): any {
320
327
  );
321
328
  }
322
329
 
323
- /** compact 展开卡:外卡片保持 userMessageBg 原色;内部 tool call card
324
- * 背景更深一层且只覆盖内容区(左右内缩、上下限首尾文本行),形成嵌套层次。
325
- * hits:非工具子卡(thinking)的行区间,供展开后点击 hint。 */
330
+ /** compact 展开面板:外卡片保持 userMessageBg;展开的 thinking 另包一层更深的
331
+ * 内卡(自身 Box 背景与外卡同色,不区分会糊在一起),工具卡直接铺在外卡背景上。
332
+ * 每个子卡前面补 1 行分隔(上一条已经是空行时不再补),子卡自带的首尾空行会被裁掉,
333
+ * 避免与外卡/内卡 padding 叠出双空行。
334
+ * hits:子卡的行区间与命中目标(thinking 命中自身,工具命中 tool 组件),
335
+ * 供展开后点击 hint。未开启 toolHits 时不映射工具,点击仍归外层面板。 */
326
336
  function layoutExpandedToolCard(
327
337
  theme: any,
328
338
  children: any[],
329
339
  width: number,
330
340
  paints?: string[][],
341
+ toolHits = false,
331
342
  ): { lines: string[]; hits: Array<{ child: any; start: number; end: number }> } {
332
343
  const slot = "userMessageBg";
333
344
  const toolBgAnsi = darkenBgAnsi(theme, slot);
@@ -335,31 +346,18 @@ function layoutExpandedToolCard(
335
346
  const lines: string[] = [];
336
347
  const hits: Array<{ child: any; start: number; end: number }> = [];
337
348
  const isThinkingPreview = (child: any) => typeof child?.setHintHovered === "function";
338
- lines.push(paddedBackgroundRow(theme, slot, "", width));
339
- // 展开不再显示摘要行:跳过第一个 child 的首行空白,避免白占一行
340
- let skipLeadingBlank = true;
349
+ let lastBlank = false;
350
+ const pushBlank = () => {
351
+ if (lastBlank) return;
352
+ lines.push(paddedBackgroundRow(theme, slot, "", width));
353
+ lastBlank = true;
354
+ };
341
355
  for (let childIndex = 0; childIndex < children.length; childIndex++) {
342
356
  const child = children[childIndex];
343
357
  const painted = paints?.[childIndex];
344
358
  const childLines = Array.isArray(painted) ? painted : child.render(innerWidth);
345
- // 展开的 thinking 与 tool 一样走嵌套深色卡,避免贴在外卡同色底上看不见。
346
- const nest = child.__ccToolCard || (isThinkingPreview(child) && child.expanded === true);
347
- if (!nest) {
348
- let start = 0;
349
- if (skipLeadingBlank) {
350
- while (start < childLines.length && !hasVisibleText(childLines[start])) start++;
351
- skipLeadingBlank = false;
352
- }
353
- const rangeStart = lines.length;
354
- for (let i = start; i < childLines.length; i++) {
355
- lines.push(paddedBackgroundRow(theme, slot, childLines[i], width));
356
- }
357
- if (lines.length > rangeStart) hits.push({ child, start: rangeStart, end: lines.length });
358
- continue;
359
- }
360
- // 工具卡:前导 1 空行 + 首尾文本行之间的内容区(深色);不保留尾随,
361
- // 相邻工具卡之间正好 1 空行,底部由卡片 padding 收尾。
362
- skipLeadingBlank = false;
359
+ // 展开的 thinking 带深色内卡(自带上下 padding,不再补外卡分隔行)。
360
+ const innerCard = isThinkingPreview(child) && child.expanded === true;
363
361
  let first = -1;
364
362
  let last = -1;
365
363
  for (let i = 0; i < childLines.length; i++) {
@@ -371,26 +369,36 @@ function layoutExpandedToolCard(
371
369
  if (first < 0) {
372
370
  for (const line of childLines) {
373
371
  lines.push(paddedBackgroundRow(theme, slot, line, width));
372
+ lastBlank = !hasVisibleText(line);
374
373
  }
375
374
  continue;
376
375
  }
377
- lines.push(paddedBackgroundRow(theme, slot, "", width));
376
+ if (!innerCard) pushBlank();
378
377
  const rangeStart = lines.length;
379
- // 子卡片内部上下各 1 行深色 padding
380
- lines.push(toolCardBgRow(theme, slot, toolBgAnsi, "", width));
381
- for (let i = first; i <= last; i++) {
382
- lines.push(toolCardBgRow(theme, slot, toolBgAnsi, childLines[i], width));
383
- }
384
- lines.push(toolCardBgRow(theme, slot, toolBgAnsi, "", width));
378
+ // 展开的 thinking 走深色内卡(上下各 1 行 padding),工具卡直接铺在外卡上。
379
+ const paintRow = innerCard
380
+ ? (line: string) => lines.push(toolCardBgRow(theme, slot, toolBgAnsi, line, width))
381
+ : (line: string) => lines.push(paddedBackgroundRow(theme, slot, line, width));
382
+ if (innerCard) paintRow("");
383
+ for (let i = first; i <= last; i++) paintRow(childLines[i]);
384
+ if (innerCard) paintRow("");
385
+ // 内卡的下 padding 不能当分隔:后面的子卡要再留 1 行外卡空行。
386
+ lastBlank = false;
387
+ // thinking 命中自身;工具在展开卡内需要能二次展开时才映射回 tool 组件。
385
388
  if (isThinkingPreview(child)) hits.push({ child, start: rangeStart, end: lines.length });
389
+ else if (toolHits && child.__ccTool) {
390
+ hits.push({ child: child.__ccTool, start: rangeStart, end: lines.length });
391
+ }
386
392
  }
387
- lines.push(paddedBackgroundRow(theme, slot, "", width));
393
+ pushBlank();
388
394
  return { lines, hits };
389
395
  }
390
396
 
391
397
  function compactRoundCard(
392
398
  cardItems: Array<{ child?: any; tool?: any }>,
393
399
  toolRender: (tool: any, width: number) => string[],
400
+ /** true 时卡内工具行归 tool 组件,可点击二次展开(整回合展开卡)。 */
401
+ toolHits = false,
394
402
  ): any {
395
403
  const children: any[] = [];
396
404
  for (const item of cardItems) {
@@ -399,6 +407,7 @@ function compactRoundCard(
399
407
  const tool = item.tool;
400
408
  children.push({
401
409
  __ccToolCard: true,
410
+ __ccTool: tool,
402
411
  render: (innerWidth: number) => toolRender(tool, innerWidth),
403
412
  invalidate: () => tool.invalidate?.(),
404
413
  });
@@ -429,12 +438,12 @@ function compactRoundCard(
429
438
  ) {
430
439
  return paint;
431
440
  }
432
- const laid = layoutExpandedToolCard(theme, children, width, paints);
441
+ const laid = layoutExpandedToolCard(theme, children, width, paints, toolHits);
433
442
  paint = {
434
443
  width,
435
444
  theme,
436
445
  paints,
437
- lines: ["", ...laid.lines],
446
+ lines: laid.lines,
438
447
  hits: laid.hits,
439
448
  };
440
449
  return paint;
@@ -445,10 +454,8 @@ function compactRoundCard(
445
454
  return layout(width).lines;
446
455
  },
447
456
  childAtRow(localRow: number, width: number) {
448
- if (localRow < 1) return null;
449
- const row = localRow - 1;
450
457
  for (const hit of layout(width).hits) {
451
- if (row >= hit.start && row < hit.end) return hit.child;
458
+ if (localRow >= hit.start && localRow < hit.end) return hit.child;
452
459
  }
453
460
  return null;
454
461
  },
@@ -531,6 +538,43 @@ export function setHoveredCompactAssistant(component: any): boolean {
531
538
  return true;
532
539
  }
533
540
 
541
+ /**
542
+ * 整回合展开卡内的工具被点击展开时调用:让 compact 的强制折叠放行这一个,
543
+ * 并把同一回合里其他展开的工具收回去(保持单开)。非 round 内工具是空操作。
544
+ */
545
+ let roundToolExpansion: { markUserExpanded(tool: any): void } | undefined;
546
+
547
+ export function markCompactRoundToolExpanded(tool: any): void {
548
+ roundToolExpansion?.markUserExpanded(tool);
549
+ }
550
+
551
+ /** 面板展开前的视口位置:收起时还原,避免面板撑高后收起导致视口错位。 */
552
+ const panelViewportBeforeExpand = new WeakMap<
553
+ object,
554
+ { scrollTop: number; followingEnd: boolean }
555
+ >();
556
+
557
+ function capturePanelViewport(card: any): void {
558
+ if (!card || panelViewportBeforeExpand.has(card)) return;
559
+ const view = getToolMouseTui()?.getPrimaryScrollView?.();
560
+ if (!view || typeof view.scrollTop !== "number" || typeof view.scrollTo !== "function") return;
561
+ panelViewportBeforeExpand.set(card, {
562
+ scrollTop: view.scrollTop,
563
+ followingEnd: view.isFollowingEnd !== false,
564
+ });
565
+ }
566
+
567
+ function restorePanelViewport(card: any): void {
568
+ const snapshot = panelViewportBeforeExpand.get(card);
569
+ if (!snapshot) return;
570
+ panelViewportBeforeExpand.delete(card);
571
+ const view = getToolMouseTui()?.getPrimaryScrollView?.();
572
+ if (!view) return;
573
+ // 展开前就在底部:交回官方 follow;否则回到展开前的滚动偏移。
574
+ if (snapshot.followingEnd && typeof view.scrollToEnd === "function") view.scrollToEnd();
575
+ else if (typeof view.scrollTo === "function") view.scrollTo(snapshot.scrollTop);
576
+ }
577
+
534
578
  function compactEditWriteLine(
535
579
  component: any,
536
580
  width: number,
@@ -968,6 +1012,8 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
968
1012
  let activeRound: CompactRound | undefined;
969
1013
  let roundByComponent = new WeakMap<object, CompactRound>();
970
1014
  const expandedRoundToolIds = new Set<string>();
1015
+ /** 用户显式点开的 round 内工具;强制折叠不压回这些 id。 */
1016
+ const explicitRoundToolIds = new Set<string>();
971
1017
  let uiRef: { requestRender?: (force?: boolean) => void } | undefined;
972
1018
  let roundTickTimer: ReturnType<typeof setInterval> | undefined;
973
1019
 
@@ -1037,6 +1083,23 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1037
1083
  return result;
1038
1084
  };
1039
1085
 
1086
+ const markRoundToolUserExpanded = (tool: any): void => {
1087
+ const id = String(tool?.toolCallId ?? "");
1088
+ if (!id || !expandedRoundToolIds.has(id)) return;
1089
+ for (const other of trackedToolComponents) {
1090
+ const otherId = String(other?.toolCallId ?? "");
1091
+ if (other === tool || !expandedRoundToolIds.has(otherId) || other.expanded !== true) continue;
1092
+ explicitRoundToolIds.delete(otherId);
1093
+ if (typeof other.setExpanded === "function") other.setExpanded(false);
1094
+ else {
1095
+ other.expanded = false;
1096
+ other.updateDisplay?.();
1097
+ }
1098
+ }
1099
+ explicitRoundToolIds.add(id);
1100
+ };
1101
+ roundToolExpansion = { markUserExpanded: markRoundToolUserExpanded };
1102
+
1040
1103
  const roundMessages = (round: CompactRound): any[] => [
1041
1104
  ...round.messages.values(),
1042
1105
  ...round.detachedMessages,
@@ -1061,17 +1124,24 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1061
1124
  const summary = getSummary();
1062
1125
  for (const id of round.suppressedToolIds) expandedRoundToolIds.delete(id);
1063
1126
  round.suppressedToolIds.clear();
1127
+ // 回合收起(或本回合不在展开态)时忘掉用户的单开选择。
1128
+ if (round.anchor.expanded !== true) {
1129
+ for (const id of roundToolCallIds(round)) explicitRoundToolIds.delete(id);
1130
+ }
1064
1131
 
1065
1132
  for (const [component, message] of round.messages) {
1066
1133
  component.lastMessage = message;
1067
1134
  ensureAssistantSetExpanded(component);
1068
1135
  component[ASSISTANT_TOGGLE_ROUND_KEY] = (expanded: boolean) => {
1136
+ const wasExpanded = round.anchor.expanded === true;
1137
+ if (expanded && !wasExpanded) capturePanelViewport(round.anchor);
1069
1138
  for (const member of round.messages.keys()) member.expanded = expanded;
1070
1139
  if (!expanded) {
1071
1140
  for (const id of round.suppressedToolIds) expandedRoundToolIds.delete(id);
1072
1141
  round.suppressedToolIds.clear();
1073
1142
  }
1074
1143
  renderRound(round);
1144
+ if (!expanded && wasExpanded) restorePanelViewport(round.anchor);
1075
1145
  };
1076
1146
  }
1077
1147
 
@@ -1080,14 +1150,39 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1080
1150
  for (const tool of trackedToolComponents) {
1081
1151
  if (typeof tool?.toolCallId === "string") toolsById.set(tool.toolCallId, tool);
1082
1152
  }
1083
- const cardItems: Array<{ child?: any; tool?: any }> = [];
1084
- const placedToolIds = new Set<string>();
1085
- const takeAssistantRun = (kids: any[], cursor: { i: number }) => {
1086
- while (cursor.i < kids.length && kids[cursor.i] instanceof Spacer) {
1087
- cardItems.push({ child: kids[cursor.i++] });
1153
+ // 助手文本按原生渲染、不进面板(面板只留 thinking 与工具卡)。
1154
+ // thinking/工具卡按连续段各包一张面板;先出文本再出面板,
1155
+ // 否则消息内容里的 thinking([thinking, text, toolCall])会盖到文本上面。
1156
+ const anchorChildren: any[] = [];
1157
+ let cardItems: Array<{ child?: any; tool?: any }> = [];
1158
+ const flushPanel = () => {
1159
+ if (cardItems.length === 0) return;
1160
+ if (cardItems.every((item) => !item.tool && isSpacerComponent(item.child))) {
1161
+ for (const item of cardItems) anchorChildren.push(item.child);
1162
+ } else {
1163
+ // 面板上沿留 1 行卡外空行:面板底色与 userMessage 同色,
1164
+ // 贴在一起会看成一个块;卡内顶部再加 1 行 padding 保持上下对称。
1165
+ anchorChildren.push(new Spacer(1));
1166
+ anchorChildren.push(
1167
+ compactRoundCard(
1168
+ cardItems,
1169
+ (tool, innerWidth) => patch.toolOriginalRender.call(tool, innerWidth),
1170
+ // 整回合展开卡:卡内工具行归 tool 组件,可单击二次展开。
1171
+ true,
1172
+ ),
1173
+ );
1088
1174
  }
1089
- if (cursor.i < kids.length) cardItems.push({ child: kids[cursor.i++] });
1175
+ cardItems = [];
1090
1176
  };
1177
+ const takeChild = (child: any, outside: boolean) => {
1178
+ if (!outside) {
1179
+ cardItems.push({ child });
1180
+ return;
1181
+ }
1182
+ flushPanel();
1183
+ anchorChildren.push(child);
1184
+ };
1185
+ const placedToolIds = new Set<string>();
1091
1186
  const placeTool = (id: string, fallbackName?: string) => {
1092
1187
  const tool = toolsById.get(id);
1093
1188
  if (!tool || EDIT_WRITE_TOOLS.has(String(tool.toolName ?? fallbackName ?? ""))) return;
@@ -1095,6 +1190,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1095
1190
  placedToolIds.add(id);
1096
1191
  cardItems.push({ tool });
1097
1192
  };
1193
+ type LocalEntry = { outside: boolean; child?: any; toolId?: string; toolName?: string };
1098
1194
  for (const [component, message] of round.messages) {
1099
1195
  passThroughAssistant(component, message);
1100
1196
  const kids = Array.isArray(component.contentContainer?.children)
@@ -1103,25 +1199,39 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1103
1199
  component.contentContainer?.clear?.();
1104
1200
  const cursor = { i: 0 };
1105
1201
  let inThinkingRun = false;
1202
+ // 本消息产出先收集再发射:文本(含其前导 Spacer)统一排在面板前面。
1203
+ const local: LocalEntry[] = [];
1204
+ const takeRun = (outside: boolean) => {
1205
+ while (cursor.i < kids.length && isSpacerComponent(kids[cursor.i])) {
1206
+ local.push({ outside, child: kids[cursor.i++] });
1207
+ }
1208
+ if (cursor.i < kids.length) local.push({ outside, child: kids[cursor.i++] });
1209
+ };
1106
1210
  for (const item of Array.isArray(message?.content) ? message.content : []) {
1107
1211
  if (item?.type === "thinking") {
1108
1212
  if (!String(item.thinking ?? "").trim()) continue;
1109
1213
  if (!inThinkingRun) {
1110
- takeAssistantRun(kids, cursor);
1214
+ takeRun(false);
1111
1215
  inThinkingRun = true;
1112
1216
  }
1113
1217
  continue;
1114
1218
  }
1115
1219
  inThinkingRun = false;
1116
1220
  if (item?.type === "text" && String(item.text ?? "").trim()) {
1117
- takeAssistantRun(kids, cursor);
1221
+ takeRun(true);
1118
1222
  continue;
1119
1223
  }
1120
1224
  if (item?.type === "toolCall" && typeof item.id === "string") {
1121
- placeTool(item.id, item.name);
1225
+ local.push({ outside: false, toolId: item.id, toolName: item.name });
1122
1226
  }
1123
1227
  }
1124
- while (cursor.i < kids.length) cardItems.push({ child: kids[cursor.i++] });
1228
+ while (cursor.i < kids.length) local.push({ outside: false, child: kids[cursor.i++] });
1229
+ for (const entry of local) if (entry.outside) takeChild(entry.child, true);
1230
+ for (const entry of local) {
1231
+ if (entry.outside) continue;
1232
+ if (entry.toolId) placeTool(entry.toolId, entry.toolName);
1233
+ else takeChild(entry.child, false);
1234
+ }
1125
1235
  }
1126
1236
  for (const message of round.detachedMessages) {
1127
1237
  for (const item of Array.isArray(message?.content) ? message.content : []) {
@@ -1136,12 +1246,14 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1136
1246
  expandedRoundToolIds.add(id);
1137
1247
  if (!placedToolIds.has(id)) placeTool(id);
1138
1248
  }
1139
- // Round 展开只打开外层卡片。普通工具保持折叠,避免长输出递归撑满屏幕。
1249
+ // Round 展开只打开外层卡片。普通工具保持折叠,避免长输出递归撑满屏幕;
1250
+ // 用户单独点开的(explicitRoundToolIds)不压回去。
1140
1251
  for (const tool of trackedToolComponents) {
1141
1252
  if (
1142
1253
  !ids.has(tool.toolCallId) ||
1143
1254
  EDIT_WRITE_TOOLS.has(String(tool.toolName ?? "")) ||
1144
- tool.expanded !== true
1255
+ tool.expanded !== true ||
1256
+ explicitRoundToolIds.has(String(tool.toolCallId ?? ""))
1145
1257
  ) {
1146
1258
  continue;
1147
1259
  }
@@ -1151,11 +1263,8 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1151
1263
  tool.updateDisplay?.();
1152
1264
  }
1153
1265
  }
1154
- round.anchor.contentContainer.addChild(
1155
- compactRoundCard(cardItems, (tool, innerWidth) =>
1156
- patch.toolOriginalRender.call(tool, innerWidth),
1157
- ),
1158
- );
1266
+ flushPanel();
1267
+ for (const child of anchorChildren) round.anchor.contentContainer.addChild(child);
1159
1268
  // 展开卡内工具会显示 error,外层仍挂 abort/length,避免只藏在折叠工具里。
1160
1269
  appendStopStatus(round.anchor, stopStatus);
1161
1270
  return;
@@ -1189,6 +1298,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1189
1298
  activeRound = undefined;
1190
1299
  roundByComponent = new WeakMap();
1191
1300
  expandedRoundToolIds.clear();
1301
+ explicitRoundToolIds.clear();
1192
1302
  deps.query?.setCompactSummaryActive?.(false);
1193
1303
  stopRoundTick();
1194
1304
  };
@@ -1322,12 +1432,11 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1322
1432
  };
1323
1433
 
1324
1434
  patch.toolInstalledUpdateDisplay = function (this: any) {
1325
- if (
1326
- patch.active &&
1327
- config.mode === "compact" &&
1328
- expandedRoundToolIds.has(String(this.toolCallId ?? ""))
1329
- ) {
1330
- this.expanded = false;
1435
+ const id = String(this.toolCallId ?? "");
1436
+ if (patch.active && config.mode === "compact" && expandedRoundToolIds.has(id)) {
1437
+ // 用户点开的保留展开;其余(含全局展开被收回的)继续强制折叠。
1438
+ if (this.expanded !== true) explicitRoundToolIds.delete(id);
1439
+ else if (!explicitRoundToolIds.has(id)) this.expanded = false;
1331
1440
  }
1332
1441
  const result = patch.toolOriginalUpdateDisplay.call(this);
1333
1442
  if (!patch.active) return result;
@@ -1355,6 +1464,9 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1355
1464
  patch.dispose = () => {
1356
1465
  if (!patch.active) return;
1357
1466
  patch.active = false;
1467
+ if (roundToolExpansion?.markUserExpanded === markRoundToolUserExpanded) {
1468
+ roundToolExpansion = undefined;
1469
+ }
1358
1470
  if (assistantPrototype.updateContent === patch.assistantInstalled) {
1359
1471
  assistantPrototype.updateContent = patch.assistantOriginal;
1360
1472
  }
@@ -36,7 +36,13 @@ import { showMoreHintText } from "./tool/show-more-hint.ts";
36
36
  import { countWriteDiffStats } from "./tool/diff/diff-renderer.ts";
37
37
  import { renderRichToolResult, type WriteExecutionMetadataStore } from "./tool/diff/index.ts";
38
38
  import { getMessageDisplayTheme } from "./tool/message-display.ts";
39
- import { fitToolCallSummary, humanizeToolLabel, toolCallSummary } from "./tool/names.ts";
39
+ import { mcpToolTitle } from "./tool/mcp-title.ts";
40
+ import {
41
+ fitToolCallSummary,
42
+ humanizeToolLabel,
43
+ renderToolSummary,
44
+ toolCallSummary,
45
+ } from "./tool/names.ts";
40
46
 
41
47
  // 成功勾:亮绿 truecolor(与 message-display 一致)
42
48
  const BRIGHT_GREEN = "\x1b[38;2;80;220;100m";
@@ -102,25 +108,6 @@ export function shouldRenderRichDiff(
102
108
  return mode === "on" && !isError && (toolName === "edit" || toolName === "write");
103
109
  }
104
110
 
105
- export function isMcpToolDefinition(definition: any, toolName: string): boolean {
106
- const label = typeof definition?.label === "string" ? definition.label.trim() : "";
107
- if (/^MCP(?::|$)/i.test(label)) return true;
108
- if (toolName === "mcp" || /^mcp[_:-]|[_:-]mcp[_:-]/i.test(toolName)) return true;
109
- if (label) return false;
110
- const description = typeof definition?.description === "string" ? definition.description : "";
111
- return /\bModel Context Protocol\b/i.test(description);
112
- }
113
-
114
- export function humanizeMcpToolName(toolName: string): string {
115
- const words = toolName
116
- .replace(/^mcp(?:[_:-]+)+/i, "")
117
- .split(/[_:-]+/)
118
- .filter(Boolean);
119
- return words.length
120
- ? words.map((word) => word[0]!.toUpperCase() + word.slice(1)).join(" ")
121
- : "MCP";
122
- }
123
-
124
111
  /** 排除名单内且自带 renderer 的工具保留原渲染。 */
125
112
  export function preservesOriginalRenderer(
126
113
  extensionDefinition: any,
@@ -225,15 +212,16 @@ function createCcstyleTool(
225
212
  writeExecutionMetadata: WriteExecutionMetadataStore,
226
213
  ): any {
227
214
  const toolName = originalTool.name;
228
- const label = isMcpToolDefinition(originalTool, toolName)
229
- ? humanizeMcpToolName(toolName)
230
- : originalTool.label || toolName;
215
+ const label = originalTool.label || toolName;
216
+ const defaultTitle = label === toolName ? humanizeToolLabel(label) : label;
217
+ // MCP 工具直接用 adapter 暴露的真实工具名,不做人性化
218
+ const title = mcpToolTitle({ toolName, definition: originalTool }) ?? defaultTitle;
231
219
 
232
220
  return {
233
221
  ...originalTool,
234
222
  renderShell: "self",
235
223
  renderCall(args: any, theme: any, context: any) {
236
- if (config.mode !== "on") {
224
+ if (config.mode === "off") {
237
225
  return renderDefault(originalTool, "renderCall", [args, theme, context], String(toolName));
238
226
  }
239
227
 
@@ -252,7 +240,7 @@ function createCcstyleTool(
252
240
  // 不再每次 tick 走 updateDisplay 把整张卡重建一遍。
253
241
  const pendingIconStyled = () => theme.fg(toolIconColor(context), pendingIcon(toolName));
254
242
  const summary = toolCallSummary(toolName, args, {
255
- title: label === toolName ? humanizeToolLabel(label) : label,
243
+ title,
256
244
  variant: "default",
257
245
  cwd: context?.cwd,
258
246
  });
@@ -295,7 +283,7 @@ function createCcstyleTool(
295
283
  cachedWidth = width;
296
284
  cachedIcon = icon;
297
285
  // 路径按最终可用宽度中间截断,避免整行二次截断隐藏文件名。
298
- cachedLine = `${lead}${icon} ${theme.fg("toolTitle", fitToolCallSummary(summary, mainWidth))}${extraStyled}`;
286
+ cachedLine = `${lead}${icon} ${renderToolSummary(summary, mainWidth, theme.fg.bind(theme))}${extraStyled}`;
299
287
  return [truncateToWidth(cachedLine, viewportWidth, "")];
300
288
  },
301
289
  invalidate() {
@@ -305,7 +293,7 @@ function createCcstyleTool(
305
293
  };
306
294
  },
307
295
  renderResult(result: any, options: any, theme: any, context: any) {
308
- if (config.mode !== "on") {
296
+ if (config.mode === "off") {
309
297
  return renderDefault(
310
298
  originalTool,
311
299
  "renderResult",
@@ -428,8 +416,9 @@ function shouldGloballyStyleTool(component: any, patch: GlobalToolRenderPatch):
428
416
  const builtInDefinition = component.builtInToolDefinition;
429
417
  const definition = extensionDefinition ?? builtInDefinition;
430
418
  const toolName = String(component.toolName || definition?.name || "");
419
+ // compact 也复用同一套 ccstyle call/result,避免折叠态工具卡回落到 Pi 原生样式。
431
420
  const useCcstyle =
432
- patch.mode() === "on" &&
421
+ patch.mode() !== "off" &&
433
422
  !DEDICATED_RENDERER_TOOLS.has(toolName) &&
434
423
  !preservesOriginalRenderer(extensionDefinition, toolName, builtInDefinition);
435
424
  component[COMPONENT_TOOL_RENDER_MODE] = useCcstyle;
@@ -597,19 +586,27 @@ function installGlobalToolRendering(
597
586
  return originalRender.call(this, width);
598
587
  }
599
588
  const cache = patch.paintCache;
600
- const hit = cache?.get(this);
589
+ // A running partial tool owns a time-varying spinner. Light animation ticks
590
+ // request a repaint without invalidating the component, so settled-content
591
+ // caching must not intercept those paints.
592
+ const cacheable = !(this.executionStarted && this.isPartial);
593
+ const hit = cacheable ? cache?.get(this) : undefined;
601
594
  if (hit && toolPaintMatches(hit, this, width)) return hit.lines;
602
595
  const lines = originalRender.call(this, width);
603
- cache?.set(this, {
604
- width,
605
- expanded: Boolean(this.expanded),
606
- isPartial: Boolean(this.isPartial),
607
- result: this.result,
608
- args: this.args,
609
- callHover: isToolCallHovered(this.toolCallId),
610
- ioHover: ioHoverOf(this),
611
- lines,
612
- });
596
+ if (cacheable) {
597
+ cache?.set(this, {
598
+ width,
599
+ expanded: Boolean(this.expanded),
600
+ isPartial: Boolean(this.isPartial),
601
+ result: this.result,
602
+ args: this.args,
603
+ callHover: isToolCallHovered(this.toolCallId),
604
+ ioHover: ioHoverOf(this),
605
+ lines,
606
+ });
607
+ } else {
608
+ cache?.delete(this);
609
+ }
613
610
  return lines;
614
611
  };
615
612
  patch.invalidatePaint = function (this: any): void {
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import { ToolExecutionComponent, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import type { CompactThinkingController } from "../feature/compact-thinking.ts";
3
3
  import { installToolGrouping, type ToolGroupingHooks } from "./tool/grouping.ts";
4
4
  import {
@@ -266,11 +266,13 @@ export default function (
266
266
  // ---- 对外导出:入口/测试实际消费的符号 ----
267
267
  export { getCompactThinkingConfig } from "../config/config.ts";
268
268
  export {
269
- humanizeMcpToolName,
270
- isMcpToolDefinition,
271
269
  preservesOriginalRenderer,
272
270
  shouldRenderRichDiff,
273
271
  } from "./default-mode.ts";
272
+ export {
273
+ isMcpToolDefinition,
274
+ mcpToolTitle,
275
+ } from "./tool/mcp-title.ts";
274
276
  export {
275
277
  ExpandedToolIoView,
276
278
  ExpandedToolResultText,