pi-cc-extensions 0.8.59 → 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.
@@ -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) => ({
@@ -149,6 +149,42 @@ function deCircled(markdown: string): string {
149
149
  return markdown.replace(CIRCLED_RE, (ch) => `(${ch.codePointAt(0)! - 0x2460 + 1})`);
150
150
  }
151
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
+
152
188
  // ============================================================================
153
189
  // 注册
154
190
  // ============================================================================
@@ -158,8 +194,10 @@ export default function (pi: ExtensionAPI): void {
158
194
  // 所以三个转换合并为一次注册,内部按序链式执行。
159
195
  pi.registerMarkdownTransformer((markdown, context) => {
160
196
  const { messageType, isStreaming = false } = context ?? {};
161
- // 与官方推荐一致:thinking 块与流式中间态不转换
162
- 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;
163
201
  // 0. 圈数字转半角括号(Nerd Font 补丁字形缺陷规避)
164
202
  markdown = deCircled(markdown);
165
203
  // 1. Mermaid 方言渲染
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-extensions",
3
- "version": "0.8.59",
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",