pi-cc-extensions 0.8.59 → 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.
@@ -13,10 +13,10 @@ import { padLine } from "../utils/format.ts";
13
13
  export type ContextPart = {
14
14
  label: string;
15
15
  tokens: number;
16
- color: "accent" | "success" | "warning" | "muted" | "dim";
16
+ color: "accent" | "success" | "warning" | "customMessageLabel" | "muted" | "dim";
17
17
  };
18
18
 
19
- type PreviewKey = "systemPrompt" | "tools" | "contextFiles" | "skills";
19
+ type PreviewKey = "systemPrompt" | "tools" | "toolResults" | "contextFiles";
20
20
 
21
21
  type ContextPreview = {
22
22
  key: PreviewKey;
@@ -259,20 +259,30 @@ const tokenEstimate = (value: unknown): number => {
259
259
  return Math.max(0, Math.ceil(text.length / 4));
260
260
  };
261
261
 
262
- export function scaleParts(parts: ContextPart[], target: number): ContextPart[] {
263
- const estimated = parts.reduce((sum, part) => sum + part.tokens, 0);
264
- if (estimated === 0 || target <= 0) return parts;
265
- const scaled = parts.map((part) => ({
266
- ...part,
267
- tokens: Math.round((part.tokens / estimated) * target),
268
- }));
269
- const delta = target - scaled.reduce((sum, part) => sum + part.tokens, 0);
270
- const largest = scaled.reduce(
271
- (best, part, index) => (part.tokens > scaled[best]!.tokens ? index : best),
272
- 0,
273
- );
274
- scaled[largest]!.tokens += delta;
275
- return scaled;
262
+ export function capParts(parts: ContextPart[], target: number, fixedPrefix = 0): ContextPart[] {
263
+ const fixed = parts.slice(0, fixedPrefix);
264
+ const variable = parts.slice(fixedPrefix);
265
+ const fixedTokens = fixed.reduce((sum, part) => sum + part.tokens, 0);
266
+ const variableTarget = Math.max(0, target - fixedTokens);
267
+ const estimated = variable.reduce((sum, part) => sum + part.tokens, 0);
268
+ if (estimated <= variableTarget || estimated === 0) return parts;
269
+ if (variableTarget === 0) {
270
+ return [...fixed, ...variable.map((part) => ({ ...part, tokens: 0 }))];
271
+ }
272
+
273
+ let previous = 0;
274
+ let cumulative = 0;
275
+ const capped = variable.map((part, index) => {
276
+ cumulative += part.tokens;
277
+ const next =
278
+ index === variable.length - 1
279
+ ? variableTarget
280
+ : Math.round((cumulative / estimated) * variableTarget);
281
+ const tokens = next - previous;
282
+ previous = next;
283
+ return { ...part, tokens };
284
+ });
285
+ return [...fixed, ...capped];
276
286
  }
277
287
 
278
288
  export function formatTokens(tokens: number): string {
@@ -281,72 +291,122 @@ export function formatTokens(tokens: number): string {
281
291
  return `${Math.round(tokens / 1_000)}k`;
282
292
  }
283
293
 
294
+ export function resolveUsedTokens(
295
+ usage: { tokens: number | null; percent: number | null } | undefined,
296
+ estimated: number,
297
+ contextWindow: number,
298
+ ): number {
299
+ const reported = usage?.tokens;
300
+ const fromPercent =
301
+ usage?.percent !== null && usage?.percent !== undefined && contextWindow > 0
302
+ ? Math.round((usage.percent / 100) * contextWindow)
303
+ : undefined;
304
+ let resolved = reported ?? fromPercent ?? estimated;
305
+ if (reported !== null && reported !== undefined && fromPercent !== undefined) {
306
+ // 某些 Provider 会返回异常 totalTokens;百分比与底部状态栏不一致时优先采用百分比。
307
+ const tolerance = Math.max(32, Math.round(contextWindow * 0.001));
308
+ if (Math.abs(reported - fromPercent) > tolerance) resolved = fromPercent;
309
+ }
310
+ // tokens 与 percent 可能同时源自异常 usage;数量级明显偏小时回退到实际内容估算。
311
+ if (estimated > 0 && resolved < estimated * 0.25) return estimated;
312
+ return resolved;
313
+ }
314
+
284
315
  type ContextBreakdown = {
285
316
  parts: ContextPart[];
286
- options: BuildSystemPromptOptions;
287
- systemPrompt: string;
317
+ previews: Record<PreviewKey, string>;
288
318
  };
289
319
 
290
- /**
291
- * Single pass over prompt options + session entries. Returns options/systemPrompt
292
- * so the /context UI does not re-fetch or re-stringify the same sources.
293
- */
294
- function collectContextBreakdown(ctx: ExtensionCommandContext): ContextBreakdown {
320
+ function previewValue(value: unknown): string {
321
+ if (typeof value === "string") return value;
322
+ return `\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``;
323
+ }
324
+
325
+ /** 按真实请求的 systemPrompt、tools、messages 三部分同步组装计数与预览。 */
326
+ export function collectContextBreakdown(
327
+ ctx: ExtensionCommandContext,
328
+ allTools: ToolInfo[],
329
+ ): ContextBreakdown {
295
330
  const options = (ctx.getSystemPromptOptions?.() ?? {}) as BuildSystemPromptOptions;
296
331
  const systemPrompt = typeof ctx.getSystemPrompt === "function" ? ctx.getSystemPrompt() : "";
297
-
298
- const contextFileTokens = (options.contextFiles ?? []).reduce(
299
- (sum, file) => sum + tokenEstimate(file.content),
300
- 0,
301
- );
302
- // Prefer field-level estimates over JSON.stringify(whole skill).
303
- const skillTokens = (options.skills ?? []).reduce((sum, skill) => {
304
- if (!skill || typeof skill !== "object") return sum + tokenEstimate(skill);
305
- return (
306
- sum +
307
- tokenEstimate(skill.name) +
308
- tokenEstimate(skill.description) +
309
- tokenEstimate(skill.filePath)
310
- );
311
- }, 0);
312
- const tools = options.selectedTools ?? [];
313
- const snippets = options.toolSnippets;
314
- let toolTokens = tokenEstimate(options.promptGuidelines);
315
- for (const name of tools) {
316
- toolTokens += tokenEstimate(name) + tokenEstimate(snippets?.[name]);
332
+ const selectedTools = new Set(options.selectedTools ?? ["read", "bash", "edit", "write"]);
333
+ const toolDefinitionPreview: string[] = [];
334
+ const toolResultPreview: string[] = [];
335
+ const contextPreview: string[] = [];
336
+ let toolDefinitionTokens = 0;
337
+ let toolResultTokens = 0;
338
+ let contextTokens = 0;
339
+
340
+ for (const tool of allTools) {
341
+ if (!selectedTools.has(tool.name)) continue;
342
+ const definition = {
343
+ name: tool.name,
344
+ description: tool.description,
345
+ parameters: tool.parameters,
346
+ };
347
+ toolDefinitionTokens += tokenEstimate(definition);
348
+ toolDefinitionPreview.push(`## Definition: ${tool.name}\n\n${previewValue(definition)}`);
317
349
  }
318
350
 
319
- let user = 0;
320
- let assistant = 0;
321
- let toolResults = 0;
322
- let summaries = 0;
323
351
  for (const entry of ctx.sessionManager.buildContextEntries()) {
324
352
  if (entry.type === "message") {
325
- const tokens = estimateTokens(entry.message);
326
- if (entry.message.role === "user") user += tokens;
327
- else if (entry.message.role === "assistant") assistant += tokens;
328
- else toolResults += tokens;
353
+ const message = entry.message;
354
+ if (message.role === "assistant") {
355
+ for (const block of message.content) {
356
+ if (block.type === "toolCall") {
357
+ contextTokens += tokenEstimate(block.name) + tokenEstimate(block.arguments);
358
+ contextPreview.push(
359
+ `## Assistant tool call: ${block.name}\n\n${previewValue(block.arguments)}`,
360
+ );
361
+ } else if (block.type === "text") {
362
+ contextTokens += tokenEstimate(block.text);
363
+ contextPreview.push(`## Assistant\n\n${block.text}`);
364
+ } else if (block.type === "thinking") {
365
+ contextTokens += tokenEstimate(block.thinking);
366
+ contextPreview.push(`## Assistant thinking\n\n${block.thinking}`);
367
+ }
368
+ }
369
+ } else if (message.role === "toolResult") {
370
+ toolResultTokens += estimateTokens(message);
371
+ toolResultPreview.push(
372
+ `## Result: ${message.toolName}\n\n${previewValue(message.content)}`,
373
+ );
374
+ } else if (message.role === "bashExecution") {
375
+ toolResultTokens += estimateTokens(message);
376
+ toolResultPreview.push(
377
+ `## Bash\n\nCommand:\n\n${previewValue(message.command)}\n\nOutput:\n\n${previewValue(message.output)}`,
378
+ );
379
+ } else if (message.role === "branchSummary" || message.role === "compactionSummary") {
380
+ contextTokens += estimateTokens(message);
381
+ contextPreview.push(`## ${message.role}\n\n${message.summary}`);
382
+ } else {
383
+ contextTokens += estimateTokens(message);
384
+ contextPreview.push(`## ${message.role}\n\n${previewValue(message.content)}`);
385
+ }
329
386
  } else if (entry.type === "compaction" || entry.type === "branch_summary") {
330
- summaries += tokenEstimate(entry);
387
+ contextTokens += tokenEstimate(entry.summary);
388
+ contextPreview.push(
389
+ `## ${entry.type === "compaction" ? "Compaction" : "Branch summary"}\n\n${entry.summary}`,
390
+ );
391
+ } else if (entry.type === "custom_message") {
392
+ contextTokens += tokenEstimate(entry.content);
393
+ contextPreview.push(`## Custom: ${entry.customType}\n\n${previewValue(entry.content)}`);
331
394
  }
332
395
  }
333
396
 
334
- const systemTotal = tokenEstimate(systemPrompt);
335
- const baseSystem = Math.max(0, systemTotal - contextFileTokens - skillTokens - toolTokens);
336
- const parts: ContextPart[] = [
337
- { label: "System prompt", tokens: baseSystem, color: "accent" },
338
- { label: "Tools", tokens: toolTokens, color: "success" },
339
- { label: "Context files", tokens: contextFileTokens, color: "warning" },
340
- { label: "Skills", tokens: skillTokens, color: "warning" },
341
- { label: "User messages", tokens: user, color: "muted" },
342
- { label: "Assistant messages", tokens: assistant, color: "accent" },
343
- { label: "Tool results", tokens: toolResults, color: "dim" },
344
- { label: "Compaction summaries", tokens: summaries, color: "success" },
345
- ];
346
397
  return {
347
- parts: parts.filter((part) => part.tokens > 0),
348
- options,
349
- systemPrompt,
398
+ parts: [
399
+ { label: "System prompt", tokens: tokenEstimate(systemPrompt), color: "accent" },
400
+ { label: "Tools", tokens: toolDefinitionTokens, color: "success" },
401
+ { label: "Tool results", tokens: toolResultTokens, color: "customMessageLabel" },
402
+ { label: "Context", tokens: contextTokens, color: "warning" },
403
+ ] satisfies ContextPart[],
404
+ previews: {
405
+ systemPrompt: systemPrompt || "No system prompt.",
406
+ tools: toolDefinitionPreview.join("\n\n") || "No active tool definitions.",
407
+ toolResults: toolResultPreview.join("\n\n") || "No tool results in the current context.",
408
+ contextFiles: contextPreview.join("\n\n") || "No conversation context.",
409
+ },
350
410
  };
351
411
  }
352
412
 
@@ -356,11 +416,20 @@ export default function contextUsageExtension(pi: ExtensionAPI) {
356
416
  handler: async (_args, ctx) => {
357
417
  const usage = ctx.getContextUsage();
358
418
  const contextWindow = usage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
359
- const breakdown = collectContextBreakdown(ctx);
360
- const used = usage?.tokens ?? breakdown.parts.reduce((sum, part) => sum + part.tokens, 0);
361
- const parts = scaleParts(breakdown.parts, used);
419
+ const tools = pi.getAllTools();
420
+ const breakdown = collectContextBreakdown(ctx, tools);
421
+ const estimated = breakdown.parts.reduce((sum, part) => sum + part.tokens, 0);
422
+ const fixedTokens = breakdown.parts.slice(0, 2).reduce((sum, part) => sum + part.tokens, 0);
423
+ const used = Math.max(resolveUsedTokens(usage, estimated, contextWindow), fixedTokens);
424
+ const parts = capParts(breakdown.parts, used, 2);
425
+ const attributed = parts.reduce((sum, part) => sum + part.tokens, 0);
426
+ const other = Math.max(0, used - attributed);
362
427
  const free = Math.max(0, contextWindow - used);
363
- const allParts = [...parts, { label: "Free space", tokens: free, color: "dim" as const }];
428
+ const allParts = [
429
+ ...parts,
430
+ { label: "Other", tokens: other, color: "muted" as const },
431
+ { label: "Free space", tokens: free, color: "dim" as const },
432
+ ];
364
433
 
365
434
  if (ctx.mode !== "tui") {
366
435
  const lines = allParts.map((part) => `${part.label}: ${formatTokens(part.tokens)} tokens`);
@@ -368,64 +437,30 @@ export default function contextUsageExtension(pi: ExtensionAPI) {
368
437
  return;
369
438
  }
370
439
 
371
- const options = breakdown.options;
372
- const toolByName = new Map<string, ToolInfo>(
373
- pi.getAllTools().map((tool) => [tool.name, tool] as const),
374
- );
375
- const toolContent = (options.selectedTools ?? []).map((name) => {
376
- const tool = toolByName.get(name);
377
- const lines = [`## ${name}`];
378
- if (tool?.description) lines.push(tool.description);
379
- if (options.toolSnippets?.[name]) lines.push(`Prompt: ${options.toolSnippets[name]}`);
380
- if (tool?.promptGuidelines?.length) {
381
- lines.push("Guidelines:", ...tool.promptGuidelines.map((guideline) => `- ${guideline}`));
382
- }
383
- return lines.join("\n");
384
- });
385
- if (options.promptGuidelines?.length) {
386
- toolContent.push(
387
- `## Shared prompt guidelines\n${options.promptGuidelines.map((guideline) => `- ${guideline}`).join("\n")}`,
388
- );
389
- }
390
- const contextFilesContent = (options.contextFiles ?? [])
391
- .map((file) => `===== ${file.path} =====\n${file.content}`)
392
- .join("\n\n");
393
- const skillsContent = (options.skills ?? [])
394
- .map((skill) =>
395
- [
396
- `## ${skill.name}`,
397
- skill.description,
398
- `Path: ${skill.filePath}`,
399
- `Model invocation: ${skill.disableModelInvocation ? "disabled" : "enabled"}`,
400
- ]
401
- .filter(Boolean)
402
- .join("\n"),
403
- )
404
- .join("\n\n");
405
440
  const rawPreviews: ContextPreview[] = [
406
441
  {
407
442
  key: "systemPrompt",
408
443
  label: "System prompt",
409
444
  title: "System Prompt",
410
- content: breakdown.systemPrompt,
445
+ content: breakdown.previews.systemPrompt,
411
446
  },
412
447
  {
413
448
  key: "tools",
414
449
  label: "Tools",
415
450
  title: "Tools",
416
- content: toolContent.join("\n\n") || "No active tools.",
451
+ content: breakdown.previews.tools,
417
452
  },
418
453
  {
419
- key: "contextFiles",
420
- label: "Context files",
421
- title: "Context Files",
422
- content: contextFilesContent || "No context files loaded.",
454
+ key: "toolResults",
455
+ label: "Tool results",
456
+ title: "Tool Results",
457
+ content: breakdown.previews.toolResults,
423
458
  },
424
459
  {
425
- key: "skills",
426
- label: "Skills",
427
- title: "Skills",
428
- content: skillsContent || "No skills loaded.",
460
+ key: "contextFiles",
461
+ label: "Context",
462
+ title: "Context",
463
+ content: breakdown.previews.contextFiles,
429
464
  },
430
465
  ];
431
466
  const previews = rawPreviews.map((preview) => ({
@@ -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() {},