pi-cc-extensions 0.8.60 → 0.8.61

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/README.en.md CHANGED
@@ -56,7 +56,8 @@ Run `/reload` after installation.
56
56
  "diffViewMode": "auto", // diff layout: auto / split / unified
57
57
  "diffIndicatorMode": "bars", // diff change indicators: bars / classic / none
58
58
  "diffSplitMinWidth": 120, // min terminal width before auto layout uses side-by-side columns
59
- "diffCollapsedLines": 24, // diff body lines shown when collapsed; beyond that shows the expand hint (Ctrl+O / click)
59
+ "diffCollapsedLines": 24, // edit/diff body lines shown when collapsed; beyond that shows the expand hint (Ctrl+O / click)
60
+ "writeDiffCollapsedLines": 0, // write collapsed body lines; 0 = ↳ created • expand hint (stats stay on the title)
60
61
  "diffWordWrap": true, // whether long diff lines wrap (otherwise truncated)
61
62
  "expandedPreviewMaxLines": 40, // max body lines for expanded output/diff
62
63
  "useSummaryTitlesAsThinkingTitle": true, // use the latest provider summary as the active thinking title
package/README.md CHANGED
@@ -56,7 +56,8 @@ pi install git:github.com/minuque/pi-cc-extensions
56
56
  "diffViewMode": "auto", // diff 布局:auto / split / unified
57
57
  "diffIndicatorMode": "bars", // diff 变更指示:bars / classic / none
58
58
  "diffSplitMinWidth": 120, // 自动布局下使用左右分栏的最小终端宽度
59
- "diffCollapsedLines": 24, // 折叠时展示的 diff 行数,超出显示展开提示(Ctrl+O / 点击)
59
+ "diffCollapsedLines": 24, // 折叠时展示的 edit/diff 行数,超出显示展开提示(Ctrl+O / 点击)
60
+ "writeDiffCollapsedLines": 0, // write 折叠行数;0 = ↳ created • 展开提示(统计在标题)
60
61
  "diffWordWrap": true, // 长 diff 行是否换行(否则截断)
61
62
  "expandedPreviewMaxLines": 40, // 展开后输出/diff 正文的最大行数
62
63
  "useSummaryTitlesAsThinkingTitle": true, // 用最新 provider 摘要作为思考标题
@@ -16,6 +16,8 @@ export interface ToolDisplayConfig {
16
16
  diffIndicatorMode: DiffIndicatorMode;
17
17
  diffSplitMinWidth: number;
18
18
  diffCollapsedLines: number;
19
+ /** Write-only collapsed body lines. 0 = `↳ created • click to show more`. */
20
+ writeDiffCollapsedLines: number;
19
21
  diffWordWrap: boolean;
20
22
  expandedPreviewMaxLines: number;
21
23
  }
@@ -26,6 +28,11 @@ export const DEFAULT_TOOL_DISPLAY_CONFIG: ToolDisplayConfig = {
26
28
  diffSplitMinWidth: 120,
27
29
  /** Collapsed tool/diff body: ~half a typical terminal after chrome. */
28
30
  diffCollapsedLines: 24,
31
+ /**
32
+ * Write create/overwrite collapsed body.
33
+ * 0 = `↳ created • click to show more` (stats stay on the title).
34
+ */
35
+ writeDiffCollapsedLines: 0,
29
36
  diffWordWrap: true,
30
37
  /**
31
38
  * Expanded tool/diff body cap. 40 ≈ one screen of content after title,
@@ -42,6 +49,7 @@ export type Config = {
42
49
  diffIndicatorMode: DiffIndicatorMode;
43
50
  diffSplitMinWidth: number;
44
51
  diffCollapsedLines: number;
52
+ writeDiffCollapsedLines: number;
45
53
  diffWordWrap: boolean;
46
54
  expandedPreviewMaxLines: number;
47
55
  useSummaryTitlesAsThinkingTitle: boolean;
@@ -64,6 +72,8 @@ export const DIFF_VIEW_MODES: DiffViewMode[] = ["auto", "split", "unified"];
64
72
  export const DIFF_INDICATOR_MODES: DiffIndicatorMode[] = ["bars", "classic", "none"];
65
73
  export const DIFF_SPLIT_MIN_WIDTH_VALUES = ["80", "100", "120", "140", "160", "180"];
66
74
  export const DIFF_COLLAPSED_LINES_VALUES = ["12", "24", "36", "48", "80", "120"];
75
+ /** Write collapsed presets. 0 = stats only (`+N -0` + expand hint). */
76
+ export const WRITE_DIFF_COLLAPSED_LINES_VALUES = ["0", "4", "8", "12", "24", "36"];
67
77
  /** Presets for expanded body height — keep low options first so cycling stays TUI-friendly. */
68
78
  export const EXPANDED_PREVIEW_MAX_LINES_VALUES = ["40", "60", "80", "120", "200", "500", "2000"];
69
79
  export const THINKING_PREVIEW_LINES_VALUES = ["0", "1", "3", "5", "10"];
@@ -90,6 +100,7 @@ export const DEFAULT_CONFIG: Config = {
90
100
  diffIndicatorMode: DEFAULT_TOOL_DISPLAY_CONFIG.diffIndicatorMode,
91
101
  diffSplitMinWidth: DEFAULT_TOOL_DISPLAY_CONFIG.diffSplitMinWidth,
92
102
  diffCollapsedLines: DEFAULT_TOOL_DISPLAY_CONFIG.diffCollapsedLines,
103
+ writeDiffCollapsedLines: DEFAULT_TOOL_DISPLAY_CONFIG.writeDiffCollapsedLines,
93
104
  diffWordWrap: DEFAULT_TOOL_DISPLAY_CONFIG.diffWordWrap,
94
105
  expandedPreviewMaxLines: DEFAULT_TOOL_DISPLAY_CONFIG.expandedPreviewMaxLines,
95
106
  useSummaryTitlesAsThinkingTitle: true,
@@ -164,6 +175,12 @@ export function normalizeConfig(input: unknown): Config {
164
175
  1,
165
176
  500,
166
177
  ),
178
+ writeDiffCollapsedLines: pickPositiveInt(
179
+ source.writeDiffCollapsedLines,
180
+ DEFAULT_CONFIG.writeDiffCollapsedLines,
181
+ 0,
182
+ 500,
183
+ ),
167
184
  diffWordWrap: source.diffWordWrap !== false,
168
185
  expandedPreviewMaxLines: pickPositiveInt(
169
186
  source.expandedPreviewMaxLines,
@@ -207,6 +224,7 @@ export function getToolDisplayConfig(source: Config = config): ToolDisplayConfig
207
224
  diffIndicatorMode: source.diffIndicatorMode,
208
225
  diffSplitMinWidth: source.diffSplitMinWidth,
209
226
  diffCollapsedLines: source.diffCollapsedLines,
227
+ writeDiffCollapsedLines: source.writeDiffCollapsedLines,
210
228
  diffWordWrap: source.diffWordWrap,
211
229
  expandedPreviewMaxLines: source.expandedPreviewMaxLines,
212
230
  };
@@ -224,6 +242,7 @@ export function formatConfigStatus(source: Config = config): string {
224
242
  `diffIndicator=${source.diffIndicatorMode}`,
225
243
  `diffSplitMin=${source.diffSplitMinWidth}`,
226
244
  `diffCollapsed=${source.diffCollapsedLines}`,
245
+ `writeCollapsed=${source.writeDiffCollapsedLines}`,
227
246
  `diffWordWrap=${source.diffWordWrap ? "on" : "off"}`,
228
247
  `expandedMax=${source.expandedPreviewMaxLines}`,
229
248
  `thinkingTitle=${source.useSummaryTitlesAsThinkingTitle ? "summary" : "default"}`,
@@ -25,6 +25,7 @@ import {
25
25
  SCROLL_STEP_LINES_VALUES,
26
26
  THINKING_ANIMATION_INTERVAL_VALUES,
27
27
  THINKING_PREVIEW_LINES_VALUES,
28
+ WRITE_DIFF_COLLAPSED_LINES_VALUES,
28
29
  updateConfig,
29
30
  type CompactStyleMode,
30
31
  type Config,
@@ -40,7 +41,7 @@ export type CcstylePanelHooks = {
40
41
 
41
42
  function modeSettingDescription(mode: CompactStyleMode): string {
42
43
  if (mode === "compact") {
43
- return "(Experimental) One summary line per assistant round; expanded edit/write rows show rich diffs.";
44
+ return "(Experimental) One summary line per assistant round; edit/write reuse the same Diff preview settings as on.";
44
45
  }
45
46
  if (mode === "off") {
46
47
  return "Pi native tool rendering. Diff options below still apply independently.";
@@ -288,14 +289,24 @@ export async function showCcstylePanel(
288
289
  };
289
290
  const diffCollapsedSetting = {
290
291
  id: "diffCollapsedLines",
291
- label: "Collapsed lines",
292
+ label: "Edit collapsed lines",
292
293
  description:
293
- "How many diff body lines to show before the expand hint (Ctrl+O / click). Enter to type a custom value.",
294
+ "How many edit/diff body lines to show before the expand hint (Ctrl+O / click). Write uses its own setting below.",
294
295
  currentValue: String(config.diffCollapsedLines),
295
296
  values: [...DIFF_COLLAPSED_LINES_VALUES],
296
297
  submenu: (_current: string, closeSubmenu: (selected?: string) => void) =>
297
298
  buildNumberInputSubmenu(theme, diffCollapsedSetting, closeSubmenu),
298
299
  };
300
+ const writeDiffCollapsedSetting = {
301
+ id: "writeDiffCollapsedLines",
302
+ label: "Write collapsed lines",
303
+ description:
304
+ "Write-only collapsed body lines. 0 shows ↳ created + expand hint (stats stay on the title). Enter to type a custom value.",
305
+ currentValue: String(config.writeDiffCollapsedLines),
306
+ values: [...WRITE_DIFF_COLLAPSED_LINES_VALUES],
307
+ submenu: (_current: string, closeSubmenu: (selected?: string) => void) =>
308
+ buildNumberInputSubmenu(theme, writeDiffCollapsedSetting, closeSubmenu),
309
+ };
299
310
  const diffWordWrapSetting = {
300
311
  id: "diffWordWrap",
301
312
  label: "Diff word wrap",
@@ -453,6 +464,17 @@ export async function showCcstylePanel(
453
464
  });
454
465
  diffCollapsedSetting.currentValue = String(config.diffCollapsedLines);
455
466
  break;
467
+ case "writeDiffCollapsedLines":
468
+ updateConfig({
469
+ writeDiffCollapsedLines: pickPositiveInt(
470
+ value,
471
+ DEFAULT_CONFIG.writeDiffCollapsedLines,
472
+ 0,
473
+ 500,
474
+ ),
475
+ });
476
+ writeDiffCollapsedSetting.currentValue = String(config.writeDiffCollapsedLines);
477
+ break;
456
478
  case "diffWordWrap":
457
479
  updateConfig({ diffWordWrap: value === "on" });
458
480
  diffWordWrapSetting.description = config.diffWordWrap
@@ -519,6 +541,7 @@ export async function showCcstylePanel(
519
541
  diffIndicatorSetting,
520
542
  diffSplitSetting,
521
543
  diffCollapsedSetting,
544
+ writeDiffCollapsedSetting,
522
545
  diffWordWrapSetting,
523
546
  expandedMaxSetting,
524
547
  ],
@@ -11,8 +11,14 @@ import {
11
11
  type ExtensionAPI,
12
12
  type Theme,
13
13
  } from "@earendil-works/pi-coding-agent";
14
- import { getKeybindings } from "@earendil-works/pi-tui";
15
- import { Markdown, Spacer, Text, type Component } from "@earendil-works/pi-tui";
14
+ import {
15
+ getKeybindings,
16
+ truncateToWidth,
17
+ visibleWidth,
18
+ wrapTextWithAnsi,
19
+ } from "@earendil-works/pi-tui";
20
+ import { Box, Markdown, Spacer, Text, type Component } from "@earendil-works/pi-tui";
21
+ import { isToolTuiFullscreen } from "../renderer/tool/show-more-hint.ts";
16
22
  import {
17
23
  animateCompactThinkingText,
18
24
  formatThoughtDuration,
@@ -163,27 +169,206 @@ function getThinkingToggleHint() {
163
169
  return keys.length > 0 ? `${keys.join("/")} to expand` : undefined;
164
170
  }
165
171
 
166
- class StrictThinkingPreview implements Component {
172
+ function thinkingExpandAction(): string | undefined {
173
+ return isToolTuiFullscreen() ? "click to show more" : getThinkingToggleHint();
174
+ }
175
+
176
+ // 只 wrap 尾部窗口;缓存未着色折行,着色放到取出时做,避免主题切换命中旧 ANSI。
177
+ const previewCache = new Map<string, { lines: string[]; hiddenBefore: number }>();
178
+ const PREVIEW_CACHE_MAX = 500;
179
+ const PREVIEW_BUDGET_MIN_CHARS = 2_000;
180
+ const PREVIEW_BUDGET_SLACK_LINES = 2;
181
+
182
+ /** 按可见宽度估算折行数,不走 Text 全量 wrap。无换行长段也能计到隐藏行。 */
183
+ function countWrappedLines(text: string, contentWidth: number): number {
184
+ if (!text) return 0;
185
+ const width = Math.max(1, contentWidth);
186
+ let lines = 0;
187
+ for (const raw of text.replace(/\t/g, " ").split(/\r\n|\r|\n/)) {
188
+ const w = visibleWidth(raw);
189
+ lines += w === 0 ? 1 : Math.ceil(w / width);
190
+ }
191
+ return lines;
192
+ }
193
+
194
+ function padPreviewLine(line: string, width: number, padding: number): string {
195
+ const left = padding > 0 ? " ".repeat(padding) : "";
196
+ const right = padding > 0 ? " ".repeat(padding) : "";
197
+ const withMargins = left + line + right;
198
+ return withMargins + " ".repeat(Math.max(0, width - visibleWidth(withMargins)));
199
+ }
200
+
201
+ function layoutThinkingPreview(
202
+ text: string,
203
+ width: number,
204
+ padding: number,
205
+ ): { visible: string[]; hiddenLines: number } {
206
+ if (!text || config.previewLines <= 0) return { visible: [], hiddenLines: 0 };
207
+ const contentWidth = Math.max(1, width - padding * 2);
208
+ const budget = Math.max(
209
+ width * (config.previewLines + PREVIEW_BUDGET_SLACK_LINES),
210
+ PREVIEW_BUDGET_MIN_CHARS,
211
+ );
212
+ let source = text;
213
+ let cut = 0;
214
+ if (source.length > budget) {
215
+ const start = source.length - budget;
216
+ const newline = source.indexOf("\n", start);
217
+ cut = newline === -1 ? start : newline + 1;
218
+ if (cut < source.length) source = source.slice(cut);
219
+ else cut = 0;
220
+ }
221
+ const cacheKey = `${width}:${padding}:${cut}:${source}`;
222
+ let entry = previewCache.get(cacheKey);
223
+ if (!entry) {
224
+ entry = {
225
+ lines: wrapTextWithAnsi(source.replace(/\t/g, " "), contentWidth),
226
+ hiddenBefore: cut > 0 ? countWrappedLines(text.slice(0, cut), contentWidth) : 0,
227
+ };
228
+ if (previewCache.size >= PREVIEW_CACHE_MAX) previewCache.clear();
229
+ previewCache.set(cacheKey, entry);
230
+ }
231
+ const hiddenLines = entry.hiddenBefore + Math.max(0, entry.lines.length - config.previewLines);
232
+ const visible = hiddenLines > 0 ? entry.lines.slice(-config.previewLines) : entry.lines;
233
+ return { visible, hiddenLines };
234
+ }
235
+
236
+ function hiddenPreviewHint(
237
+ hiddenLines: number,
238
+ forceExpandHint = false,
239
+ ): { prefix: string; action: string; suffix: string } | undefined {
240
+ const action = thinkingExpandAction() ?? "";
241
+ if (hiddenLines > 0) {
242
+ const noun = hiddenLines === 1 ? "line" : "lines";
243
+ return {
244
+ prefix: ` • (${hiddenLines} more ${noun}${action ? ", " : ""}`,
245
+ action,
246
+ suffix: ")",
247
+ };
248
+ }
249
+ if (forceExpandHint && isToolTuiFullscreen()) {
250
+ return { prefix: " • ", action: "click to show more", suffix: "" };
251
+ }
252
+ return undefined;
253
+ }
254
+
255
+ const expandedThinking = new Set<number>();
256
+
257
+ /** 折叠预览 + 展开全文。fullscreen 点击 hint 展开、再点整块收起,对齐工具卡。 */
258
+ export class ThinkingPreviewBlock implements Component {
259
+ private heading: string;
167
260
  private text: string;
168
261
  private padding: number;
262
+ private messageTimestamp: number;
169
263
  private style: (text: string) => string;
170
-
171
- constructor(text: string, padding: number, style: (text: string) => string) {
264
+ private theme: Theme | undefined;
265
+ private _expanded: boolean;
266
+ private hintHovered = false;
267
+ private expandedBody: { width: number; lines: string[] } | undefined;
268
+
269
+ constructor(
270
+ heading: string,
271
+ text: string,
272
+ padding: number,
273
+ messageTimestamp: number,
274
+ style: (text: string) => string,
275
+ theme?: Theme,
276
+ ) {
277
+ this.heading = heading;
172
278
  this.text = text;
173
279
  this.padding = padding;
280
+ this.messageTimestamp = messageTimestamp;
174
281
  this.style = style;
282
+ this.theme = theme;
283
+ this._expanded = expandedThinking.has(messageTimestamp);
175
284
  }
176
285
 
177
- render(width: number) {
178
- const lines = new Text(this.style(this.text), this.padding, 0).render(width);
179
- if (lines.length <= config.previewLines) return lines;
286
+ private paint(color: string, text: string): string {
287
+ return this.theme && typeof this.theme.fg === "function"
288
+ ? this.theme.fg(color as never, text)
289
+ : text;
290
+ }
180
291
 
181
- const hiddenLines = lines.length - config.previewLines;
182
- const noun = hiddenLines === 1 ? "line" : "lines";
183
- const toggleHint = getThinkingToggleHint();
184
- const hint = `... (${hiddenLines} more ${noun}${toggleHint ? `, ${toggleHint}` : ""})`;
185
- const hintLines = new Text(this.style(hint), this.padding, 0).render(width);
186
- return [...hintLines, ...lines.slice(-config.previewLines)];
292
+ get expanded(): boolean {
293
+ return this._expanded;
294
+ }
295
+
296
+ setExpanded(expanded: boolean): void {
297
+ this._expanded = expanded;
298
+ if (expanded) expandedThinking.add(this.messageTimestamp);
299
+ else {
300
+ expandedThinking.delete(this.messageTimestamp);
301
+ this.expandedBody = undefined;
302
+ }
303
+ }
304
+
305
+ setHintHovered(hovered: boolean): void {
306
+ this.hintHovered = hovered;
307
+ }
308
+
309
+ private headingLines(width: number, hiddenLines: number, padding: number): string[] {
310
+ const hint = this._expanded
311
+ ? undefined
312
+ : hiddenPreviewHint(hiddenLines, Boolean(this.text) && config.previewLines <= 0);
313
+ if (!hint) return new Text(this.heading, padding, 0).render(width);
314
+ const rawHint = hint.prefix + hint.action + hint.suffix;
315
+ const contentWidth = Math.max(1, width - padding * 2);
316
+ const headingBudget = Math.max(0, contentWidth - visibleWidth(rawHint));
317
+ const clipped =
318
+ visibleWidth(this.heading) > headingBudget
319
+ ? truncateToWidth(this.heading, headingBudget, "")
320
+ : this.heading;
321
+ const action = this.paint(this.hintHovered ? "text" : "dim", hint.action);
322
+ return new Text(
323
+ clipped + this.paint("dim", hint.prefix) + action + this.paint("dim", hint.suffix),
324
+ padding,
325
+ 0,
326
+ ).render(width);
327
+ }
328
+
329
+ private bodyLines(width: number, padding: number): { lines: string[]; hiddenLines: number } {
330
+ if (!this.text || (config.previewLines <= 0 && !this._expanded)) {
331
+ return { lines: [], hiddenLines: 0 };
332
+ }
333
+ if (this._expanded) {
334
+ if (this.expandedBody?.width !== width) {
335
+ this.expandedBody = {
336
+ width,
337
+ lines: wrapTextWithAnsi(this.text.replace(/\t/g, " "), Math.max(1, width)),
338
+ };
339
+ }
340
+ return { lines: this.expandedBody.lines, hiddenLines: 0 };
341
+ }
342
+ const preview = layoutThinkingPreview(this.text, width, padding);
343
+ return { lines: preview.visible, hiddenLines: preview.hiddenLines };
344
+ }
345
+
346
+ render(width: number) {
347
+ if (this._expanded) {
348
+ const innerWidth = Math.max(1, width - 2);
349
+ const body = this.bodyLines(innerWidth, 0);
350
+ const heading = this.headingLines(innerWidth, 0, 0);
351
+ const inner = body.lines.length
352
+ ? [...heading, ...body.lines.map((line) => this.style(line))]
353
+ : heading;
354
+ const bgFn =
355
+ this.theme && typeof this.theme.bg === "function"
356
+ ? (text: string) => this.theme!.bg("userMessageBg" as never, text)
357
+ : undefined;
358
+ const box = new Box(1, 1, bgFn);
359
+ box.addChild({
360
+ render: () => inner,
361
+ invalidate() {},
362
+ });
363
+ return box.render(width);
364
+ }
365
+ const body = this.bodyLines(width, this.padding);
366
+ const heading = this.headingLines(width, body.hiddenLines, this.padding);
367
+ if (!body.lines.length) return heading;
368
+ return [
369
+ ...heading,
370
+ ...body.lines.map((line) => padPreviewLine(this.style(line), width, this.padding)),
371
+ ];
187
372
  }
188
373
 
189
374
  invalidate() {}
@@ -461,14 +646,17 @@ function compactThinking(pi: ExtensionAPI) {
461
646
  durationText ? `Thought for ${durationText}` : (latestSummary?.title ?? "Thought"),
462
647
  );
463
648
  }
464
- self.contentContainer.addChild(new Text(heading, self.outputPad, 0));
465
-
466
- const previewSource = latestSummary?.body ?? thinkingBlocks.join("\n\n");
467
- if (config.previewLines > 0 && previewSource.trim()) {
468
- self.contentContainer.addChild(
469
- new StrictThinkingPreview(previewSource.trim(), self.outputPad, thinkingStyle),
470
- );
471
- }
649
+ const previewSource = (latestSummary?.body ?? thinkingBlocks.join("\n\n")).trim();
650
+ self.contentContainer.addChild(
651
+ new ThinkingPreviewBlock(
652
+ heading,
653
+ previewSource,
654
+ self.outputPad,
655
+ message.timestamp,
656
+ thinkingStyle,
657
+ activeTheme,
658
+ ),
659
+ );
472
660
 
473
661
  const hasVisibleContentAfter = message.content
474
662
  .slice(i + 1)
@@ -733,6 +921,7 @@ function compactThinking(pi: ExtensionAPI) {
733
921
  latestComponentTimestamp = undefined;
734
922
  completedDurations.clear();
735
923
  streamingComponents.clear();
924
+ expandedThinking.clear();
736
925
  if (ctx.mode === "tui") ctx.ui.setWidget(WIDGET_ID, undefined);
737
926
 
738
927
  if (patchInstalled) {
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Compact mode:每条含 toolCall 的 assistant message 折叠为一条逐步累加的摘要行
3
- * (`Ran for 8s, bash×2, read×2`),edit/write 独立单行(`✓ write <path> (+25 -0)`),
3
+ * (`Ran for 8s, bash×2, read×2`),edit/write 独立标题行(`✓ write <path> (+25 -0)`),
4
4
  * 普通工具折叠时不显示独立行;展开(Ctrl+O / fullscreen 点击)在单个工具卡中恢复
5
- * compact-thinking/Pi 原生或专用 renderer。edit/write 折叠时显示统计,展开时显示 rich diff。
5
+ * compact-thinking/Pi 原生或专用 renderer。edit/write 复用 mode=on 的 rich diff
6
+ * 与 `diffCollapsedLines` / `writeDiffCollapsedLines` / `expandedPreviewMaxLines`。
6
7
  * Agent/Task 族:调用只进摘要计数,tool 卡始终折叠(避免 pending→完成高度闪动)。
7
8
  * 底部 Agents/Tasks 面板由 pi-subagents/pi-tasks 独立 widget 负责,不经 tool 卡外置。
8
9
  *
@@ -19,7 +20,7 @@ import {
19
20
  ToolExecutionComponent,
20
21
  type Theme,
21
22
  } from "@earendil-works/pi-coding-agent";
22
- import { truncateToWidth, visibleWidth, Box } from "@earendil-works/pi-tui";
23
+ import { truncateToWidth, visibleWidth, Box, Spacer } from "@earendil-works/pi-tui";
23
24
  import { config, getToolDisplayConfig } from "../config/config.ts";
24
25
  import { toolLoadingIcon } from "../utils/tool-loading-icon.ts";
25
26
  import { sanitizeToolResultText } from "../utils/tool-result-sanitize.ts";
@@ -33,6 +34,7 @@ import {
33
34
  } from "./tool/diff/diff-renderer.ts";
34
35
  import { renderRichToolResult } from "./tool/diff/index.ts";
35
36
  import type { WriteExecutionMetadataStore } from "./tool/diff/write-execution.ts";
37
+ import { isToolCallHovered } from "./mouse/hover.ts";
36
38
  import { insetComponent, renderExpandedToolResult, scheduleAnimation } from "./tool/result.ts";
37
39
  import { oneLine } from "../utils/format.ts";
38
40
  import { paddedBackgroundRow } from "./tool/grouping.ts";
@@ -475,6 +477,7 @@ function compactEditWriteLine(
475
477
  component: any,
476
478
  width: number,
477
479
  writeMetadata?: WriteExecutionMetadataStore,
480
+ options: { hint?: boolean } = {},
478
481
  ): string[] {
479
482
  const theme = themeOf();
480
483
  const name = String(component.toolName ?? "tool");
@@ -510,7 +513,8 @@ function compactEditWriteLine(
510
513
  }
511
514
  const iconPart = ` ${theme.fg(iconColor, icon)} `;
512
515
  const namePart = theme.fg("toolTitle", name);
513
- const hintText = component.expanded ? "" : ` • ${showMoreHintText()}`;
516
+ const hintText =
517
+ options.hint !== false && component.expanded !== true ? ` • ${showMoreHintText()}` : "";
514
518
  const fixedWidth =
515
519
  visibleWidth(iconPart) +
516
520
  visibleWidth(namePart) +
@@ -522,34 +526,50 @@ function compactEditWriteLine(
522
526
  return ["", truncateToWidth(line, width, "")];
523
527
  }
524
528
 
525
- /** compact edit/write 展开:复用 mode=on 的 rich diff;不可用时回退 Input/Output。 */
526
- function compactEditWriteExpandedLines(
529
+ /**
530
+ * compact edit/write:标题行 + mode=on 同一套 rich diff。
531
+ * 折叠/展开都走 `renderRichToolResult`,limits 不另开一套。
532
+ */
533
+ function compactEditWriteLines(
527
534
  component: any,
528
535
  width: number,
529
536
  writeMetadata?: WriteExecutionMetadataStore,
530
537
  ): string[] {
531
538
  const theme = themeOf();
532
539
  const result = component.result;
540
+ const expanded = component.expanded === true;
533
541
  const isError = result?.isError === true;
534
- const candidate = writeMetadata
535
- ? renderRichToolResult(
536
- String(component.toolName ?? ""),
537
- result,
538
- {
539
- expanded: true,
540
- isPartial: component.isPartial === true,
541
- isError,
542
- },
543
- theme,
544
- component,
545
- writeMetadata,
546
- getToolDisplayConfig,
547
- )
548
- : undefined;
549
- let detail: any;
550
- if (isRichDiffComponent(candidate)) {
542
+ const isPending = !result || component.isPartial === true;
543
+ const candidate =
544
+ !isPending && writeMetadata
545
+ ? renderRichToolResult(
546
+ String(component.toolName ?? ""),
547
+ result,
548
+ {
549
+ expanded,
550
+ isPartial: component.isPartial === true,
551
+ isError,
552
+ isHovered: () => isToolCallHovered(component.toolCallId),
553
+ },
554
+ theme,
555
+ component,
556
+ writeMetadata,
557
+ getToolDisplayConfig,
558
+ )
559
+ : undefined;
560
+ const hasRich = isRichDiffComponent(candidate);
561
+ if (hasRich) {
551
562
  component.resultRendererComponent = candidate;
552
- detail = insetComponent(candidate as any);
563
+ }
564
+
565
+ const title = compactEditWriteLine(component, width, writeMetadata, { hint: !hasRich });
566
+ if (!hasRich && !expanded) {
567
+ return title;
568
+ }
569
+
570
+ let detail: any;
571
+ if (hasRich) {
572
+ detail = expanded ? candidate : insetComponent(candidate as any);
553
573
  } else {
554
574
  const outputText = sanitizeToolResultText(
555
575
  Array.isArray(result?.content)
@@ -571,10 +591,14 @@ function compactEditWriteExpandedLines(
571
591
  component.resultRendererComponent = detail;
572
592
  }
573
593
 
594
+ if (!expanded) {
595
+ return [...title, ...detail.render(width)];
596
+ }
597
+
574
598
  const box = editWriteExpandedCard(theme);
575
599
  box.addChild({
576
600
  render(innerWidth: number): string[] {
577
- return compactEditWriteLine(component, innerWidth, writeMetadata).slice(1);
601
+ return compactEditWriteLine(component, innerWidth, writeMetadata, { hint: false }).slice(1);
578
602
  },
579
603
  invalidate() {},
580
604
  });
@@ -788,7 +812,8 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
788
812
  return;
789
813
  }
790
814
  try {
791
- uiRef?.requestRender?.(true);
815
+ // 非 force:保留 fullscreen 布局缓存和差分绘制。
816
+ uiRef?.requestRender?.();
792
817
  } catch {
793
818
  /* 无 UI */
794
819
  }
@@ -864,18 +889,65 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
864
889
  }
865
890
 
866
891
  if (round.anchor.expanded === true) {
867
- const assistantChildren: any[] = [];
892
+ const toolsById = new Map<string, any>();
893
+ for (const tool of trackedToolComponents) {
894
+ if (typeof tool?.toolCallId === "string") toolsById.set(tool.toolCallId, tool);
895
+ }
896
+ const cardItems: Array<{ child?: any; tool?: any }> = [];
897
+ const placedToolIds = new Set<string>();
898
+ const takeAssistantRun = (kids: any[], cursor: { i: number }) => {
899
+ while (cursor.i < kids.length && kids[cursor.i] instanceof Spacer) {
900
+ cardItems.push({ child: kids[cursor.i++] });
901
+ }
902
+ if (cursor.i < kids.length) cardItems.push({ child: kids[cursor.i++] });
903
+ };
904
+ const placeTool = (id: string, fallbackName?: string) => {
905
+ const tool = toolsById.get(id);
906
+ if (!tool || EDIT_WRITE_TOOLS.has(String(tool.toolName ?? fallbackName ?? ""))) return;
907
+ if (placedToolIds.has(id)) return;
908
+ placedToolIds.add(id);
909
+ cardItems.push({ tool });
910
+ };
868
911
  for (const [component, message] of round.messages) {
869
912
  passThroughAssistant(component, message);
870
- if (Array.isArray(component.contentContainer?.children)) {
871
- assistantChildren.push(...component.contentContainer.children);
872
- }
913
+ const kids = Array.isArray(component.contentContainer?.children)
914
+ ? [...component.contentContainer.children]
915
+ : [];
873
916
  component.contentContainer?.clear?.();
917
+ const cursor = { i: 0 };
918
+ let inThinkingRun = false;
919
+ for (const item of Array.isArray(message?.content) ? message.content : []) {
920
+ if (item?.type === "thinking") {
921
+ if (!String(item.thinking ?? "").trim()) continue;
922
+ if (!inThinkingRun) {
923
+ takeAssistantRun(kids, cursor);
924
+ inThinkingRun = true;
925
+ }
926
+ continue;
927
+ }
928
+ inThinkingRun = false;
929
+ if (item?.type === "text" && String(item.text ?? "").trim()) {
930
+ takeAssistantRun(kids, cursor);
931
+ continue;
932
+ }
933
+ if (item?.type === "toolCall" && typeof item.id === "string") {
934
+ placeTool(item.id, item.name);
935
+ }
936
+ }
937
+ while (cursor.i < kids.length) cardItems.push({ child: kids[cursor.i++] });
938
+ }
939
+ for (const message of round.detachedMessages) {
940
+ for (const item of Array.isArray(message?.content) ? message.content : []) {
941
+ if (item?.type === "toolCall" && typeof item.id === "string") {
942
+ placeTool(item.id, item.name);
943
+ }
944
+ }
874
945
  }
875
946
  const ids = roundToolCallIds(round);
876
947
  for (const id of ids) {
877
948
  round.suppressedToolIds.add(id);
878
949
  expandedRoundToolIds.add(id);
950
+ if (!placedToolIds.has(id)) placeTool(id);
879
951
  }
880
952
  // Round 展开只打开外层卡片。普通工具保持折叠,避免长输出递归撑满屏幕。
881
953
  for (const tool of trackedToolComponents) {
@@ -896,21 +968,20 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
896
968
  render(width: number): string[] {
897
969
  const theme = themeOf();
898
970
  const box = expandedToolCard(theme);
899
- for (const child of assistantChildren) box.addChild(child);
900
- const ids = roundToolCallIds(round);
901
- for (const tool of trackedToolComponents) {
902
- if (!ids.has(tool.toolCallId) || EDIT_WRITE_TOOLS.has(String(tool.toolName ?? ""))) {
903
- continue;
971
+ for (const item of cardItems) {
972
+ if (item.child) box.addChild(item.child);
973
+ else if (item.tool) {
974
+ const tool = item.tool;
975
+ box.addToolChild({
976
+ render: (innerWidth: number) => patch.toolOriginalRender.call(tool, innerWidth),
977
+ invalidate: () => tool.invalidate?.(),
978
+ });
904
979
  }
905
- box.addToolChild({
906
- render: (innerWidth: number) => patch.toolOriginalRender.call(tool, innerWidth),
907
- invalidate: () => tool.invalidate?.(),
908
- });
909
980
  }
910
981
  return ["", ...box.render(width)];
911
982
  },
912
983
  invalidate() {
913
- for (const child of assistantChildren) child.invalidate?.();
984
+ for (const item of cardItems) item.child?.invalidate?.();
914
985
  },
915
986
  });
916
987
  // 展开卡内工具会显示 error,外层仍挂 abort/length,避免只藏在折叠工具里。
@@ -1066,9 +1137,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1066
1137
  const name = String(this.toolName ?? "");
1067
1138
  if (EDIT_WRITE_TOOLS.has(name)) {
1068
1139
  if (!this.result || this.isPartial === true) scheduleAnimation(this);
1069
- const lines = compactEditWriteLine(this, width, deps.writeMetadata);
1070
- if (this.expanded !== true) return lines;
1071
- return compactEditWriteExpandedLines(this, width, deps.writeMetadata);
1140
+ return compactEditWriteLines(this, width, deps.writeMetadata);
1072
1141
  }
1073
1142
  // Agent/Task 等同普通工具:折叠不外置(live 面板走独立 widget)。
1074
1143
  if (expandedRoundToolIds.has(String(this.toolCallId ?? ""))) return [];
@@ -34,6 +34,7 @@ import {
34
34
  } from "./tool/result.ts";
35
35
  import { oneLine } from "../utils/format.ts";
36
36
  import { showMoreHintText } from "./tool/show-more-hint.ts";
37
+ import { countWriteDiffStats } from "./tool/diff/diff-renderer.ts";
37
38
  import { renderRichToolResult, type WriteExecutionMetadataStore } from "./tool/diff/index.ts";
38
39
  import { getMessageDisplayTheme } from "./tool/message-display.ts";
39
40
  import { humanizeToolLabel, toolCallSummary } from "./tool/names.ts";
@@ -242,6 +243,22 @@ function createCcstyleTool(
242
243
  title: label === toolName ? humanizeToolLabel(label) : label,
243
244
  variant: "default",
244
245
  });
246
+ let writeStatsText = "";
247
+ let writeStatsStyled = "";
248
+ if (toolName === "write" && visualState === "success") {
249
+ const meta = writeExecutionMetadata.get(context?.toolCallId);
250
+ const stats = countWriteDiffStats(
251
+ typeof args?.content === "string" ? args.content : undefined,
252
+ meta?.previousContent,
253
+ meta?.fileExistedBeforeWrite,
254
+ );
255
+ if (stats) {
256
+ writeStatsText = ` (+${stats.added} -${stats.removed})`;
257
+ writeStatsStyled = ` ${theme.fg("dim", "(")}${theme.fg("success", `+${stats.added}`)} ${theme.fg("error", `-${stats.removed}`)}${theme.fg("dim", ")")}`;
258
+ }
259
+ }
260
+ const extraText = writeStatsText || summary.detail;
261
+ const extraStyled = writeStatsStyled || theme.fg("dim", summary.detail);
245
262
  let cachedWidth: number | undefined;
246
263
  let cachedLine: string | undefined;
247
264
  const expanded = Boolean(context?.expanded);
@@ -255,10 +272,10 @@ function createCcstyleTool(
255
272
  0,
256
273
  viewportWidth - visibleWidth(icon) - 1 - (expanded ? 0 : 1),
257
274
  );
258
- const mainWidth = Math.max(0, callWidth - visibleWidth(summary.detail));
275
+ const mainWidth = Math.max(0, callWidth - visibleWidth(extraText));
259
276
  cachedWidth = width;
260
277
  // 纯文本先截断再着色(省略号不带 ANSI);从头截断,与多 tool 一致
261
- cachedLine = `${lead}${icon} ${theme.fg("toolTitle", headTruncateToWidth(summary.main, mainWidth))}${theme.fg("dim", summary.detail)}`;
278
+ cachedLine = `${lead}${icon} ${theme.fg("toolTitle", headTruncateToWidth(summary.main, mainWidth))}${extraStyled}`;
262
279
  return [truncateToWidth(cachedLine, viewportWidth, "")];
263
280
  },
264
281
  invalidate() {},
@@ -1,3 +1,4 @@
1
+ import { ThinkingPreviewBlock } from "../../feature/compact-thinking.ts";
1
2
  import { setHoveredCompactAssistant } from "../compact-mode.ts";
2
3
  import { patchRegistry, TOOL_HOVER_STATE_KEY } from "../../utils/patch-keys.ts";
3
4
  import {
@@ -43,6 +44,16 @@ export function setHoveredToolGroup(group: ToolGroupComponent | null): boolean {
43
44
  return true;
44
45
  }
45
46
 
47
+ let hoveredThinking: ThinkingPreviewBlock | null = null;
48
+
49
+ export function setHoveredThinking(block: ThinkingPreviewBlock | null): boolean {
50
+ if (block === hoveredThinking) return false;
51
+ hoveredThinking?.setHintHovered(false);
52
+ hoveredThinking = block;
53
+ block?.setHintHovered(true);
54
+ return true;
55
+ }
56
+
46
57
  let hoveredToolIoView: ExpandedToolIoView | null = null;
47
58
  let hoveredToolIoSection: ToolIoSection | null = null;
48
59
 
@@ -98,6 +109,7 @@ export function cachedFullscreenComponentAtRow(
98
109
  export type FullscreenHoverTarget =
99
110
  | { kind: "button" }
100
111
  | { kind: "group"; component: ToolGroupComponent }
112
+ | { kind: "thinking"; component: ThinkingPreviewBlock }
101
113
  | { kind: "assistant"; component: any }
102
114
  | {
103
115
  kind: "tool";
@@ -119,6 +131,8 @@ export function applyFullscreenHover(tui: any, target: FullscreenHoverTarget | n
119
131
  }
120
132
  const nextGroup = target?.kind === "group" ? target.component : null;
121
133
  if (setHoveredToolGroup(nextGroup)) changed = true;
134
+ const nextThinking = target?.kind === "thinking" ? target.component : null;
135
+ if (setHoveredThinking(nextThinking)) changed = true;
122
136
  const nextAssistant = target?.kind === "assistant" ? target.component : null;
123
137
  if (setHoveredCompactAssistant(nextAssistant)) changed = true;
124
138
  const nextView = target?.kind === "tool" ? target.view : null;
@@ -1,4 +1,5 @@
1
1
  import { hasActiveTextPreview, showTextPreview } from "../../feature/context.ts";
2
+ import { ThinkingPreviewBlock } from "../../feature/compact-thinking.ts";
2
3
  import { patchRegistry, TOOL_MOUSE_OWNER_KEY } from "../../utils/patch-keys.ts";
3
4
  import { ToolGroupComponent } from "../tool/grouping.ts";
4
5
  import { isCompactAssistantComponent, setHoveredCompactAssistant } from "../compact-mode.ts";
@@ -56,6 +57,7 @@ import {
56
57
  sharedToolHoverState,
57
58
  setHoveredToolCallId,
58
59
  setHoveredToolGroup,
60
+ setHoveredThinking,
59
61
  setHoveredToolIo,
60
62
  type FullscreenHoverTarget,
61
63
  } from "./hover.ts";
@@ -273,7 +275,8 @@ function handleFullscreenToolClick(tui: any, packet: SgrMousePacket): boolean {
273
275
  const isTool = isToolExecutionComponent(component);
274
276
  const isGroup = component instanceof ToolGroupComponent;
275
277
  const isAssistant = isCompactAssistantComponent(component);
276
- if (!isTool && !isGroup && !isAssistant) return false;
278
+ const isThinking = component instanceof ThinkingPreviewBlock;
279
+ if (!isTool && !isGroup && !isAssistant && !isThinking) return false;
277
280
  if (!component.expanded) {
278
281
  // collapsed 仅按钮文本可展开,不能把同一行正文/留白变成点击区。
279
282
  const hint = collapsedHintHitbox(line);
@@ -315,6 +318,7 @@ function handleFullscreenToolClick(tui: any, packet: SgrMousePacket): boolean {
315
318
  // 点击后清 hover 高亮。
316
319
  setHoveredToolCallId(null);
317
320
  setHoveredToolGroup(null);
321
+ setHoveredThinking(null);
318
322
  setHoveredToolIo(null, null);
319
323
  setHoveredCompactAssistant(null);
320
324
  card.invalidate?.();
@@ -360,6 +364,8 @@ function handleFullscreenToolHover(tui: any, packet: SgrMousePacket): void {
360
364
  );
361
365
  if (component instanceof ToolGroupComponent) {
362
366
  if (overHint) target = { kind: "group", component };
367
+ } else if (component instanceof ThinkingPreviewBlock) {
368
+ if (component.expanded || overHint) target = { kind: "thinking", component };
363
369
  } else if (isToolExecutionComponent(component)) {
364
370
  let view: ExpandedToolIoView | null = null;
365
371
  let section: ToolIoSection | null = null;
@@ -778,6 +784,7 @@ export function teardownToolMouseInteraction(
778
784
  toolMouseInputUnsubscribe = null;
779
785
  setHoveredToolCallId(null);
780
786
  setHoveredToolGroup(null);
787
+ setHoveredThinking(null);
781
788
  setHoveredToolIo(null, null);
782
789
  setHoveredCompactAssistant(null);
783
790
  try {
@@ -803,6 +810,7 @@ export function teardownToolMouseInteraction(
803
810
  /** off 模式清理:清空 hover 与回到底部按钮状态(跨模块 rebind 统一经由此函数)。 */
804
811
  export function resetToolHoverState(): void {
805
812
  setHoveredToolCallId(null);
813
+ setHoveredThinking(null);
806
814
  setHoveredCompactAssistant(null);
807
815
  setScrollButtonVisible(false);
808
816
  setScrollButtonHovered(false);
@@ -1,4 +1,5 @@
1
1
  import { visibleWidth } from "@earendil-works/pi-tui";
2
+ import { ThinkingPreviewBlock } from "../../feature/compact-thinking.ts";
2
3
  import { isCompactAssistantComponent } from "../compact-mode.ts";
3
4
  import { ToolGroupComponent } from "../tool/grouping.ts";
4
5
  import { isToolExecutionComponent, stripTerminalSequencesPreservingLayout } from "./packets.ts";
@@ -97,6 +98,9 @@ export function componentAtLocalRow(
97
98
  // compact 摘要行整体作为可展开卡片(折叠摘要 / 展开内容)。
98
99
  return { component, row: localRow };
99
100
  }
101
+ if (component instanceof ThinkingPreviewBlock) {
102
+ return { component, row: localRow };
103
+ }
100
104
  if (component === getScrollButtonWidget()) {
101
105
  return { component, row: localRow };
102
106
  }
@@ -134,7 +138,8 @@ export function collectFullscreenToolCards(
134
138
  if (
135
139
  isToolExecutionComponent(component) ||
136
140
  component instanceof ToolGroupComponent ||
137
- isCompactAssistantComponent(component)
141
+ isCompactAssistantComponent(component) ||
142
+ component instanceof ThinkingPreviewBlock
138
143
  ) {
139
144
  out.push(component);
140
145
  return;
@@ -40,6 +40,7 @@ export function displayConfigCacheKey(config: ToolDisplayConfig): string {
40
40
  config.diffIndicatorMode,
41
41
  String(config.diffSplitMinWidth),
42
42
  String(config.diffCollapsedLines),
43
+ String(config.writeDiffCollapsedLines),
43
44
  config.diffWordWrap ? "1" : "0",
44
45
  String(config.expandedPreviewMaxLines),
45
46
  ].join(":");
@@ -1,5 +1,5 @@
1
1
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
- import { DEFAULT_TOOL_DISPLAY_CONFIG } from "../../../config/config.ts";
2
+ import { DEFAULT_TOOL_DISPLAY_CONFIG, type ToolDisplayConfig } from "../../../config/config.ts";
3
3
  import { showMoreHintText } from "../show-more-hint.ts";
4
4
  import {
5
5
  buildCollapsedDiffHintText,
@@ -45,6 +45,20 @@ export function resolveDiffDisplayLimit(
45
45
  return expanded ? Math.max(0, expandedLimit) : Math.max(1, collapsedLimit);
46
46
  }
47
47
 
48
+ /** Write collapsed body limit. Missing/NaN falls back to edit's `diffCollapsedLines`. */
49
+ export function resolveWriteCollapsedLimit(
50
+ config: Pick<ToolDisplayConfig, "writeDiffCollapsedLines" | "diffCollapsedLines">,
51
+ ): number {
52
+ const value = config.writeDiffCollapsedLines;
53
+ if (Number.isFinite(value)) {
54
+ return Math.max(0, value);
55
+ }
56
+ const fallback = Number.isFinite(config.diffCollapsedLines)
57
+ ? config.diffCollapsedLines
58
+ : DEFAULT_TOOL_DISPLAY_CONFIG.writeDiffCollapsedLines;
59
+ return Math.max(0, fallback);
60
+ }
61
+
48
62
  /**
49
63
  * How many source rows/entries to fully highlight+render before applyLineLimit.
50
64
  * Extra headroom covers word-wrap expansion so the display limit can still fill.
@@ -1,5 +1,6 @@
1
1
  import { Text, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
2
2
  import { sanitizeToolResultText } from "../../../utils/tool-result-sanitize.ts";
3
+ import { showMoreHintText } from "../show-more-hint.ts";
3
4
  import { splitWriteContentLines } from "./diff-text.ts";
4
5
  import {
5
6
  collectDiffStats,
@@ -31,9 +32,11 @@ import {
31
32
  import { renderDiffFrameLine, renderSingleDiffRow, renderWriteHeader } from "./diff-header.ts";
32
33
  import {
33
34
  applyLineLimit,
35
+ clampDiffLineToWidth,
34
36
  clampDiffLinesToWidth,
35
37
  resolveDiffDisplayLimit,
36
38
  resolveDiffProcessBudget,
39
+ resolveWriteCollapsedLimit,
37
40
  takeEntriesForLineBudget,
38
41
  takeSplitRowsForBudget,
39
42
  } from "./diff-limits.ts";
@@ -286,6 +289,41 @@ function renderWriteOverwriteGuardRows(
286
289
  return renderSingleDiffRow(buildWriteOverwriteGuardText(guard, width), "warning", width, theme);
287
290
  }
288
291
 
292
+ /** Folded write with `writeDiffCollapsedLines: 0`: `↳ created • click to show more`. */
293
+ function renderWriteCollapsedHintLine(
294
+ wasOverwrite: boolean,
295
+ width: number,
296
+ theme: DiffTheme,
297
+ hovered: boolean,
298
+ headerLabel?: string,
299
+ ): string[] {
300
+ const safeWidth = normalizeDiffRenderWidth(width);
301
+ if (safeWidth === 0) {
302
+ return [""];
303
+ }
304
+ const actionLabel = headerLabel?.trim() || (wasOverwrite ? "overwritten" : "created");
305
+ const clickLabel = showMoreHintText();
306
+ const candidates = [`↳ ${actionLabel} • ${clickLabel}`, `↳ ${actionLabel}`, actionLabel, "…"];
307
+ let text = candidates[candidates.length - 1] ?? "…";
308
+ for (const candidate of candidates) {
309
+ if (visibleWidth(candidate) <= safeWidth) {
310
+ text = candidate;
311
+ break;
312
+ }
313
+ }
314
+ if (visibleWidth(text) > safeWidth) {
315
+ text = truncateToWidth(text, safeWidth, "");
316
+ }
317
+ const clickIndex = text.lastIndexOf(clickLabel);
318
+ const styled =
319
+ hovered && clickIndex >= 0
320
+ ? theme.fg("muted", text.slice(0, clickIndex)) +
321
+ theme.fg("text", clickLabel) +
322
+ theme.fg("muted", text.slice(clickIndex + clickLabel.length))
323
+ : theme.fg("muted", text);
324
+ return [clampDiffLineToWidth(styled, safeWidth)];
325
+ }
326
+
289
327
  /**
290
328
  * write 变更行统计(compact 单行用):复用当前 write diff 算法。
291
329
  * 新文件 = 全部新内容为 added;覆盖已有文件用 LCS 精确统计。
@@ -392,6 +430,28 @@ export function renderWriteDiffResult(
392
430
  return cached;
393
431
  }
394
432
 
433
+ const writeCollapsedLimit = resolveWriteCollapsedLimit(live);
434
+ const statsOnlyCollapsed = !options.expanded && writeCollapsedLimit === 0;
435
+ if (statsOnlyCollapsed) {
436
+ return cache.set(
437
+ safeWidth,
438
+ options.expanded,
439
+ mode,
440
+ configKey,
441
+ hovered,
442
+ clampDiffLinesToWidth(
443
+ renderWriteCollapsedHintLine(
444
+ options.fileExistedBeforeWrite === true,
445
+ safeWidth,
446
+ theme,
447
+ hovered,
448
+ options.headerLabel,
449
+ ),
450
+ safeWidth,
451
+ ),
452
+ );
453
+ }
454
+
395
455
  const header = renderWriteHeader(
396
456
  options.fileExistedBeforeWrite === true,
397
457
  safeWidth,
@@ -442,7 +502,7 @@ export function renderWriteDiffResult(
442
502
  const data = getDetailedData();
443
503
  const displayLimit = resolveDiffDisplayLimit(
444
504
  options.expanded,
445
- live.diffCollapsedLines,
505
+ writeCollapsedLimit,
446
506
  live.expandedPreviewMaxLines,
447
507
  );
448
508
  const processBudget = resolveDiffProcessBudget(displayLimit, wordWrap);
@@ -480,7 +540,7 @@ export function renderWriteDiffResult(
480
540
  bodyRows,
481
541
  safeWidth,
482
542
  options.expanded,
483
- live.diffCollapsedLines,
543
+ writeCollapsedLimit,
484
544
  live.expandedPreviewMaxLines,
485
545
  data.hunkCount,
486
546
  theme,
@@ -7,7 +7,7 @@ import {
7
7
  type Component,
8
8
  } from "@earendil-works/pi-tui";
9
9
  import { TOOL_LOADING_INTERVAL_MS, toolLoadingIcon } from "../../utils/tool-loading-icon.ts";
10
- import { showMoreHintText } from "./show-more-hint.ts";
10
+ import { isToolTuiFullscreen, showMoreHintText } from "./show-more-hint.ts";
11
11
  import { stripAnsi, stripBackgroundAnsi, stripLeadingStatusIcon } from "../../utils/ansi-text.ts";
12
12
  import { walkComponentTree } from "../../utils/component-tree.ts";
13
13
  import { humanizeToolLabel, toolCallSummary } from "./names.ts";
@@ -149,6 +149,17 @@ function toolNameList(tools: any[]): string {
149
149
 
150
150
  let nextGroupId = 1;
151
151
 
152
+ type SettledGroupCache = {
153
+ width: number;
154
+ hover: boolean;
155
+ theme: unknown;
156
+ fullscreen: boolean;
157
+ children: readonly unknown[];
158
+ args: unknown[];
159
+ results: unknown[];
160
+ lines: string[];
161
+ };
162
+
152
163
  export class ToolGroupComponent extends Container {
153
164
  readonly toolCallId = `ccstyle-tool-group-${nextGroupId++}`;
154
165
  readonly toolName = "Tool group";
@@ -159,6 +170,8 @@ export class ToolGroupComponent extends Container {
159
170
  }
160
171
  private hintHovered = false;
161
172
  private readonly patch: Patch;
173
+ /** 仅缓存已完成且折叠的分组;pending / expanded 每帧现算。 */
174
+ private settledCache: SettledGroupCache | undefined;
162
175
 
163
176
  constructor(patch: Patch) {
164
177
  super();
@@ -222,7 +235,49 @@ export class ToolGroupComponent extends Container {
222
235
  for (const tool of this.children) tool.invalidate?.();
223
236
  }
224
237
 
238
+ private settledCacheHit(width: number): string[] | undefined {
239
+ const cache = this.settledCache;
240
+ if (!cache || this._expanded) return;
241
+ if (
242
+ cache.width !== width ||
243
+ cache.hover !== this.hintHovered ||
244
+ cache.theme !== this.patch.theme ||
245
+ cache.fullscreen !== isToolTuiFullscreen()
246
+ ) {
247
+ return;
248
+ }
249
+ const tools = this.children as any[];
250
+ if (cache.children.length !== tools.length) return;
251
+ for (let i = 0; i < tools.length; i++) {
252
+ const tool = tools[i];
253
+ if (
254
+ cache.children[i] !== tool ||
255
+ cache.args[i] !== tool?.args ||
256
+ cache.results[i] !== tool?.result ||
257
+ status(tool) === "pending"
258
+ ) {
259
+ return;
260
+ }
261
+ }
262
+ return cache.lines;
263
+ }
264
+
265
+ private storeSettledCache(width: number, lines: string[]): void {
266
+ this.settledCache = {
267
+ width,
268
+ hover: this.hintHovered,
269
+ theme: this.patch.theme,
270
+ fullscreen: isToolTuiFullscreen(),
271
+ children: [...this.children],
272
+ args: (this.children as any[]).map((tool) => tool?.args),
273
+ results: (this.children as any[]).map((tool) => tool?.result),
274
+ lines,
275
+ };
276
+ }
277
+
225
278
  render(width: number): string[] {
279
+ const cached = this.settledCacheHit(width);
280
+ if (cached) return cached;
226
281
  const theme = this.patch.theme;
227
282
  const fg = (color: string, text: string) => theme?.fg?.(color, text) ?? text;
228
283
  const counts = { pending: 0, success: 0, error: 0 };
@@ -296,6 +351,8 @@ export class ToolGroupComponent extends Container {
296
351
  lines.push(paddedBackgroundRow(theme, backgroundSlot, line, width));
297
352
  }
298
353
  lines.push(paddedBackgroundRow(theme, backgroundSlot, "", width));
354
+ } else if (counts.pending === 0) {
355
+ this.storeSettledCache(width, lines);
299
356
  }
300
357
  return lines;
301
358
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-extensions",
3
- "version": "0.8.60",
3
+ "version": "0.8.61",
4
4
  "license": "MIT",
5
5
  "description": "A Pi productivity suite with Claude Code-style UI, context inspection, and agent/session references",
6
6
  "type": "module",