pi-cc-extensions 0.8.58 → 0.8.60

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
@@ -6,7 +6,6 @@
6
6
  <a href="https://pi.dev/packages?name=pi-cc-extensions"><img alt="Pi package catalog" src="https://img.shields.io/badge/Pi-package-58B7FF?style=flat-square"></a>
7
7
  <a href="https://www.npmjs.com/package/pi-cc-extensions"><img alt="npm version" src="https://img.shields.io/npm/v/pi-cc-extensions?style=flat-square&color=66E3C4"></a>
8
8
  <a href="#compatibility"><img alt="Node.js 22.19 or newer" src="https://img.shields.io/badge/Node.js-%E2%89%A522.19-66E3C4?style=flat-square"></a>
9
- <a href="./extensions"><img alt="TypeScript extensions" src="https://img.shields.io/badge/TypeScript-extensions-3178C6?style=flat-square"></a>
10
9
  </p>
11
10
 
12
11
  <p align="center">
@@ -21,9 +20,7 @@
21
20
 
22
21
  ## Preview
23
22
 
24
- <table>
25
- <img width="100%" alt="demo_new" src="https://github.com/user-attachments/assets/d4f9bb51-a49a-4a34-aa60-006514d37b09" />
26
- </table>
23
+ https://github.com/user-attachments/assets/6c858000-fdad-43f9-957f-4d0278648498
27
24
 
28
25
  ## Quick start
29
26
 
package/README.md CHANGED
@@ -6,7 +6,6 @@
6
6
  <a href="https://pi.dev/packages?name=pi-cc-extensions"><img alt="Pi package catalog" src="https://img.shields.io/badge/Pi-package-58B7FF?style=flat-square"></a>
7
7
  <a href="https://www.npmjs.com/package/pi-cc-extensions"><img alt="npm version" src="https://img.shields.io/npm/v/pi-cc-extensions?style=flat-square&color=66E3C4"></a>
8
8
  <a href="#兼容性"><img alt="Node.js 22.19 or newer" src="https://img.shields.io/badge/Node.js-%E2%89%A522.19-66E3C4?style=flat-square"></a>
9
- <a href="./extensions"><img alt="TypeScript extensions" src="https://img.shields.io/badge/TypeScript-extensions-3178C6?style=flat-square"></a>
10
9
  </p>
11
10
 
12
11
  <p align="center">
@@ -21,9 +20,7 @@
21
20
 
22
21
  ## 界面预览
23
22
 
24
- <table>
25
- <img width="100%" alt="demo_new" src="https://github.com/user-attachments/assets/d4f9bb51-a49a-4a34-aa60-006514d37b09" />
26
- </table>
23
+ https://github.com/user-attachments/assets/6c858000-fdad-43f9-957f-4d0278648498
27
24
 
28
25
  ## 快速开始
29
26
 
@@ -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) => ({
@@ -415,6 +415,10 @@ export default function sessionReferenceExtension(pi: ExtensionAPI): void {
415
415
 
416
416
  pi.on("session_start", (_event, ctx) => {
417
417
  const generation = ++sessionGeneration;
418
+ // 会话替换/reload 会让 ctx 的 getter 抛 stale 错误,await 之后不能再读;
419
+ // 这里在同步阶段一次性取出纯值。
420
+ const currentCwd = ctx.cwd;
421
+ const ui = ctx.ui;
418
422
  subagentIds.clear();
419
423
  clearLiveSubagentRecords();
420
424
  let loadErrorShown = false;
@@ -434,7 +438,7 @@ export default function sessionReferenceExtension(pi: ExtensionAPI): void {
434
438
  if (!loadErrorShown) {
435
439
  loadErrorShown = true;
436
440
  const reason = error instanceof Error ? error.message : String(error);
437
- ctx.ui.notify(`session-reference: failed to load sessions: ${reason}`, "error");
441
+ ui.notify(`session-reference: failed to load sessions: ${reason}`, "error");
438
442
  }
439
443
  return [];
440
444
  });
@@ -447,6 +451,8 @@ export default function sessionReferenceExtension(pi: ExtensionAPI): void {
447
451
  | undefined;
448
452
  const getReferences = async (): Promise<SessionReference[]> => {
449
453
  const sessions = await getSessions();
454
+ // 会话替换/reload 后丢弃旧 generation 的结果,避免对失效状态继续工作。
455
+ if (generation !== sessionGeneration) return [];
450
456
  const subagentKey = [...subagentIds].join("\0");
451
457
  if (
452
458
  referencesCache &&
@@ -457,7 +463,7 @@ export default function sessionReferenceExtension(pi: ExtensionAPI): void {
457
463
  }
458
464
  const ordered = orderSessionReferences(
459
465
  mergeReferences(sessions, liveSubagentReferences(subagentIds, currentSessionId)),
460
- ctx.cwd,
466
+ currentCwd,
461
467
  );
462
468
  referencesCache = { sessions, subagentKey, ordered };
463
469
  return ordered;
@@ -465,13 +471,15 @@ export default function sessionReferenceExtension(pi: ExtensionAPI): void {
465
471
 
466
472
  getAvailableReferences = getReferences;
467
473
  if (ctx.mode === "tui") {
468
- void getReferences();
474
+ // 预取是 detached 的,必须兜住 rejection,否则 stale 错误会变成
475
+ // unhandled rejection 直接终止 Pi。
476
+ void getReferences().catch(() => {});
469
477
  // Register after other session_start handlers. pi-fff claims every @
470
478
  // prefix, so a provider installed before it would never see session mentions.
471
479
  setTimeout(() => {
472
480
  if (generation !== sessionGeneration) return;
473
- ctx.ui.addAutocompleteProvider((current) =>
474
- createAutocompleteProvider(current, getReferences, ctx.cwd),
481
+ ui.addAutocompleteProvider((current) =>
482
+ createAutocompleteProvider(current, getReferences, currentCwd),
475
483
  );
476
484
  }, 0);
477
485
  }
@@ -82,14 +82,23 @@ function renderAdmonition(lines: string[], i: number): { output: string[]; next:
82
82
  // 裸 URL 转可点击超链接
83
83
  // ============================================================================
84
84
 
85
- const URL_RE = /(?<!<)(?<!\]\()https?:\/\/[^\s<>'"|,。;:!?、」』】]+/g;
86
- const TRIM_URL_RE = /[.,;:!?】」』"'》]+$/;
85
+ const URL_RE = /(?<!<)(?<!\]\()https?:\/\/[^\s<>'"|,。;:!?、」』】()【】《》『』「」]+/g;
86
+ const TRIM_URL_RE = /[.,;:!?】」』"'》)}]+$/;
87
87
 
88
- /** 去掉 URL 尾部标点;括号按平衡保留(如 Wikipedia 链接含括号)。 */
88
+ /**
89
+ * 去掉 URL 尾部标点;括号按平衡保留(如 Wikipedia 链接含括号、
90
+ * IPv6 地址含 [::1]),尾部不成对的 ] 与 ) 会截掉。
91
+ */
89
92
  function trimUrl(url: string): string {
90
93
  let t = url.replace(TRIM_URL_RE, "");
91
- while (t.endsWith(")") && (t.match(/\(/g)?.length ?? 0) < (t.match(/\)/g)?.length ?? 0)) {
92
- t = t.slice(0, -1);
94
+ for (;;) {
95
+ if (t.endsWith(")") && (t.match(/\(/g)?.length ?? 0) < (t.match(/\)/g)?.length ?? 0)) {
96
+ t = t.slice(0, -1);
97
+ } else if (t.endsWith("]") && (t.match(/\[/g)?.length ?? 0) < (t.match(/\]/g)?.length ?? 0)) {
98
+ t = t.slice(0, -1);
99
+ } else {
100
+ break;
101
+ }
93
102
  }
94
103
  return t;
95
104
  }
@@ -140,6 +149,42 @@ function deCircled(markdown: string): string {
140
149
  return markdown.replace(CIRCLED_RE, (ch) => `(${ch.codePointAt(0)! - 0x2460 + 1})`);
141
150
  }
142
151
 
152
+ // ============================================================================
153
+ // 跨行链接规整
154
+ // ============================================================================
155
+
156
+ const MULTILINE_LINK_RE = /\[([^\]\n]*(?:\n[^\]\n]*)+)\](?=\()/g;
157
+
158
+ /** 防止 Markdown 跨行链接把行尾填充空格纳入 OSC 8 点击区。 */
159
+ function normalizeMultilineLinks(markdown: string): string {
160
+ const out: string[] = [];
161
+ let prose: string[] = [];
162
+ let inFence = false;
163
+ const flush = () => {
164
+ if (prose.length === 0) return;
165
+ out.push(
166
+ prose.join("\n").replace(MULTILINE_LINK_RE, (_match, label: string) => {
167
+ const normalized = label.replace(/[ \t]*\n[ \t]*/g, " ").trim();
168
+ return `[${normalized}]`;
169
+ }),
170
+ );
171
+ prose = [];
172
+ };
173
+ for (const line of markdown.split("\n")) {
174
+ if (/^```/.test(line)) {
175
+ flush();
176
+ inFence = !inFence;
177
+ out.push(line);
178
+ } else if (inFence) {
179
+ out.push(line);
180
+ } else {
181
+ prose.push(line);
182
+ }
183
+ }
184
+ flush();
185
+ return out.join("\n");
186
+ }
187
+
143
188
  // ============================================================================
144
189
  // 注册
145
190
  // ============================================================================
@@ -149,8 +194,10 @@ export default function (pi: ExtensionAPI): void {
149
194
  // 所以三个转换合并为一次注册,内部按序链式执行。
150
195
  pi.registerMarkdownTransformer((markdown, context) => {
151
196
  const { messageType, isStreaming = false } = context ?? {};
152
- // 与官方推荐一致:thinking 块与流式中间态不转换
153
- if (messageType === "assistant-thinking" || isStreaming) return markdown;
197
+ // thinking 保持原文;普通回复即使流式中也先关闭跨行链接的空白点击区。
198
+ if (messageType === "assistant-thinking") return markdown;
199
+ markdown = normalizeMultilineLinks(markdown);
200
+ if (isStreaming) return markdown;
154
201
  // 0. 圈数字转半角括号(Nerd Font 补丁字形缺陷规避)
155
202
  markdown = deCircled(markdown);
156
203
  // 1. Mermaid 方言渲染
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-extensions",
3
- "version": "0.8.58",
3
+ "version": "0.8.60",
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",
@@ -21,6 +21,7 @@
21
21
  "files": [
22
22
  "extensions",
23
23
  "assets/readme",
24
+ "assets/video",
24
25
  "themes",
25
26
  "README.md",
26
27
  "README.en.md"
@@ -34,7 +35,7 @@
34
35
  "./themes/cc-light.json"
35
36
  ],
36
37
  "image": "https://raw.githubusercontent.com/minuque/pi-cc-extensions/main/assets/readme/welcome.webp",
37
- "video": "https://github.com/user-attachments/assets/debf76ea-a9dc-4f34-977f-aae344a9a7f8"
38
+ "video": "https://github.com/user-attachments/assets/6c858000-fdad-43f9-957f-4d0278648498"
38
39
  },
39
40
  "scripts": {
40
41
  "test": "node --test tests/**/*.test.ts",