@herbertgao/pi-extensions 2026.10.4 → 2026.10.5

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.
Files changed (30) hide show
  1. package/README.md +1 -1
  2. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +12 -7
  3. package/node_modules/@herbertgao/pi-cc-extensions/README.md +12 -7
  4. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +67 -16
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +240 -48
  6. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/footer-currency.ts +29 -0
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/footer.ts +92 -59
  8. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/git-stats.ts +162 -0
  9. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/token-rate.ts +192 -0
  10. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/working-message.ts +51 -65
  11. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +113 -25
  12. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/default-mode.ts +160 -24
  13. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/index.ts +30 -1
  14. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +26 -24
  15. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/sticky-prompt.ts +402 -0
  16. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/ask-user-question.ts +173 -0
  17. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/codemode.ts +4 -8
  18. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-edit-render.ts +64 -11
  19. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-presentation.ts +41 -0
  20. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-renderer.ts +5 -1
  21. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-write-render.ts +35 -9
  22. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +101 -31
  23. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/names.ts +3 -3
  24. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/result.ts +106 -15
  25. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/format.ts +9 -0
  26. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/patch-keys.ts +5 -0
  27. package/node_modules/@herbertgao/pi-cc-extensions/package.json +4 -4
  28. package/node_modules/@herbertgao/pi-cc-extensions/themes/cc-dark.json +1 -1
  29. package/node_modules/@herbertgao/pi-cc-extensions/themes/cc-light.json +1 -1
  30. package/package.json +2 -2
@@ -1,6 +1,7 @@
1
- import { Text, type Component } from "@earendil-works/pi-tui";
1
+ import { Text, visibleWidth, type Component } from "@earendil-works/pi-tui";
2
2
  import type { EditToolDetails } from "@earendil-works/pi-coding-agent";
3
3
  import { sanitizeToolResultText } from "../../../utils/tool-result-sanitize.ts";
4
+ import { showMoreHintText } from "../show-more-hint.ts";
4
5
  import {
5
6
  getLineNumberWidth,
6
7
  parseDiff,
@@ -20,6 +21,7 @@ import {
20
21
  import { renderDiffFrameLine, renderHeaderRows, renderSingleDiffRow } from "./diff-header.ts";
21
22
  import {
22
23
  applyLineLimit,
24
+ clampDiffLineToWidth,
23
25
  clampDiffLinesToWidth,
24
26
  resolveDiffDisplayLimit,
25
27
  resolveDiffProcessBudget,
@@ -40,6 +42,7 @@ import {
40
42
  buildDiffSummaryText,
41
43
  normalizeDiffRenderWidth,
42
44
  resolveDiffPresentationMode,
45
+ styleDiffSummaryLine,
43
46
  } from "./diff-presentation.ts";
44
47
 
45
48
  function safeGetDiff(details: unknown): string {
@@ -81,6 +84,28 @@ export function countEditDiffStats(
81
84
  }
82
85
  }
83
86
 
87
+ /** Keep counts in the result row: the default-mode edit title does not include them. */
88
+ function renderEditCollapsedSummaryLine(
89
+ stats: ParsedDiff["stats"],
90
+ width: number,
91
+ theme: DiffTheme,
92
+ hovered: boolean,
93
+ ): string {
94
+ const counts = `+${stats.added} -${stats.removed}`;
95
+ const hint = showMoreHintText();
96
+ const candidates = [
97
+ `↳ diff ${counts} · ${hint}`,
98
+ `↳ ${counts} · ${hint}`,
99
+ `${counts} · ${hint}`,
100
+ `↳ diff ${counts}`,
101
+ counts,
102
+ "diff",
103
+ "…",
104
+ ];
105
+ const text = candidates.find((candidate) => visibleWidth(candidate) <= width) ?? "";
106
+ return clampDiffLineToWidth(styleDiffSummaryLine(text, stats, hint, theme, hovered), width);
107
+ }
108
+
84
109
  export function renderEditDiffResult(
85
110
  details: unknown,
86
111
  options: DiffRenderOptions,
@@ -110,29 +135,41 @@ export function renderEditDiffResult(
110
135
  return new Text(theme.fg("muted", "↳ no diff data"), 0, 0);
111
136
  }
112
137
 
113
- const splitRows = buildSplitRows(renderEntries);
114
- const showHashlineAnchors =
115
- options.expanded === true &&
116
- renderEntries.some((entry) => entry.kind === "line" && !!entry.hashlineAnchorContent);
117
- const lineNumberWidth = getLineNumberWidth(renderEntries, showHashlineAnchors);
118
138
  const palette = resolveDiffPalette(theme);
119
139
  // Rich diffs use ccstyle's self shell. Keep the panel transparent so the
120
140
  // separator cannot leak toolSuccessBg across the entire new column.
121
141
  const containerBgAnsi = undefined;
122
142
  const language = resolveLanguageFromPath(options.filePath);
123
143
  const cache = createDiffRenderCache();
124
- const highlightLine = createCodeLineHighlighter(language, theme, renderEntries, () => {
125
- cache.invalidate();
126
- options.invalidate?.();
127
- });
144
+ // Summary-only previews need parsed counts, not split rows or syntax highlighting.
145
+ // Keep detailed data lazy so a live setting change can still reveal the same diff.
146
+ function buildDetailedData() {
147
+ const showHashlineAnchors =
148
+ options.expanded === true &&
149
+ renderEntries.some((entry) => entry.kind === "line" && !!entry.hashlineAnchorContent);
150
+ return {
151
+ splitRows: buildSplitRows(renderEntries),
152
+ showHashlineAnchors,
153
+ lineNumberWidth: getLineNumberWidth(renderEntries, showHashlineAnchors),
154
+ highlightLine: createCodeLineHighlighter(language, theme, renderEntries, () => {
155
+ cache.invalidate();
156
+ options.invalidate?.();
157
+ }),
158
+ };
159
+ }
160
+ let detailedData: ReturnType<typeof buildDetailedData> | undefined;
128
161
 
129
162
  return {
130
163
  [RICH_DIFF_COMPONENT]: true,
131
- /** 鼠标层只在真正的 remainder 行上提供展开入口。 */
164
+ /** Only the current summary/remainder row is an expand target, never diff body text. */
132
165
  isCollapsedHintLine(plainLine: string): boolean {
133
166
  const hint = cache.getHintLine();
134
167
  return hint !== undefined && normalizeCollapsedHintLine(plainLine) === hint;
135
168
  },
169
+ /** 正文首行(diff 头行/0 行摘要行)自带 +A -D:标题不再重复计数。 */
170
+ bodyShowsStats(): boolean {
171
+ return true;
172
+ },
136
173
  render(width: number): string[] {
137
174
  // Live config: panel can change indicator/wrap/limits after this component is created.
138
175
  const live = resolveLiveDisplayConfig(config);
@@ -147,6 +184,20 @@ export function renderEditDiffResult(
147
184
  return cached;
148
185
  }
149
186
 
187
+ // Like write's zero-line preview, handle this before the normal body limiter.
188
+ if (!options.expanded && live.editDiffCollapsedLines === 0) {
189
+ const summary = renderEditCollapsedSummaryLine(parsed.stats, safeWidth, theme, hovered);
190
+ return cache.set(
191
+ safeWidth,
192
+ options.expanded,
193
+ mode,
194
+ configKey,
195
+ hovered,
196
+ [summary],
197
+ normalizeCollapsedHintLine(summary),
198
+ );
199
+ }
200
+
150
201
  if (mode === "summary") {
151
202
  return cache.set(
152
203
  safeWidth,
@@ -166,6 +217,8 @@ export function renderEditDiffResult(
166
217
  );
167
218
  }
168
219
 
220
+ const { splitRows, showHashlineAnchors, lineNumberWidth, highlightLine } = (detailedData ??=
221
+ buildDetailedData());
169
222
  const headerRows = renderHeaderRows(parsed.stats, mode, safeWidth, theme);
170
223
  const displayLimit = resolveDiffDisplayLimit(options.expanded, live.editDiffCollapsedLines);
171
224
  const processBudget = resolveDiffProcessBudget(displayLimit, wordWrap);
@@ -1,5 +1,6 @@
1
1
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
2
  import { pluralize } from "./diff-text.ts";
3
+ import type { DiffTheme } from "./diff-palette.ts";
3
4
  import type { ToolDisplayConfig } from "../../../config/config.ts";
4
5
 
5
6
  export interface DiffSummaryStats {
@@ -68,3 +69,43 @@ export function buildDiffSummaryText(stats: DiffSummaryStats, width: number): st
68
69
 
69
70
  return truncateToWidth(summaryCandidates[summaryCandidates.length - 1] ?? "", safeWidth, "");
70
71
  }
72
+
73
+ /**
74
+ * 折叠摘要行样式:整行 muted,`+A -D` 用 diff 头行同一套绿/红,hover 只把动作词提到 text。
75
+ * 0 行预览与多行预览共用这一套颜色:改折叠行数时同一处信息不变色。
76
+ */
77
+ export function styleDiffSummaryLine(
78
+ text: string,
79
+ stats: Pick<DiffSummaryStats, "added" | "removed"> | undefined,
80
+ hint: string,
81
+ theme: DiffTheme,
82
+ hovered: boolean,
83
+ ): string {
84
+ const counts = stats ? `+${stats.added} -${stats.removed}` : "";
85
+ const countsIndex = counts ? text.indexOf(counts) : -1;
86
+ const hintIndex = hint ? text.lastIndexOf(hint) : -1;
87
+ if (countsIndex < 0 && hintIndex < 0) {
88
+ return theme.fg("muted", text);
89
+ }
90
+
91
+ const pieces: string[] = [];
92
+ let cursor = 0;
93
+ const pushMuted = (end: number) => {
94
+ const gap = text.slice(cursor, end);
95
+ if (gap) pieces.push(theme.fg("muted", gap));
96
+ };
97
+ if (countsIndex >= 0 && stats) {
98
+ pushMuted(countsIndex);
99
+ pieces.push(theme.fg("toolDiffAdded", `+${stats.added}`));
100
+ pieces.push(theme.fg("muted", " "));
101
+ pieces.push(theme.fg("toolDiffRemoved", `-${stats.removed}`));
102
+ cursor = countsIndex + counts.length;
103
+ }
104
+ if (hintIndex >= cursor) {
105
+ pushMuted(hintIndex);
106
+ pieces.push(theme.fg(hovered ? "text" : "muted", hint));
107
+ cursor = hintIndex + hint.length;
108
+ }
109
+ pushMuted(text.length);
110
+ return pieces.join("");
111
+ }
@@ -20,4 +20,8 @@ export { shouldHighlightCodeBlock } from "./diff-highlight.ts";
20
20
  export type { DisplayConfigInput } from "./diff-component.ts";
21
21
  export { isRichDiffComponent } from "./diff-component.ts";
22
22
  export { countEditDiffStats, renderEditDiffResult } from "./diff-edit-render.ts";
23
- export { countWriteDiffStats, renderWriteDiffResult } from "./diff-write-render.ts";
23
+ export {
24
+ countWriteDiffStats,
25
+ renderWriteDiffResult,
26
+ writeStatsInCollapsedBody,
27
+ } from "./diff-write-render.ts";
@@ -1,4 +1,5 @@
1
1
  import { Text, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
2
+ import type { ToolDisplayConfig } from "../../../config/config.ts";
2
3
  import { sanitizeToolResultText } from "../../../utils/tool-result-sanitize.ts";
3
4
  import { showMoreHintText } from "../show-more-hint.ts";
4
5
  import { splitWriteContentLines } from "./diff-text.ts";
@@ -54,6 +55,7 @@ import {
54
55
  buildDiffSummaryText,
55
56
  normalizeDiffRenderWidth,
56
57
  resolveDiffPresentationMode,
58
+ styleDiffSummaryLine,
57
59
  type DiffPresentationMode,
58
60
  } from "./diff-presentation.ts";
59
61
 
@@ -290,13 +292,14 @@ function renderWriteOverwriteGuardRows(
290
292
  return renderSingleDiffRow(buildWriteOverwriteGuardText(guard, width), "warning", width, theme);
291
293
  }
292
294
 
293
- /** Folded write with `writeDiffCollapsedLines: 0`: `↳ created · click to show more`. */
295
+ /** Folded write with `writeDiffCollapsedLines: 0`: `↳ diff +A -D · created · click to show more`. */
294
296
  function renderWriteCollapsedHintLine(
295
297
  wasOverwrite: boolean,
296
298
  width: number,
297
299
  theme: DiffTheme,
298
300
  hovered: boolean,
299
301
  headerLabel?: string,
302
+ stats?: { added: number; removed: number },
300
303
  ): string[] {
301
304
  const safeWidth = normalizeDiffRenderWidth(width);
302
305
  if (safeWidth === 0) {
@@ -304,7 +307,20 @@ function renderWriteCollapsedHintLine(
304
307
  }
305
308
  const actionLabel = headerLabel?.trim() || (wasOverwrite ? "overwritten" : "created");
306
309
  const clickLabel = showMoreHintText();
307
- const candidates = [`↳ ${actionLabel} · ${clickLabel}`, `↳ ${actionLabel}`, actionLabel, "…"];
310
+ const counts = stats ? `+${stats.added} -${stats.removed}` : "";
311
+ const candidates = stats
312
+ ? [
313
+ `↳ diff ${counts} · ${actionLabel} · ${clickLabel}`,
314
+ `↳ diff ${counts} · ${clickLabel}`,
315
+ `↳ diff ${counts} · ${actionLabel}`,
316
+ `↳ diff ${counts}`,
317
+ `↳ ${counts} · ${clickLabel}`,
318
+ `↳ ${actionLabel} · ${clickLabel}`,
319
+ `↳ ${actionLabel}`,
320
+ actionLabel,
321
+ "…",
322
+ ]
323
+ : [`↳ ${actionLabel} · ${clickLabel}`, `↳ ${actionLabel}`, actionLabel, "…"];
308
324
  let text = candidates[candidates.length - 1] ?? "…";
309
325
  for (const candidate of candidates) {
310
326
  if (visibleWidth(candidate) <= safeWidth) {
@@ -315,16 +331,21 @@ function renderWriteCollapsedHintLine(
315
331
  if (visibleWidth(text) > safeWidth) {
316
332
  text = truncateToWidth(text, safeWidth, "");
317
333
  }
318
- const clickIndex = text.lastIndexOf(clickLabel);
319
- const styled =
320
- hovered && clickIndex >= 0
321
- ? theme.fg("muted", text.slice(0, clickIndex)) +
322
- theme.fg("text", clickLabel) +
323
- theme.fg("muted", text.slice(clickIndex + clickLabel.length))
324
- : theme.fg("muted", text);
334
+ const styled = styleDiffSummaryLine(text, stats, clickLabel, theme, hovered);
325
335
  return [clampDiffLineToWidth(styled, safeWidth)];
326
336
  }
327
337
 
338
+ /**
339
+ * 折叠 0 行预览时计数由正文那行显示(`↳ diff +A -D · created · click to show more`),
340
+ * 标题不再重复。compact 与 default 两个模式的写标题共用这条口径。
341
+ */
342
+ export function writeStatsInCollapsedBody(
343
+ expanded: boolean,
344
+ display: Pick<ToolDisplayConfig, "writeDiffCollapsedLines">,
345
+ ): boolean {
346
+ return !expanded && resolveWriteCollapsedLimit(display) === 0;
347
+ }
348
+
328
349
  /**
329
350
  * write 变更行统计(compact 单行用):复用当前 write diff 算法。
330
351
  * 新文件 = 全部新内容为 added;覆盖已有文件用 LCS 精确统计。
@@ -413,6 +434,10 @@ export function renderWriteDiffResult(
413
434
  const hint = cache.getHintLine();
414
435
  return hint !== undefined && normalizeCollapsedHintLine(plainLine) === hint;
415
436
  },
437
+ /** 折叠 0 行预览时正文那行自带 +A -D:标题不再重复计数。 */
438
+ bodyShowsStats(): boolean {
439
+ return writeStatsInCollapsedBody(options.expanded, resolveLiveDisplayConfig(config));
440
+ },
416
441
  render(width: number): string[] {
417
442
  // Live config: panel can change indicator/wrap/limits after this component is created.
418
443
  const live = resolveLiveDisplayConfig(config);
@@ -445,6 +470,7 @@ export function renderWriteDiffResult(
445
470
  theme,
446
471
  hovered,
447
472
  options.headerLabel,
473
+ countWriteDiffStats(content, options.previousContent, options.fileExistedBeforeWrite),
448
474
  );
449
475
  return cache.set(
450
476
  safeWidth,
@@ -8,6 +8,12 @@ import {
8
8
  } from "@earendil-works/pi-tui";
9
9
  import { config } from "../../config/config.ts";
10
10
  import { TOOL_LOADING_INTERVAL_MS, toolLoadingIcon } from "../../utils/tool-loading-icon.ts";
11
+ import {
12
+ ASK_USER_QUESTION_TOOL_NAME,
13
+ askUserAnswerText,
14
+ askUserAnswers,
15
+ type AskUserAnswer,
16
+ } from "./ask-user-question.ts";
11
17
  import {
12
18
  CODEMODE_TOOL_NAME,
13
19
  codemodeCalls,
@@ -23,10 +29,16 @@ import {
23
29
  fitToolCallSummary,
24
30
  humanizeToolLabel,
25
31
  renderToolSummary,
32
+ SHELL_TOOL_NAMES,
26
33
  toolCallSummary,
27
34
  type ToolCallSummary,
28
35
  } from "./names.ts";
29
- import { toolViewportWidth } from "./result.ts";
36
+ import {
37
+ formatToolDuration,
38
+ headTruncateToWidth,
39
+ isLiveToolCall,
40
+ toolViewportWidth,
41
+ } from "./result.ts";
30
42
  import {
31
43
  patchRegistry,
32
44
  TOOL_GROUPING_GENERATION_KEY as GENERATION_KEY,
@@ -108,6 +120,12 @@ function statusIcon(value: ToolStatus): string {
108
120
  return toolLoadingIcon();
109
121
  }
110
122
 
123
+ /** 等待窗口或运行中的行:spinner 逐帧变化,分组卡要跟着跳帧。 */
124
+ function isAnimatingTool(tool: any): boolean {
125
+ if (status(tool) !== "pending") return false;
126
+ return Boolean(tool?.executionStarted) || isLiveToolCall(tool?.toolCallId);
127
+ }
128
+
111
129
  function scheduleGroupAnimation(patch: Patch): void {
112
130
  if (patch.animationTimer || !patch.active) return;
113
131
  patch.animationTimer = setTimeout(() => {
@@ -115,11 +133,7 @@ function scheduleGroupAnimation(patch: Patch): void {
115
133
  if (!patch.active) return;
116
134
  let needsRender = false;
117
135
  for (const group of patch.groups) {
118
- if (
119
- (group.children as any[]).some(
120
- (tool) => tool?.executionStarted && status(tool) === "pending",
121
- )
122
- ) {
136
+ if ((group.children as any[]).some(isAnimatingTool)) {
123
137
  group.invalidate();
124
138
  // 分组卡不是 ToolExecutionComponent:它的 invalidate() 不会请求渲染,
125
139
  // 不补这一步 spinner 只在别人渲染时才跳帧(并行工具下表现为卡顿/冻结)。
@@ -203,6 +217,39 @@ function toolSummary(tool: any): ToolCallSummary {
203
217
  * codemode 的折叠行:有结果就报该次调用的统计(与单卡汇总行同一套口径),
204
218
  * 免得一张卡里几行长代码预览互相看不出区别。还没结果时退回代码预览。
205
219
  */
220
+ /**
221
+ * ask_user_question 的折叠行:有结果就报「问题 -> 回答」,
222
+ * 多题压成一行并标注剩余条数。还没结果时退回问题预览。
223
+ */
224
+ function askUserResultSummary(tool: any): ToolCallSummary | undefined {
225
+ if (toolName(tool) !== ASK_USER_QUESTION_TOOL_NAME || !tool?.result) return undefined;
226
+ const answers = askUserAnswers(tool.result)
227
+ .map((answer: AskUserAnswer, index: number) => ({ answer, index }))
228
+ .sort((left, right) => {
229
+ const leftIndex = Number(left.answer.questionIndex);
230
+ const rightIndex = Number(right.answer.questionIndex);
231
+ const leftMissing = !Number.isFinite(leftIndex);
232
+ const rightMissing = !Number.isFinite(rightIndex);
233
+ if (leftMissing || rightMissing) {
234
+ if (leftMissing !== rightMissing) return leftMissing ? 1 : -1;
235
+ return left.index - right.index;
236
+ }
237
+ return leftIndex - rightIndex;
238
+ })
239
+ .map((entry) => entry.answer);
240
+ if (answers.length === 0) {
241
+ const label = tool.result?.details?.cancelled === true ? "Declined" : "Done";
242
+ return { main: `${toolTitle(tool)} ${label}`, detail: "" };
243
+ }
244
+ const first = answers[0]!;
245
+ const question = String(first.question ?? "").trim() || "Question";
246
+ const more = answers.length > 1 ? ` (+${answers.length - 1})` : "";
247
+ return {
248
+ main: `${toolTitle(tool)} ${question} -> ${askUserAnswerText(first)}${more}`,
249
+ detail: "",
250
+ };
251
+ }
252
+
206
253
  function codemodeResultSummary(tool: any): ToolCallSummary | undefined {
207
254
  if (toolName(tool) !== CODEMODE_TOOL_NAME || !tool?.result) return undefined;
208
255
  const parts = codemodeSummaryParts(
@@ -213,10 +260,36 @@ function codemodeResultSummary(tool: any): ToolCallSummary | undefined {
213
260
  return { main: `${toolTitle(tool)} ${parts.join(" · ")}`, detail: "" };
214
261
  }
215
262
 
263
+ /**
264
+ * 组内 shell 行的尾巴:只报耗时(`· 12.4s`)。输出和错误正文都在展开后看;
265
+ * 失败靠行首的 ✗,组头另有 failed 计数。
266
+ * 耗时取 pi 记在结果上的 durationMs(单卡渲染上下文里的同一个值),运行中还拿不到就不报。
267
+ */
268
+ function shellResultTail(
269
+ tool: any,
270
+ fg: (color: string, text: string) => string,
271
+ toolStatus: ToolStatus,
272
+ ): string {
273
+ if (!SHELL_TOOL_NAMES.has(toolName(tool)) || toolStatus === "pending" || !tool?.result) return "";
274
+ const durationMs = tool.result?.durationMs;
275
+ const duration =
276
+ typeof durationMs === "number" && Number.isFinite(durationMs) && durationMs >= 0
277
+ ? formatToolDuration(durationMs)
278
+ : "";
279
+ return duration ? ` ${fg("dim", `· ${duration}`)}` : "";
280
+ }
281
+
282
+ /**
283
+ * 组头的工具列表:名称用与单组标题、子行同一套人性化写法,次数一律带上(各跑一次也报 ×1)。
284
+ * 同名(同显示标题)的合并成一项,避免 `MCP call×1, MCP call×1` 这种重复。
285
+ */
216
286
  function toolNameList(tools: any[]): string {
217
287
  const counts = new Map<string, number>();
218
- for (const tool of tools) counts.set(toolName(tool), (counts.get(toolName(tool)) ?? 0) + 1);
219
- return [...counts].map(([name, count]) => `${name}${count > 1 ? `×${count}` : ""}`).join(", ");
288
+ for (const tool of tools) {
289
+ const label = toolTitle(tool);
290
+ counts.set(label, (counts.get(label) ?? 0) + 1);
291
+ }
292
+ return [...counts].map(([label, count]) => `${label}×${count}`).join(", ");
220
293
  }
221
294
 
222
295
  let nextGroupId = 1;
@@ -389,31 +462,23 @@ export class ToolGroupComponent extends Container {
389
462
  const names = new Set(this.children.map(toolName));
390
463
  const label = names.size === 1 ? toolTitle(this.children[0]) : "Multiple Tools";
391
464
  const overall: ToolStatus = counts.error ? "error" : counts.pending ? "pending" : "success";
392
- if (
393
- (this.children as any[]).some((tool) => tool?.executionStarted && status(tool) === "pending")
394
- )
395
- scheduleGroupAnimation(this.patch);
465
+ if ((this.children as any[]).some(isAnimatingTool)) scheduleGroupAnimation(this.patch);
396
466
  const overallColor = overall === "pending" ? "accent" : overall;
397
- const nameList = names.size > 1 ? ` ${fg("dim", `· ${toolNameList(this.children)}`)}` : "";
398
467
  // 圆点保持 dim;hover 只高亮可点击文字。
399
468
  const hintText = this._expanded ? collapseHintText() : showMoreHintText();
400
469
  const hint = `${fg("dim", "·")} ${fg(this.hintHovered ? "text" : "dim", hintText)}`;
401
- // 行尾提示承载折叠分组的可点击区(collapsedHintHitbox 依赖行尾短语)。
402
- // 空间不足时先省略工具名列表,再截断计数尾部,提示最后让位;
403
- // 极窄视口连“圆点+标题+计数”都放不下时才回退整行截断。
404
- const viewport = toolViewportWidth(width);
470
+ const rowWidth = toolViewportWidth(width);
471
+ // 工具列表让位给展开提示:提示被截断的话,鼠标层要在行尾找 `click to show more`
472
+ // 就找不到,整张卡在窄宽度下点不开。名字截断后子行里仍能看全。
405
473
  const head = ` ${fg(overallColor, "●")} ${label}: ${countText}`;
406
- const hintWidth = visibleWidth(` ${hint}`);
407
- const nameBudget = viewport - visibleWidth(head) - hintWidth;
408
- const elidedName =
409
- nameList && nameBudget < visibleWidth(nameList)
410
- ? nameBudget >= 4
411
- ? ` ${fg("dim", "…")}`
412
- : ""
413
- : nameList;
414
- const headBudget = viewport - hintWidth - visibleWidth(elidedName);
415
- const titleLine = `${visibleWidth(head) <= headBudget ? head : truncateToWidth(head, headBudget, "…")}${elidedName} ${hint}`;
416
- const lines = ["", truncateToWidth(titleLine, viewport, "…")];
474
+ const tail = ` ${hint}`;
475
+ const listRoom = rowWidth - visibleWidth(head) - visibleWidth(tail) - 3;
476
+ // 连一个工具名都放不下就不放列表:退化成 `· …` 只会把行撑爆。
477
+ const nameList =
478
+ names.size > 1 && listRoom >= 4
479
+ ? ` ${fg("dim", `· ${headTruncateToWidth(toolNameList(this.children), listRoom)}`)}`
480
+ : "";
481
+ const lines = ["", truncateToWidth(`${head}${nameList}${tail}`, rowWidth, "")];
417
482
  const total = this.children.length;
418
483
  const childPaints = this._expanded
419
484
  ? (this.children as any[]).map((tool) => tool.render?.(Math.max(1, width - 2)))
@@ -441,15 +506,20 @@ export class ToolGroupComponent extends Container {
441
506
  const branch = index === total - 1 ? "└" : "├";
442
507
  const continuation = index === total - 1 ? " " : "│ ";
443
508
  if (!this._expanded) {
444
- const summary = codemodeResultSummary(tool) ?? toolSummary(tool);
509
+ const summary =
510
+ askUserResultSummary(tool) ?? codemodeResultSummary(tool) ?? toolSummary(tool);
445
511
  const prefix = ` ${fg("dim", branch)} ${fg(color, statusIcon(toolStatus))} `;
446
512
  const detail = fg("dim", summary.detail);
513
+ const result = shellResultTail(tool, fg, toolStatus);
447
514
  // 与单工具卡标题同宽,宽屏右侧留白一致
448
515
  const rowWidth = toolViewportWidth(width);
449
- const mainWidth = Math.max(0, rowWidth - visibleWidth(prefix) - visibleWidth(detail));
516
+ const mainWidth = Math.max(
517
+ 0,
518
+ rowWidth - visibleWidth(prefix) - visibleWidth(detail) - visibleWidth(result),
519
+ );
450
520
  lines.push(
451
521
  truncateToWidth(
452
- `${prefix}${renderToolSummary(summary, mainWidth, fg)}${detail}`,
522
+ `${prefix}${renderToolSummary(summary, mainWidth, fg)}${detail}${result}`,
453
523
  rowWidth,
454
524
  "",
455
525
  ),
@@ -5,9 +5,9 @@ import { config } from "../../config/config.ts";
5
5
  import { oneLine } from "../../utils/format.ts";
6
6
  import { headTruncateToWidth } from "./result.ts";
7
7
 
8
- /** inputClip=0 时不设字符上限,交给渲染时的实际宽度截断。 */
8
+ /** toolLabelClip=0 时不设字符上限,交给渲染时的实际宽度截断。 */
9
9
  function clipLimit(): number {
10
- return config.inputClip > 0 ? config.inputClip : Number.POSITIVE_INFINITY;
10
+ return config.toolLabelClip > 0 ? config.toolLabelClip : Number.POSITIVE_INFINITY;
11
11
  }
12
12
 
13
13
  function clip(value: unknown): string {
@@ -157,7 +157,7 @@ function pathSummary(
157
157
  }
158
158
 
159
159
  /** 入参是 shell 命令的工具。 */
160
- const SHELL_TOOL_NAMES = new Set(["bash", "powershell", "pwsh", "sh", "zsh", "cmd"]);
160
+ export const SHELL_TOOL_NAMES = new Set(["bash", "powershell", "pwsh", "sh", "zsh", "cmd"]);
161
161
 
162
162
  /** 内置检索工具:pattern 缺失(无效调用)时保留 "…" 占位。 */
163
163
  const SEARCH_TOOL_NAMES = new Set(["grep", "find"]);