pi-cc-extensions 0.8.48 → 0.8.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.en.md +17 -36
  2. package/README.md +13 -12
  3. package/assets/readme/welcome.webp +0 -0
  4. package/extensions/config/config.ts +245 -0
  5. package/extensions/config/panel.ts +469 -0
  6. package/extensions/{compact-thinking.ts → feature/compact-thinking.ts} +43 -6
  7. package/extensions/{markdown-enhance.ts → feature/markdown-enhance.ts} +88 -62
  8. package/extensions/{pi-startup-header.ts → feature/pi-startup-header.ts} +129 -56
  9. package/extensions/index.ts +9 -9
  10. package/extensions/renderer/index.ts +890 -0
  11. package/extensions/renderer/mouse-interaction.ts +1344 -0
  12. package/extensions/renderer/show-more-hint.ts +40 -0
  13. package/extensions/{tool-diff → renderer/tool-diff}/diff-renderer.ts +3 -2
  14. package/extensions/{tool-diff → renderer/tool-diff}/line-width-safety.ts +2 -1
  15. package/extensions/{tool-grouping.ts → renderer/tool-grouping.ts} +71 -10
  16. package/extensions/renderer/tool-result.ts +717 -0
  17. package/package.json +1 -1
  18. package/themes/cc-dark.json +2 -2
  19. package/themes/cc-light.json +2 -2
  20. package/extensions/claude-code-style.ts +0 -3348
  21. /package/extensions/{agent-autocomplete.ts → feature/agent-autocomplete.ts} +0 -0
  22. /package/extensions/{context.ts → feature/context.ts} +0 -0
  23. /package/extensions/{pi-aliases.ts → feature/pi-aliases.ts} +0 -0
  24. /package/extensions/{session-reference-core.ts → feature/session-reference-core.ts} +0 -0
  25. /package/extensions/{session-reference.ts → feature/session-reference.ts} +0 -0
  26. /package/extensions/{working-message.ts → feature/working-message.ts} +0 -0
  27. /package/extensions/{tool-diff → renderer/tool-diff}/ATTRIBUTION.md +0 -0
  28. /package/extensions/{tool-diff → renderer/tool-diff}/ansi-utils.ts +0 -0
  29. /package/extensions/{tool-diff → renderer/tool-diff}/diff-presentation.ts +0 -0
  30. /package/extensions/{tool-diff → renderer/tool-diff}/index.ts +0 -0
  31. /package/extensions/{tool-diff → renderer/tool-diff}/render-utils.ts +0 -0
  32. /package/extensions/{tool-diff → renderer/tool-diff}/shiki-highlight.ts +0 -0
  33. /package/extensions/{tool-diff → renderer/tool-diff}/types.ts +0 -0
  34. /package/extensions/{tool-diff → renderer/tool-diff}/write-display-utils.ts +0 -0
  35. /package/extensions/{tool-diff → renderer/tool-diff}/write-execution.ts +0 -0
  36. /package/extensions/{fullscreen-detect.ts → utils/fullscreen-detect.ts} +0 -0
  37. /package/extensions/{tool-loading-icon.ts → utils/tool-loading-icon.ts} +0 -0
  38. /package/extensions/{tool-result-sanitize.ts → utils/tool-result-sanitize.ts} +0 -0
@@ -0,0 +1,890 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { ToolExecutionComponent } from "@earendil-works/pi-coding-agent";
3
+ import { Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
+ import type { CompactThinkingController } from "../feature/compact-thinking.ts";
5
+ import { installToolGrouping, type ToolGroupingHooks } from "./tool-grouping.ts";
6
+ import { isLazyProxyTui } from "../utils/fullscreen-detect.ts";
7
+ import { showCcstylePanel } from "../config/panel.ts";
8
+ import {
9
+ config,
10
+ DEFAULT_CONFIG,
11
+ formatConfigStatus,
12
+ getCompactThinkingConfig,
13
+ getToolDisplayConfig,
14
+ normalizeConfig,
15
+ saveConfig,
16
+ setConfig,
17
+ type CompactStyleMode,
18
+ type Config,
19
+ } from "../config/config.ts";
20
+ import {
21
+ installToolMouseInteraction,
22
+ isToolCallHovered,
23
+ resetToolHoverState,
24
+ scheduleSessionRender,
25
+ setHoveredToolGroup,
26
+ setHoveredToolIo,
27
+ teardownToolMouseInteraction,
28
+ toolMouseTui,
29
+ TOOL_MOUSE_DISABLE,
30
+ } from "./mouse-interaction.ts";
31
+ import {
32
+ clearAllAnimations,
33
+ countLines,
34
+ hasExpandableDetail,
35
+ isToolExpanded,
36
+ middleTruncateToWidth,
37
+ oneLine,
38
+ outputLineCount,
39
+ pendingIcon,
40
+ renderCollapsedToolResultToWidth,
41
+ renderExpandedToolResult,
42
+ resolveToolVisualState,
43
+ scheduleAnimation,
44
+ settledIcon,
45
+ setToolVisualState,
46
+ textFromResult,
47
+ toolIconColor,
48
+ toolViewportWidth,
49
+ } from "./tool-result.ts";
50
+ import { showMoreHintText } from "./show-more-hint.ts";
51
+ import {
52
+ installWriteOverride,
53
+ renderRichToolResult,
54
+ WriteExecutionMetadataStore,
55
+ } from "./tool-diff/index.ts";
56
+
57
+ /**
58
+ * Claude Code Style for pi — 装配入口(拆分后位于 ccstyle/index.ts)。
59
+ *
60
+ * Tool rendering (summaries, rich edit/write diffs, expand/collapse) is applied
61
+ * via prototype-level patches.
62
+ */
63
+ // Bright green for success icon (truecolor ANSI escape)
64
+ const BRIGHT_GREEN = "\x1b[38;2;80;220;100m";
65
+ const ANSI_FG_RESET = "\x1b[39m";
66
+
67
+ function refreshCurrentTranscript(ctx?: any, toolGrouping?: ToolGroupingHooks): void {
68
+ toolGrouping?.refresh(toolMouseTui);
69
+ toolMouseTui?.requestRender?.(true);
70
+ ctx?.ui?.requestRender?.(true);
71
+ }
72
+
73
+ function applyStyleMode(mode: CompactStyleMode, ctx: any, toolGrouping?: ToolGroupingHooks): void {
74
+ config.mode = mode;
75
+ saveConfig();
76
+ if (mode === "off") {
77
+ // Native rendering mode: drop hover/click state and fully disable mouse
78
+ // reporting so the terminal restores its default scrollback wheel scrolling.
79
+ resetToolHoverState();
80
+ setHoveredToolGroup(null);
81
+ setHoveredToolIo(null, null);
82
+ // 惰性 Proxy(regular/fullscreen)不持有 reporting:regular 保终端回滚,
83
+ // fullscreen 归官方所有,均不能在此关闭。
84
+ if (toolMouseTui && !isLazyProxyTui(toolMouseTui)) {
85
+ toolMouseTui.terminal?.write?.(TOOL_MOUSE_DISABLE);
86
+ }
87
+ }
88
+ refreshCurrentTranscript(ctx, toolGrouping);
89
+ ctx.ui.notify(`Claude Code style: ${mode}`, "info");
90
+ }
91
+
92
+ function renderDefault(tool: any, slot: "renderCall" | "renderResult", args: any[], fallback = "") {
93
+ try {
94
+ if (typeof tool?.[slot] === "function") return tool[slot](...args);
95
+ } catch {
96
+ // Fall through to raw fallback.
97
+ }
98
+ return new Text(fallback, 0, 0);
99
+ }
100
+
101
+ function singleLine(text: string) {
102
+ return {
103
+ render: (width: number) => [truncateToWidth(text, width, "…")],
104
+ invalidate() {},
105
+ };
106
+ }
107
+
108
+ function insetComponent(component: any): any {
109
+ return {
110
+ render: (width: number) =>
111
+ component.render(Math.max(1, width - 1)).map((line: string) => {
112
+ const nestedMarker = line.replace(/^((?:\x1b\[[0-?]*[ -/]*[@-~])*)↳/, "$1 ↳");
113
+ return ` ${nestedMarker}`;
114
+ }),
115
+ invalidate: () => component.invalidate?.(),
116
+ };
117
+ }
118
+
119
+ function humanizeToolLabel(label: string): string {
120
+ return label
121
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
122
+ .replace(/[_-]+/g, " ")
123
+ .replace(/\b\w/g, (char) => char.toUpperCase());
124
+ }
125
+
126
+ function singleToolCallSummary(
127
+ toolName: string,
128
+ label: string,
129
+ args: any,
130
+ ): { main: string; detail: string } {
131
+ const title = label === toolName ? humanizeToolLabel(label) : label;
132
+ if (!args || typeof args !== "object") return { main: title, detail: "" };
133
+ const name = toolName.toLowerCase();
134
+ const value = (fallback: string, ...keys: string[]) => {
135
+ const found = keys.map((key) => args[key]).find((item) => typeof item === "string" && item);
136
+ return `${title} ${oneLine(found || fallback, Infinity)}`;
137
+ };
138
+ if (AGENT_FAMILY_TOOL_NAMES.has(toolName) && args.agent_id) {
139
+ return { main: `${title} ${oneLine(args.agent_id, Infinity)}`, detail: "" };
140
+ }
141
+ // Agents still uses the ccstyle wrapper; Agent keeps its dedicated renderer.
142
+ if (name === "agents") {
143
+ return {
144
+ main: value("launch agents", "description", "prompt"),
145
+ detail: "",
146
+ };
147
+ }
148
+ if (name === "skill") return { main: value("run skill", "name"), detail: "" };
149
+ if (name === "enterplanmode" || name === "enter_plan_mode") {
150
+ return { main: `${title} enable read-only planning`, detail: "" };
151
+ }
152
+ if (name === "exitplanmode" || name === "exit_plan_mode") {
153
+ return { main: `${title} present plan`, detail: "" };
154
+ }
155
+ if (name === "taskcreate") return { main: value("create task", "subject"), detail: "" };
156
+ if (name === "tasklist") return { main: `${title} task list`, detail: "" };
157
+ if (name === "taskget" || name === "taskupdate") {
158
+ return { main: value("task", "taskId", "task_id"), detail: "" };
159
+ }
160
+ if (name === "taskoutput" || name === "taskstop") {
161
+ return { main: value("background task", "task_id", "taskId"), detail: "" };
162
+ }
163
+ if (name === "taskexecute") {
164
+ const ids = Array.isArray(args.task_ids)
165
+ ? args.task_ids
166
+ : Array.isArray(args.taskIds)
167
+ ? args.taskIds
168
+ : [];
169
+ const summary = ids.length
170
+ ? `${ids[0]}${ids.length > 1 ? ` (+${ids.length - 1} tasks)` : ""}`
171
+ : "start tasks";
172
+ return { main: `${title} ${summary}`, detail: "" };
173
+ }
174
+ if (toolName === "read") {
175
+ const details = [
176
+ args.offset !== undefined ? `offset=${args.offset}` : "",
177
+ args.limit !== undefined ? `limit=${args.limit}` : "",
178
+ ].filter(Boolean);
179
+ return {
180
+ main: `${title}${args.path ? ` ${oneLine(args.path, Infinity)}` : ""}`,
181
+ detail: details.length ? ` (${details.join(", ")})` : "",
182
+ };
183
+ }
184
+ const preferred =
185
+ args.path ??
186
+ args.file_path ??
187
+ args.command ??
188
+ args.query ??
189
+ args.question ??
190
+ args.pattern ??
191
+ args.url ??
192
+ args.name ??
193
+ args.tool_use_id ??
194
+ args.toolCallId ??
195
+ args.id ??
196
+ args.message;
197
+ return {
198
+ main:
199
+ preferred !== undefined && preferred !== null && typeof preferred !== "object"
200
+ ? `${title} ${oneLine(preferred, Infinity)}`
201
+ : title,
202
+ detail: "",
203
+ };
204
+ }
205
+
206
+ export function shouldRenderRichDiff(
207
+ mode: CompactStyleMode,
208
+ toolName: string,
209
+ isError: boolean,
210
+ ): boolean {
211
+ return mode === "on" && !isError && (toolName === "edit" || toolName === "write");
212
+ }
213
+
214
+ type ParsedTask = { id: string; status: string; subject: string };
215
+
216
+ function parseTaskList(text: string): ParsedTask[] {
217
+ return text
218
+ .split("\n")
219
+ .map((line) => line.match(/^#(\d+) \[([^\]]+)] (.+)$/))
220
+ .filter((match): match is RegExpMatchArray => Boolean(match))
221
+ .map((match) => ({ id: match[1]!, status: match[2]!, subject: match[3]! }));
222
+ }
223
+
224
+ function taskListSummary(tasks: ParsedTask[]): string {
225
+ const counts = { pending: 0, in_progress: 0, completed: 0 };
226
+ for (const task of tasks) {
227
+ if (task.status in counts) counts[task.status as keyof typeof counts]++;
228
+ }
229
+ return [
230
+ `${tasks.length} tasks`,
231
+ counts.in_progress ? `${counts.in_progress} in progress` : "",
232
+ counts.pending ? `${counts.pending} pending` : "",
233
+ counts.completed ? `${counts.completed} completed` : "",
234
+ ]
235
+ .filter(Boolean)
236
+ .join(" • ");
237
+ }
238
+
239
+ function renderExpandedTaskResult(
240
+ toolName: string,
241
+ text: string,
242
+ theme: any,
243
+ isError: boolean,
244
+ ): any | undefined {
245
+ if (isError) return undefined;
246
+ if (toolName === "TaskList") {
247
+ const tasks = parseTaskList(text);
248
+ if (!tasks.length) return undefined;
249
+ const limit = Math.max(1, config.expandedPreviewMaxLines);
250
+ const rows = tasks.slice(0, limit).map((task) => {
251
+ const color =
252
+ task.status === "completed"
253
+ ? "success"
254
+ : task.status === "in_progress"
255
+ ? "warning"
256
+ : "muted";
257
+ return ` ${theme.fg("accent", `#${task.id}`)} ${theme.fg(color, task.status)} ${theme.fg("dim", task.subject)}`;
258
+ });
259
+ if (tasks.length > rows.length)
260
+ rows.push(theme.fg("muted", ` … ${tasks.length - rows.length} more tasks`));
261
+ return new Text(` ↳ ${theme.fg("muted", taskListSummary(tasks))}\n${rows.join("\n")}`, 0, 0);
262
+ }
263
+ const line = text.trim();
264
+ if (!line || line.includes("\n")) return undefined;
265
+ let formatted: string | undefined;
266
+ let match: RegExpMatchArray | null;
267
+ if (
268
+ toolName === "TaskCreate" &&
269
+ (match = line.match(/^Task #(\d+) created successfully: (.+)$/))
270
+ ) {
271
+ formatted = `${theme.fg("success", "Created task")} ${theme.fg("accent", `#${match[1]}`)} ${theme.fg("muted", match[2])}`;
272
+ } else if (toolName === "TaskUpdate" && (match = line.match(/^Updated task #(\d+) (.+)$/))) {
273
+ formatted = `${theme.fg("success", "Updated task")} ${theme.fg("accent", `#${match[1]}`)} ${theme.fg("muted", match[2])}`;
274
+ } else if (toolName === "TaskExecute") {
275
+ formatted = `${theme.fg("success", "Started")} ${theme.fg("muted", line)}`;
276
+ } else if (toolName === "TaskStop") {
277
+ formatted = `${theme.fg("success", "Stopped")} ${theme.fg("muted", line)}`;
278
+ }
279
+ return formatted ? new Text(` ↳ ${formatted}`, 0, 0) : undefined;
280
+ }
281
+
282
+ /** Wrap an arbitrary tool definition with ccstyle call/result rendering. */
283
+ function createCcstyleTool(
284
+ originalTool: any,
285
+ writeExecutionMetadata: WriteExecutionMetadataStore,
286
+ ): any {
287
+ const toolName = originalTool.name;
288
+ const label = isMcpToolDefinition(originalTool, toolName)
289
+ ? humanizeMcpToolName(toolName)
290
+ : originalTool.label || toolName;
291
+
292
+ return {
293
+ ...originalTool,
294
+ renderShell: "self",
295
+ renderCall(args: any, theme: any, context: any) {
296
+ if (config.mode !== "on") {
297
+ return renderDefault(originalTool, "renderCall", [args, theme, context], String(toolName));
298
+ }
299
+
300
+ const visualState = resolveToolVisualState(context);
301
+ const isPending =
302
+ visualState === "pending" ||
303
+ (!visualState && (context?.isPartial || context?.executionStarted));
304
+ if (isPending) scheduleAnimation(context);
305
+ const rawIcon = isPending ? pendingIcon(toolName) : settledIcon(toolName, visualState);
306
+ const icon =
307
+ visualState === "success"
308
+ ? `${BRIGHT_GREEN}${rawIcon}${ANSI_FG_RESET}`
309
+ : theme.fg(toolIconColor(context), rawIcon);
310
+ const summary = singleToolCallSummary(toolName, label, args);
311
+ let cachedWidth: number | undefined;
312
+ let cachedLine: string | undefined;
313
+ return {
314
+ render(width: number) {
315
+ if (cachedLine !== undefined && cachedWidth === width) return [cachedLine];
316
+ const viewportWidth = toolViewportWidth(width);
317
+ const callWidth = Math.max(0, viewportWidth - visibleWidth(icon) - 2);
318
+ const mainWidth = Math.max(0, callWidth - visibleWidth(summary.detail));
319
+ cachedWidth = width;
320
+ cachedLine = ` ${icon} ${theme.fg("toolTitle", middleTruncateToWidth(summary.main, mainWidth))}${theme.fg("dim", summary.detail)}`;
321
+ return [truncateToWidth(cachedLine, viewportWidth, "")];
322
+ },
323
+ invalidate() {},
324
+ };
325
+ },
326
+ renderResult(result: any, options: any, theme: any, context: any) {
327
+ if (config.mode !== "on") {
328
+ return renderDefault(
329
+ originalTool,
330
+ "renderResult",
331
+ [result, options, theme, context],
332
+ textFromResult(result),
333
+ );
334
+ }
335
+
336
+ if (options?.isPartial) {
337
+ return new Text(theme.fg("muted", " ↳ Pending…"), 0, 0);
338
+ }
339
+
340
+ const isError = options?.isError || context?.isError;
341
+ setToolVisualState(context, isError ? "error" : "success");
342
+ const expanded = isToolExpanded(options, context);
343
+ const toolCallId = context?.toolCallId;
344
+ if (shouldRenderRichDiff(config.mode, toolName, Boolean(isError))) {
345
+ // Pass getter so Diff indicator / wrap / limits update on the next paint
346
+ // without recreating the tool result component.
347
+ const richResult = renderRichToolResult(
348
+ toolName,
349
+ result,
350
+ {
351
+ ...options,
352
+ expanded,
353
+ // 全局共享状态避免 /reload 后旧 result renderer 闭包失联。
354
+ isHovered: () => isToolCallHovered(toolCallId),
355
+ },
356
+ theme,
357
+ context,
358
+ writeExecutionMetadata,
359
+ getToolDisplayConfig,
360
+ );
361
+ if (richResult) return insetComponent(richResult);
362
+ }
363
+
364
+ const text = textFromResult(result, expanded);
365
+ const args = context?.args;
366
+ if (expanded) {
367
+ const taskResult = renderExpandedTaskResult(toolName, text, theme, Boolean(isError));
368
+ if (taskResult) return taskResult;
369
+ }
370
+ const tasks = !isError && toolName === "TaskList" ? parseTaskList(text) : [];
371
+ const outputLines = outputLineCount(result) || countLines(text);
372
+ const lineWord = outputLines === 1 ? "line" : "lines";
373
+ const action = toolName === "read" ? "loaded" : "returned";
374
+ const rendered = tasks.length
375
+ ? taskListSummary(tasks)
376
+ : isError
377
+ ? text
378
+ ? oneLine(text)
379
+ : "Failed"
380
+ : outputLines
381
+ ? `${outputLines} ${lineWord} ${action}`
382
+ : "Done";
383
+ const expandable = !expanded && (tasks.length > 0 || hasExpandableDetail(text, args));
384
+ const hintText = showMoreHintText();
385
+ const hintPrefix = expandable ? theme.fg("dim", " • ") : "";
386
+ const hint = expandable ? hintPrefix + theme.fg("dim", hintText) : "";
387
+ const hoveredHint = expandable ? hintPrefix + theme.fg("text", hintText) : "";
388
+ if (expanded) {
389
+ return renderExpandedToolResult(
390
+ text || "",
391
+ theme,
392
+ Boolean(isError),
393
+ context?.lastComponent,
394
+ args,
395
+ context,
396
+ );
397
+ }
398
+ if (context?.state) context.state.ccstyleIoView = undefined;
399
+ let cachedWidth: number | undefined;
400
+ let cachedLine: string | undefined;
401
+ let cachedHoveredLine: string | undefined;
402
+ return {
403
+ render(width: number) {
404
+ if (cachedLine === undefined || cachedWidth !== width) {
405
+ cachedWidth = width;
406
+ cachedLine = theme.fg(
407
+ isError ? "error" : "muted",
408
+ renderCollapsedToolResultToWidth(rendered, hint, width),
409
+ );
410
+ cachedHoveredLine = theme.fg(
411
+ isError ? "error" : "muted",
412
+ renderCollapsedToolResultToWidth(rendered, hoveredHint, width),
413
+ );
414
+ }
415
+ return [isToolCallHovered(toolCallId) ? cachedHoveredLine! : cachedLine];
416
+ },
417
+ invalidate() {},
418
+ };
419
+ },
420
+ };
421
+ }
422
+
423
+ /**
424
+ * Apart from the write override used to capture pre-write content, renderers are
425
+ * applied through ToolExecutionComponent. Patch its lookup once so tools use the
426
+ * same ccstyle fallback shell by default. Tools named in excludeRenderers keep
427
+ * their original renderer.
428
+ */
429
+ const GLOBAL_TOOL_RENDER_PATCH = Symbol.for("pi.ccstyle.global-tool-render-patch");
430
+ const COMPONENT_TOOL_RENDER_MODE = Symbol.for("pi.ccstyle.component-tool-render-mode");
431
+ const COMPONENT_TOOL_SELF_SHELL_MODE = Symbol.for("pi.ccstyle.component-tool-self-shell-mode");
432
+ const AGENT_FAMILY_TOOL_NAMES = new Set([
433
+ "Agent",
434
+ "Agents",
435
+ "get_subagent_result",
436
+ "steer_subagent",
437
+ ]);
438
+ // pi-subagents 等扩展为 Agent 提供专用渲染器(displayName/运行统计),ccstyle 必须保留,不能 wrap。
439
+ const DEDICATED_RENDERER_TOOLS = new Set(["Agent"]);
440
+
441
+ type ToolRenderMethods = {
442
+ hasRendererDefinition: (...args: any[]) => boolean;
443
+ getRenderShell: (...args: any[]) => "default" | "self";
444
+ getCallRenderer: (...args: any[]) => any;
445
+ getResultRenderer: (...args: any[]) => any;
446
+ };
447
+
448
+ type GlobalToolRenderPatch = {
449
+ version: 2;
450
+ prototype: any;
451
+ owner: object;
452
+ active: boolean;
453
+ enabled: () => boolean;
454
+ mode: () => CompactStyleMode;
455
+ wrap: (tool: any) => any;
456
+ byDefinition: WeakMap<object, any>;
457
+ byName: Map<string, any>;
458
+ downstream: ToolRenderMethods;
459
+ installed: ToolRenderMethods;
460
+ // Keep the legacy aliases so an older extension build can read this Symbol.
461
+ originalHasRendererDefinition: ToolRenderMethods["hasRendererDefinition"];
462
+ originalGetRenderShell: ToolRenderMethods["getRenderShell"];
463
+ originalGetCallRenderer: ToolRenderMethods["getCallRenderer"];
464
+ originalGetResultRenderer: ToolRenderMethods["getResultRenderer"];
465
+ };
466
+
467
+ export function isMcpToolDefinition(definition: any, toolName: string): boolean {
468
+ const label = typeof definition?.label === "string" ? definition.label.trim() : "";
469
+ if (/^MCP(?::|$)/i.test(label)) return true;
470
+ if (toolName === "mcp" || /^mcp[_:-]|[_:-]mcp[_:-]/i.test(toolName)) return true;
471
+ if (label) return false;
472
+ const description = typeof definition?.description === "string" ? definition.description : "";
473
+ return /\bModel Context Protocol\b/i.test(description);
474
+ }
475
+
476
+ export function humanizeMcpToolName(toolName: string): string {
477
+ const words = toolName
478
+ .replace(/^mcp(?:[_:-]+)+/i, "")
479
+ .split(/[_:-]+/)
480
+ .filter(Boolean);
481
+ return words.length
482
+ ? words.map((word) => word[0]!.toUpperCase() + word.slice(1)).join(" ")
483
+ : "MCP";
484
+ }
485
+
486
+ /** Return true when this tool must keep its original renderer. */
487
+ export function preservesOriginalRenderer(
488
+ extensionDefinition: any,
489
+ toolName: string,
490
+ builtInToolDefinition?: any,
491
+ excludeRenderers: readonly string[] = config.excludeRenderers,
492
+ ): boolean {
493
+ if (!excludeRenderers.includes(toolName)) return false;
494
+ return [extensionDefinition, builtInToolDefinition].some(
495
+ (definition) =>
496
+ definition?.renderShell === "self" ||
497
+ typeof definition?.renderCall === "function" ||
498
+ typeof definition?.renderResult === "function",
499
+ );
500
+ }
501
+
502
+ function syncToolShell(component: any, shell: "default" | "self"): void {
503
+ const target = shell === "self" ? component.selfRenderContainer : component.contentBox;
504
+ if (!target || !Array.isArray(component.children)) return;
505
+ const candidates = new Set(
506
+ [component.contentText, component.contentBox, component.selfRenderContainer].filter(Boolean),
507
+ );
508
+ const indexes = component.children
509
+ .map((child: any, index: number) => (candidates.has(child) ? index : -1))
510
+ .filter((index: number) => index >= 0);
511
+ const targetIndex = indexes[0];
512
+ // During construction getRenderShell() runs immediately before Pi mounts the
513
+ // selected shell. Do not mount it here or the constructor will add it twice.
514
+ if (targetIndex === undefined) return;
515
+ component.children[targetIndex] = target;
516
+ for (const index of indexes.sort((left: number, right: number) => right - left)) {
517
+ if (index !== targetIndex) component.children.splice(index, 1);
518
+ }
519
+ }
520
+
521
+ function shouldGloballyStyleTool(component: any, patch: GlobalToolRenderPatch): boolean {
522
+ const extensionDefinition = component.toolDefinition;
523
+ const builtInDefinition = component.builtInToolDefinition;
524
+ const definition = extensionDefinition ?? builtInDefinition;
525
+ const toolName = String(component.toolName || definition?.name || "");
526
+ const useCcstyle =
527
+ patch.mode() === "on" &&
528
+ !DEDICATED_RENDERER_TOOLS.has(toolName) &&
529
+ !preservesOriginalRenderer(extensionDefinition, toolName, builtInDefinition);
530
+ component[COMPONENT_TOOL_RENDER_MODE] = useCcstyle;
531
+ return useCcstyle;
532
+ }
533
+
534
+ function shouldUseSelfShell(component: any, _patch: GlobalToolRenderPatch): boolean {
535
+ component[COMPONENT_TOOL_SELF_SHELL_MODE] = false;
536
+ return false;
537
+ }
538
+
539
+ function getGloballyStyledTool(component: any, patch: GlobalToolRenderPatch): any {
540
+ const definition = component.toolDefinition ?? component.builtInToolDefinition;
541
+ if (definition && typeof definition === "object") {
542
+ let wrapped = patch.byDefinition.get(definition);
543
+ if (!wrapped) {
544
+ wrapped = patch.wrap(definition);
545
+ patch.byDefinition.set(definition, wrapped);
546
+ }
547
+ return wrapped;
548
+ }
549
+
550
+ const name = String(component.toolName || "tool");
551
+ let wrapped = patch.byName.get(name);
552
+ if (!wrapped) {
553
+ wrapped = patch.wrap({ name, label: name });
554
+ patch.byName.set(name, wrapped);
555
+ }
556
+ return wrapped;
557
+ }
558
+
559
+ function prototypeToolRenderMethods(prototype: any): ToolRenderMethods {
560
+ return {
561
+ hasRendererDefinition: prototype.hasRendererDefinition,
562
+ getRenderShell: prototype.getRenderShell,
563
+ getCallRenderer: prototype.getCallRenderer,
564
+ getResultRenderer: prototype.getResultRenderer,
565
+ };
566
+ }
567
+
568
+ function isOwnershipAwarePatch(value: any): value is GlobalToolRenderPatch {
569
+ if (!value || value.version !== 2 || !value.installed || !value.downstream) return false;
570
+ return ["hasRendererDefinition", "getRenderShell", "getCallRenderer", "getResultRenderer"].every(
571
+ (name) =>
572
+ typeof value.installed[name] === "function" && typeof value.downstream[name] === "function",
573
+ );
574
+ }
575
+
576
+ function isLegacyInstalledWrapper(method: unknown, downstreamField: string): boolean {
577
+ if (typeof method !== "function") return false;
578
+ try {
579
+ const source = Function.prototype.toString.call(method);
580
+ return (
581
+ source.includes(downstreamField) &&
582
+ (source.includes("shouldGloballyStyleTool") ||
583
+ source.includes("shouldUseSelfShell") ||
584
+ source.includes("getGloballyStyledTool"))
585
+ );
586
+ } catch {
587
+ return false;
588
+ }
589
+ }
590
+
591
+ function downstreamForGlobalToolInstall(prototype: any, previous: any): ToolRenderMethods {
592
+ const current = prototypeToolRenderMethods(prototype);
593
+ if (!previous || previous.prototype !== prototype) return current;
594
+ if (isOwnershipAwarePatch(previous)) {
595
+ return {
596
+ hasRendererDefinition:
597
+ current.hasRendererDefinition === previous.installed.hasRendererDefinition
598
+ ? previous.downstream.hasRendererDefinition
599
+ : current.hasRendererDefinition,
600
+ getRenderShell:
601
+ current.getRenderShell === previous.installed.getRenderShell
602
+ ? previous.downstream.getRenderShell
603
+ : current.getRenderShell,
604
+ getCallRenderer:
605
+ current.getCallRenderer === previous.installed.getCallRenderer
606
+ ? previous.downstream.getCallRenderer
607
+ : current.getCallRenderer,
608
+ getResultRenderer:
609
+ current.getResultRenderer === previous.installed.getResultRenderer
610
+ ? previous.downstream.getResultRenderer
611
+ : current.getResultRenderer,
612
+ };
613
+ }
614
+
615
+ // Pre-v2 Symbol state did not retain wrapper references. Recognize its known
616
+ // wrappers when possible; otherwise preserve the current method as external.
617
+ const legacyDownstream = (method: Function, field: string): Function => {
618
+ const saved = previous[field];
619
+ return typeof saved === "function" && isLegacyInstalledWrapper(method, field) ? saved : method;
620
+ };
621
+ return {
622
+ hasRendererDefinition: legacyDownstream(
623
+ current.hasRendererDefinition,
624
+ "originalHasRendererDefinition",
625
+ ) as ToolRenderMethods["hasRendererDefinition"],
626
+ getRenderShell: legacyDownstream(
627
+ current.getRenderShell,
628
+ "originalGetRenderShell",
629
+ ) as ToolRenderMethods["getRenderShell"],
630
+ getCallRenderer: legacyDownstream(
631
+ current.getCallRenderer,
632
+ "originalGetCallRenderer",
633
+ ) as ToolRenderMethods["getCallRenderer"],
634
+ getResultRenderer: legacyDownstream(
635
+ current.getResultRenderer,
636
+ "originalGetResultRenderer",
637
+ ) as ToolRenderMethods["getResultRenderer"],
638
+ };
639
+ }
640
+
641
+ function disconnectGlobalToolRenderPatch(patch: any): void {
642
+ if (!patch || typeof patch !== "object") return;
643
+ patch.active = false;
644
+ patch.enabled = () => false;
645
+ patch.mode = () => "off";
646
+ patch.wrap = (tool: any) => tool;
647
+ patch.byDefinition = new WeakMap();
648
+ if (patch.byName && typeof patch.byName.clear === "function") patch.byName.clear();
649
+ else patch.byName = new Map();
650
+ }
651
+
652
+ function installGlobalToolRendering(
653
+ writeExecutionMetadata: WriteExecutionMetadataStore,
654
+ ): GlobalToolRenderPatch {
655
+ const prototype = (ToolExecutionComponent as any).prototype;
656
+ const host = globalThis as any;
657
+ const previous = host[GLOBAL_TOOL_RENDER_PATCH];
658
+ const downstream = downstreamForGlobalToolInstall(prototype, previous);
659
+ // Any wrapper retained by an external owner must become a callback-free
660
+ // pass-through before the new installation is linked above it.
661
+ disconnectGlobalToolRenderPatch(previous);
662
+
663
+ const patch: GlobalToolRenderPatch = {
664
+ version: 2,
665
+ prototype,
666
+ owner: {},
667
+ active: true,
668
+ enabled: () => config.mode === "on",
669
+ mode: () => config.mode,
670
+ wrap: (tool: any) => createCcstyleTool(tool, writeExecutionMetadata),
671
+ byDefinition: new WeakMap(),
672
+ byName: new Map(),
673
+ downstream,
674
+ installed: undefined as any,
675
+ originalHasRendererDefinition: downstream.hasRendererDefinition,
676
+ originalGetRenderShell: downstream.getRenderShell,
677
+ originalGetCallRenderer: downstream.getCallRenderer,
678
+ originalGetResultRenderer: downstream.getResultRenderer,
679
+ };
680
+
681
+ patch.installed = {
682
+ hasRendererDefinition: function (this: any, ...args: any[]) {
683
+ if (patch.active && shouldGloballyStyleTool(this, patch)) return true;
684
+ return patch.downstream.hasRendererDefinition.apply(this, args);
685
+ },
686
+ getRenderShell: function (this: any, ...args: any[]) {
687
+ if (!patch.active) return patch.downstream.getRenderShell.apply(this, args);
688
+ const useSelfShell = shouldUseSelfShell(this, patch);
689
+ const useCcstyle = shouldGloballyStyleTool(this, patch);
690
+ const shell =
691
+ useSelfShell || (useCcstyle && !this.expanded)
692
+ ? "self"
693
+ : useCcstyle
694
+ ? "default"
695
+ : patch.downstream.getRenderShell.apply(this, args);
696
+ syncToolShell(this, shell);
697
+ return shell;
698
+ },
699
+ getCallRenderer: function (this: any, ...args: any[]) {
700
+ if (patch.active && shouldGloballyStyleTool(this, patch)) {
701
+ return getGloballyStyledTool(this, patch).renderCall;
702
+ }
703
+ return patch.downstream.getCallRenderer.apply(this, args);
704
+ },
705
+ getResultRenderer: function (this: any, ...args: any[]) {
706
+ if (patch.active && shouldGloballyStyleTool(this, patch)) {
707
+ return getGloballyStyledTool(this, patch).renderResult;
708
+ }
709
+ return patch.downstream.getResultRenderer.apply(this, args);
710
+ },
711
+ };
712
+
713
+ prototype.hasRendererDefinition = patch.installed.hasRendererDefinition;
714
+ prototype.getRenderShell = patch.installed.getRenderShell;
715
+ prototype.getCallRenderer = patch.installed.getCallRenderer;
716
+ prototype.getResultRenderer = patch.installed.getResultRenderer;
717
+ host[GLOBAL_TOOL_RENDER_PATCH] = patch;
718
+ return patch;
719
+ }
720
+
721
+ function deactivateGlobalToolRendering(patch: GlobalToolRenderPatch): void {
722
+ if (!patch.active) return;
723
+ disconnectGlobalToolRenderPatch(patch);
724
+ const prototype = patch.prototype;
725
+ if (prototype.hasRendererDefinition === patch.installed.hasRendererDefinition) {
726
+ prototype.hasRendererDefinition = patch.downstream.hasRendererDefinition;
727
+ }
728
+ if (prototype.getRenderShell === patch.installed.getRenderShell) {
729
+ prototype.getRenderShell = patch.downstream.getRenderShell;
730
+ }
731
+ if (prototype.getCallRenderer === patch.installed.getCallRenderer) {
732
+ prototype.getCallRenderer = patch.downstream.getCallRenderer;
733
+ }
734
+ if (prototype.getResultRenderer === patch.installed.getResultRenderer) {
735
+ prototype.getResultRenderer = patch.downstream.getResultRenderer;
736
+ }
737
+ }
738
+
739
+ const GLOBAL_COMPACTION_RENDER_PATCH = Symbol.for("pi.ccstyle.compaction-render-patch");
740
+
741
+ type LegacyCompactionRenderPatch = {
742
+ enabled?: () => boolean;
743
+ };
744
+
745
+ /** Disable the pre-native compaction monkey patch left alive by /reload. */
746
+ function deactivateLegacyCompactionRendering() {
747
+ const patch = (globalThis as any)[GLOBAL_COMPACTION_RENDER_PATCH] as
748
+ | LegacyCompactionRenderPatch
749
+ | undefined;
750
+ if (patch) patch.enabled = () => false;
751
+ }
752
+
753
+ export default function (
754
+ pi: ExtensionAPI,
755
+ configOverride?: Partial<Config>,
756
+ compactThinking?: CompactThinkingController,
757
+ ) {
758
+ // The optional override keeps integration tests independent from the user's global config.
759
+ if (configOverride) setConfig(normalizeConfig({ ...config, ...configOverride }));
760
+ const writeExecutionMetadata = new WriteExecutionMetadataStore();
761
+ const mouseOwner = {};
762
+ let installation:
763
+ | {
764
+ globalToolRendering: GlobalToolRenderPatch;
765
+ toolGrouping: ToolGroupingHooks;
766
+ }
767
+ | undefined;
768
+ const ensureTuiInstallation = (ctx: any) => {
769
+ if (ctx?.mode !== "tui" || !ctx?.hasUI) return undefined;
770
+ // 渲染层(工具样式/分组)是原型与组件级 patch,fullscreen 官方布局
771
+ // 同样渲染这些组件,因此两种模式都安装。
772
+ if (installation) return installation;
773
+ const globalToolRendering = installGlobalToolRendering(writeExecutionMetadata);
774
+ const toolGrouping = installToolGrouping(() => config.mode === "on");
775
+ deactivateLegacyCompactionRendering();
776
+ installation = { globalToolRendering, toolGrouping };
777
+ return installation;
778
+ };
779
+
780
+ pi.registerCommand("ccstyle", {
781
+ description: "Configure Claude Code style and rich diff options",
782
+ getArgumentCompletions: (prefix) => {
783
+ const topLevel = [
784
+ { value: "on", label: "on", description: "Enable Claude Code style" },
785
+ { value: "off", label: "off", description: "Use Pi's native renderer" },
786
+ { value: "status", label: "status", description: "Show full configuration" },
787
+ { value: "panel", label: "panel", description: "Open interactive settings panel" },
788
+ ];
789
+ return topLevel.filter((item) => item.value.startsWith(prefix));
790
+ },
791
+ handler: async (args, ctx) => {
792
+ const arg = args.trim().toLowerCase();
793
+ if (!arg || arg === "panel" || arg === "on" || arg === "off") {
794
+ if (ctx?.mode !== "tui" || !ctx?.hasUI) {
795
+ ctx.ui?.notify?.("/ccstyle requires TUI mode", "warning");
796
+ return;
797
+ }
798
+ const hooks = ensureTuiInstallation(ctx);
799
+ if (!hooks) return;
800
+ if (!arg || arg === "panel") {
801
+ await showCcstylePanel(
802
+ ctx,
803
+ { applyStyleMode, refreshCurrentTranscript },
804
+ hooks.toolGrouping,
805
+ compactThinking,
806
+ );
807
+ } else {
808
+ applyStyleMode(arg, ctx, hooks.toolGrouping);
809
+ }
810
+ return;
811
+ }
812
+ if (arg === "status") {
813
+ ctx.ui.notify(`Claude Code style: ${formatConfigStatus(config)}`, "info");
814
+ return;
815
+ }
816
+ ctx.ui.notify("Usage: /ccstyle [on|off|status|panel]", "warning");
817
+ },
818
+ });
819
+
820
+ pi.on("session_start", async (event, ctx) => {
821
+ // 延迟到 session_start 注册 write override:加载阶段 getAllTools 不可用且其他扩展
822
+ // 尚未注册工具,无法检测外部 write 所有者(如 pi-spark),直接注册会与对方撞名。
823
+ // session_start 时所有扩展已加载完毕,installWriteOverride 内部会检测并让位。
824
+ installWriteOverride(pi, writeExecutionMetadata);
825
+ const hooks = ensureTuiInstallation(ctx);
826
+ // 鼠标交互独立于渲染层:fullscreen 渲染层让位(hooks undefined)但
827
+ // 工具点击/回到底部适配仍需安装;保持在渲染层安装之后以维持原顺序。
828
+ if (ctx?.mode === "tui" && ctx?.hasUI) installToolMouseInteraction(ctx, mouseOwner);
829
+ if (!hooks) return;
830
+ hooks.toolGrouping.setTheme(ctx.ui.theme);
831
+ ctx.ui.setStatus("ccstyle", undefined);
832
+ scheduleSessionRender(() => hooks.toolGrouping.refresh(toolMouseTui));
833
+ });
834
+
835
+ pi.on("session_compact", async (event, ctx) => {
836
+ const hooks = ensureTuiInstallation(ctx);
837
+ // Compaction rebuilds the transcript without session_start. Rebind after
838
+ // other TUI extensions may have replaced the root input dispatcher.
839
+ if (ctx?.mode === "tui" && ctx?.hasUI) installToolMouseInteraction(ctx, mouseOwner);
840
+ if (!hooks) return;
841
+ hooks.toolGrouping.setTheme(ctx.ui.theme);
842
+ scheduleSessionRender(() => hooks.toolGrouping.refresh(toolMouseTui));
843
+ });
844
+
845
+ pi.on("tool_execution_start", async (event, ctx) => {
846
+ installation?.toolGrouping.setTheme(ctx.ui.theme);
847
+ });
848
+
849
+ pi.on("session_shutdown", async (event, ctx) => {
850
+ writeExecutionMetadata.clear();
851
+ // 鼠标交互独立于渲染层:fullscreen 下 installation 为 undefined,
852
+ // 但 onTerminalInput 监听与 handleViewportInput 包装仍需释放。
853
+ teardownToolMouseInteraction(mouseOwner);
854
+ const current = installation;
855
+ if (
856
+ !current ||
857
+ (globalThis as any)[GLOBAL_TOOL_RENDER_PATCH] !== current.globalToolRendering ||
858
+ !current.globalToolRendering.active
859
+ )
860
+ return;
861
+ deactivateGlobalToolRendering(current.globalToolRendering);
862
+ current.toolGrouping.shutdown();
863
+ deactivateLegacyCompactionRendering();
864
+ clearAllAnimations();
865
+ installation = undefined;
866
+ });
867
+ }
868
+
869
+ // ---- 对外导出:保持与拆分前 claude-code-style.ts 完全一致的符号集合 ----
870
+ export {
871
+ DEFAULT_CONFIG,
872
+ formatConfigStatus,
873
+ getCompactThinkingConfig,
874
+ getToolDisplayConfig,
875
+ normalizeConfig,
876
+ } from "../config/config.ts";
877
+ export type { CompactStyleMode, Config } from "../config/config.ts";
878
+ export {
879
+ ExpandedToolIoView,
880
+ ExpandedToolResultText,
881
+ formatExpandHint,
882
+ formatToolInputArgs,
883
+ middleTruncateToWidth,
884
+ outputLineCount,
885
+ renderCollapsedToolResult,
886
+ renderCollapsedToolResultToWidth,
887
+ SHOW_MORE_LABEL,
888
+ } from "./tool-result.ts";
889
+ export type { ToolIoSection } from "./tool-result.ts";
890
+ export { installToolMouseInteraction } from "./mouse-interaction.ts";