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
@@ -1,3348 +0,0 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { getSettingsListTheme, ToolExecutionComponent } from "@earendil-works/pi-coding-agent";
3
- import { isFullscreenUi, isLazyProxyTui } from "./fullscreen-detect.ts";
4
- import type { CompactThinkingConfig, CompactThinkingController } from "./compact-thinking.ts";
5
- import { hasActiveTextPreview, showTextPreview } from "./context.ts";
6
- import {
7
- installToolGrouping,
8
- ToolGroupComponent,
9
- type ToolGroupingHooks,
10
- } from "./tool-grouping.ts";
11
- import { TOOL_LOADING_INTERVAL_MS, toolLoadingIcon } from "./tool-loading-icon.ts";
12
- import { sanitizeToolResultText } from "./tool-result-sanitize.ts";
13
- import {
14
- DEFAULT_TOOL_DISPLAY_CONFIG,
15
- installWriteOverride,
16
- renderRichToolResult,
17
- WriteExecutionMetadataStore,
18
- type DiffIndicatorMode,
19
- type DiffViewMode,
20
- type ToolDisplayConfig,
21
- } from "./tool-diff/index.ts";
22
- import {
23
- SettingsList,
24
- Text,
25
- getKeybindings,
26
- matchesKey,
27
- truncateToWidth,
28
- visibleWidth,
29
- wrapTextWithAnsi,
30
- } from "@earendil-works/pi-tui";
31
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
32
- import { homedir } from "node:os";
33
- import { join } from "node:path";
34
- import { inspect } from "node:util";
35
-
36
- /**
37
- * Claude Code Style for pi.
38
- *
39
- * This is the package's only entry point. Tool rendering (summaries, rich
40
- * edit/write diffs, expand/collapse) is applied via prototype-level patches.
41
- */
42
-
43
- export type CompactStyleMode = "on" | "off";
44
-
45
- export type Config = {
46
- mode: CompactStyleMode;
47
- excludeRenderers: string[];
48
- diffViewMode: DiffViewMode;
49
- diffIndicatorMode: DiffIndicatorMode;
50
- diffSplitMinWidth: number;
51
- diffCollapsedLines: number;
52
- diffWordWrap: boolean;
53
- expandedPreviewMaxLines: number;
54
- useSummaryTitlesAsThinkingTitle: boolean;
55
- previewLines: number;
56
- animationIntervalMs: number;
57
- };
58
-
59
- const AGENT_DIR = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
60
- const CONFIG_PATH = join(AGENT_DIR, "claude-code-style.json");
61
-
62
- const DIFF_VIEW_MODES: DiffViewMode[] = ["auto", "split", "unified"];
63
- const DIFF_INDICATOR_MODES: DiffIndicatorMode[] = ["bars", "classic", "none"];
64
- const DIFF_SPLIT_MIN_WIDTH_VALUES = ["80", "100", "120", "140", "160", "180"];
65
- const DIFF_COLLAPSED_LINES_VALUES = ["12", "24", "36", "48", "80", "120"];
66
- /** Presets for expanded body height — keep low options first so cycling stays TUI-friendly. */
67
- const EXPANDED_PREVIEW_MAX_LINES_VALUES = ["40", "60", "80", "120", "200", "500", "2000"];
68
- const THINKING_PREVIEW_LINES_VALUES = ["0", "1", "3", "5", "10"];
69
- const THINKING_ANIMATION_INTERVAL_VALUES = ["40", "60", "90", "120", "180"];
70
- /** Tools commonly toggled in excludeRenderers via the settings panel. */
71
- const EXCLUDE_RENDERER_CANDIDATES = [
72
- "bash",
73
- "read",
74
- "edit",
75
- "write",
76
- "grep",
77
- "find",
78
- "ls",
79
- "webfetch",
80
- "wait",
81
- ];
82
-
83
- export const DEFAULT_CONFIG: Config = {
84
- mode: "on",
85
- excludeRenderers: [],
86
- diffViewMode: DEFAULT_TOOL_DISPLAY_CONFIG.diffViewMode,
87
- diffIndicatorMode: DEFAULT_TOOL_DISPLAY_CONFIG.diffIndicatorMode,
88
- diffSplitMinWidth: DEFAULT_TOOL_DISPLAY_CONFIG.diffSplitMinWidth,
89
- diffCollapsedLines: DEFAULT_TOOL_DISPLAY_CONFIG.diffCollapsedLines,
90
- diffWordWrap: DEFAULT_TOOL_DISPLAY_CONFIG.diffWordWrap,
91
- expandedPreviewMaxLines: DEFAULT_TOOL_DISPLAY_CONFIG.expandedPreviewMaxLines,
92
- useSummaryTitlesAsThinkingTitle: true,
93
- previewLines: 3,
94
- animationIntervalMs: 90,
95
- };
96
-
97
- function pickEnum<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
98
- return typeof value === "string" && (allowed as readonly string[]).includes(value)
99
- ? (value as T)
100
- : fallback;
101
- }
102
-
103
- function pickPositiveInt(value: unknown, fallback: number, min = 1, max = 100_000): number {
104
- const n = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
105
- if (!Number.isFinite(n)) return fallback;
106
- return Math.min(max, Math.max(min, Math.floor(n)));
107
- }
108
-
109
- function pickPositiveNumber(value: unknown, fallback: number, min = 1): number {
110
- const n = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
111
- return Number.isFinite(n) ? Math.max(min, n) : fallback;
112
- }
113
-
114
- function nearestPreset(value: number, presets: readonly string[]): string {
115
- const numeric = presets.map((p) => Number(p));
116
- let best = presets[0] ?? String(value);
117
- let bestDist = Number.POSITIVE_INFINITY;
118
- for (let i = 0; i < numeric.length; i++) {
119
- const dist = Math.abs((numeric[i] ?? 0) - value);
120
- if (dist < bestDist) {
121
- bestDist = dist;
122
- best = presets[i] ?? best;
123
- }
124
- }
125
- // Prefer exact match when value is already a preset.
126
- const exact = presets.find((p) => Number(p) === value);
127
- return exact ?? best;
128
- }
129
-
130
- export function normalizeConfig(input: unknown): Config {
131
- const source = (input && typeof input === "object" ? input : {}) as Record<string, unknown>;
132
- const mode = source.mode;
133
- // mode=compact 已日落:旧配置回退到 on(Claude Code 风格)。
134
- const migratedMode: CompactStyleMode =
135
- mode === "on" || mode === "off"
136
- ? mode
137
- : typeof source.enabled === "boolean"
138
- ? source.enabled
139
- ? "on"
140
- : "off"
141
- : "on";
142
- const excludeRenderers = Array.isArray(source.excludeRenderers)
143
- ? [
144
- ...new Set(
145
- source.excludeRenderers.filter(
146
- (name): name is string => typeof name === "string" && name.length > 0,
147
- ),
148
- ),
149
- ]
150
- : [];
151
- return {
152
- mode: migratedMode,
153
- excludeRenderers,
154
- diffViewMode: pickEnum(source.diffViewMode, DIFF_VIEW_MODES, DEFAULT_CONFIG.diffViewMode),
155
- diffIndicatorMode: pickEnum(
156
- source.diffIndicatorMode,
157
- DIFF_INDICATOR_MODES,
158
- DEFAULT_CONFIG.diffIndicatorMode,
159
- ),
160
- diffSplitMinWidth: pickPositiveInt(
161
- source.diffSplitMinWidth,
162
- DEFAULT_CONFIG.diffSplitMinWidth,
163
- 40,
164
- 300,
165
- ),
166
- diffCollapsedLines: pickPositiveInt(
167
- source.diffCollapsedLines,
168
- DEFAULT_CONFIG.diffCollapsedLines,
169
- 1,
170
- 500,
171
- ),
172
- diffWordWrap: source.diffWordWrap !== false,
173
- expandedPreviewMaxLines: pickPositiveInt(
174
- source.expandedPreviewMaxLines,
175
- DEFAULT_CONFIG.expandedPreviewMaxLines,
176
- 10,
177
- 50_000,
178
- ),
179
- useSummaryTitlesAsThinkingTitle: source.useSummaryTitlesAsThinkingTitle !== false,
180
- previewLines: pickPositiveInt(
181
- source.previewLines,
182
- DEFAULT_CONFIG.previewLines,
183
- 0,
184
- Number.MAX_SAFE_INTEGER,
185
- ),
186
- animationIntervalMs: pickPositiveNumber(
187
- source.animationIntervalMs,
188
- DEFAULT_CONFIG.animationIntervalMs,
189
- ),
190
- };
191
- }
192
-
193
- export function getCompactThinkingConfig(source: Config = config): CompactThinkingConfig {
194
- return {
195
- useSummaryTitlesAsThinkingTitle: source.useSummaryTitlesAsThinkingTitle,
196
- previewLines: source.previewLines,
197
- animationIntervalMs: source.animationIntervalMs,
198
- };
199
- }
200
-
201
- export function getToolDisplayConfig(source: Config = config): ToolDisplayConfig {
202
- return {
203
- diffViewMode: source.diffViewMode,
204
- diffIndicatorMode: source.diffIndicatorMode,
205
- diffSplitMinWidth: source.diffSplitMinWidth,
206
- diffCollapsedLines: source.diffCollapsedLines,
207
- diffWordWrap: source.diffWordWrap,
208
- expandedPreviewMaxLines: source.expandedPreviewMaxLines,
209
- };
210
- }
211
-
212
- function formatExcludeRenderers(names: readonly string[]): string {
213
- return names.length === 0 ? "none" : names.join(", ");
214
- }
215
-
216
- export function formatConfigStatus(source: Config = config): string {
217
- return [
218
- `mode=${source.mode}`,
219
- `exclude=[${source.excludeRenderers.join(", ") || "none"}]`,
220
- `diffView=${source.diffViewMode}`,
221
- `diffIndicator=${source.diffIndicatorMode}`,
222
- `diffSplitMin=${source.diffSplitMinWidth}`,
223
- `diffCollapsed=${source.diffCollapsedLines}`,
224
- `diffWordWrap=${source.diffWordWrap ? "on" : "off"}`,
225
- `expandedMax=${source.expandedPreviewMaxLines}`,
226
- `thinkingTitle=${source.useSummaryTitlesAsThinkingTitle ? "summary" : "default"}`,
227
- `thinkingPreview=${source.previewLines}`,
228
- `thinkingAnimation=${source.animationIntervalMs}ms`,
229
- ].join(" · ");
230
- }
231
-
232
- let config: Config = loadConfig();
233
-
234
- function loadConfig(): Config {
235
- try {
236
- const source = existsSync(CONFIG_PATH)
237
- ? (JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as Record<string, unknown>)
238
- : {};
239
- const normalized = normalizeConfig(source);
240
- if (typeof source.enabled === "boolean" && source.mode !== "on" && source.mode !== "off") {
241
- try {
242
- writeFileSync(CONFIG_PATH, JSON.stringify(normalized, null, 2));
243
- } catch {
244
- // A read-only config still uses the migrated in-memory value.
245
- }
246
- }
247
- return normalized;
248
- } catch {
249
- // Ignore bad config and fall back to defaults.
250
- }
251
- return { ...DEFAULT_CONFIG };
252
- }
253
-
254
- function saveConfig() {
255
- writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
256
- }
257
-
258
- const TOOL_VIEWPORT_WIDTH_RATIO = 0.8;
259
-
260
- function toolViewportWidth(width: number): number {
261
- return Math.max(1, Math.floor(width * TOOL_VIEWPORT_WIDTH_RATIO));
262
- }
263
-
264
- function oneLine(value: unknown, max = 72): string {
265
- const text = String(value ?? "")
266
- .replace(/\s+/g, " ")
267
- .trim();
268
- return text.length > max ? `${text.slice(0, max - 1)}…` : text;
269
- }
270
-
271
- function rawTextFromResult(result: any): string {
272
- return Array.isArray(result?.content)
273
- ? result.content
274
- .filter((item: any) => item?.type === "text")
275
- .map((item: any) => String(item.text ?? ""))
276
- .join("\n")
277
- : "";
278
- }
279
-
280
- function detailsFromResult(result: any): string {
281
- if (result?.details === undefined) return "";
282
- const details =
283
- typeof result.details === "string"
284
- ? result.details
285
- : inspect(result.details, { depth: 8, breakLength: 100, compact: false });
286
- return sanitizeToolResultText(details, 16_384);
287
- }
288
-
289
- function textFromResult(result: any, expanded = false): string {
290
- // Compact previews only need short text; bound sanitize work.
291
- const content = sanitizeToolResultText(rawTextFromResult(result), 16_384);
292
- const details = detailsFromResult(result);
293
- if (!content) return details;
294
- if (!expanded || !details || details === content) return content;
295
- return `${content}\nDetails:\n${details}`;
296
- }
297
-
298
- export function outputLineCount(result: any): number {
299
- const text = rawTextFromResult(result).replace(/\r\n?/g, "\n").replace(/\n+$/, "");
300
- return text ? text.split("\n").length : 0;
301
- }
302
-
303
- function countLines(text: string): number {
304
- return text
305
- .trim()
306
- .split("\n")
307
- .filter((line) => line.trim().length > 0).length;
308
- }
309
-
310
- function hasExpandableResult(text: string): boolean {
311
- return countLines(text) > 1;
312
- }
313
-
314
- function toolIcon(_name: string): string {
315
- return "●";
316
- }
317
-
318
- const activeAnimationContexts = new Set<any>();
319
- let sharedAnimationTimer: ReturnType<typeof setTimeout> | null = null;
320
-
321
- function clearAnimation(context: any) {
322
- if (!context?.state?.ccstyleAnimationScheduled) return;
323
- context.state.ccstyleAnimationScheduled = false;
324
- activeAnimationContexts.delete(context);
325
- if (activeAnimationContexts.size === 0 && sharedAnimationTimer) {
326
- clearTimeout(sharedAnimationTimer);
327
- sharedAnimationTimer = null;
328
- }
329
- }
330
-
331
- function clearAllAnimations() {
332
- for (const ctx of activeAnimationContexts) {
333
- ctx.state.ccstyleAnimationScheduled = false;
334
- }
335
- activeAnimationContexts.clear();
336
- if (sharedAnimationTimer) {
337
- clearTimeout(sharedAnimationTimer);
338
- sharedAnimationTimer = null;
339
- }
340
- }
341
-
342
- function scheduleAnimation(context: any, intervalMs = TOOL_LOADING_INTERVAL_MS) {
343
- const state = (context.state ??= {});
344
- if (state.ccstyleAnimationScheduled) return;
345
- state.ccstyleAnimationScheduled = true;
346
- activeAnimationContexts.add(context);
347
- if (!sharedAnimationTimer) {
348
- sharedAnimationTimer = setTimeout(() => {
349
- sharedAnimationTimer = null;
350
- const contexts = Array.from(activeAnimationContexts);
351
- activeAnimationContexts.clear();
352
- for (const ctx of contexts) {
353
- ctx.state.ccstyleAnimationScheduled = false;
354
- ctx.invalidate?.();
355
- }
356
- }, intervalMs);
357
- }
358
- }
359
-
360
- function pendingIcon(_name: string): string {
361
- return toolLoadingIcon();
362
- }
363
-
364
- type ToolVisualState = "pending" | "success" | "error";
365
-
366
- function settledIcon(name: string, state: ToolVisualState | undefined): string {
367
- if (state === "success") return "✓";
368
- if (state === "error") return "✗";
369
- return toolIcon(name);
370
- }
371
-
372
- function setToolVisualState(context: any, visualState: ToolVisualState) {
373
- const state = (context.state ??= {});
374
- if (visualState !== "pending") clearAnimation(context);
375
- if (state.ccstyleToolVisualState === visualState) return;
376
- state.ccstyleToolVisualState = visualState;
377
- // Do not invalidate synchronously from renderResult. Pi is already rendering
378
- // this tool row; recursively scheduling another render here can retain both
379
- // the finalized result component and its previous secondary/partial component,
380
- // which displays the result summary twice. The current render pass also
381
- // refreshes renderCall, so the settled icon still updates immediately.
382
- }
383
-
384
- function getToolVisualState(context: any): ToolVisualState | undefined {
385
- return context?.state?.ccstyleToolVisualState as ToolVisualState | undefined;
386
- }
387
-
388
- function resolveToolVisualState(context: any): ToolVisualState | undefined {
389
- const visualState = getToolVisualState(context);
390
- if (visualState || context?.isPartial !== false) return visualState;
391
- const settledState: ToolVisualState = context?.isError ? "error" : "success";
392
- setToolVisualState(context, settledState);
393
- return settledState;
394
- }
395
-
396
- function toolIconColor(context: any): "accent" | "error" | "success" | "muted" {
397
- const visualState = getToolVisualState(context);
398
- if (context?.isError || visualState === "error") return "error";
399
- if (visualState === "success") return "success";
400
- if (context?.isPartial || context?.executionStarted || visualState === "pending") return "accent";
401
- return "muted";
402
- }
403
-
404
- function isToolExpanded(options: any, context: any): boolean {
405
- const local = context?.state?.ccstyleToolExpanded;
406
- return typeof local === "boolean" ? local : Boolean(options?.expanded ?? context?.expanded);
407
- }
408
-
409
- /** Keep the guide aligned when long result lines wrap at the viewport edge. */
410
- export class ExpandedToolResultText {
411
- private text: string;
412
- private prefix: string;
413
- private normalizedText: string;
414
- private cachedWidth: number | undefined;
415
- private cachedLines: string[] | undefined;
416
-
417
- constructor(text: string, prefix: string) {
418
- this.text = text;
419
- this.prefix = prefix;
420
- this.normalizedText = text.replace(/\t/g, " ").replace(/\n+$/, "");
421
- }
422
-
423
- setText(text: string): void {
424
- if (this.text === text) return;
425
- this.text = text;
426
- this.normalizedText = text.replace(/\t/g, " ").replace(/\n+$/, "");
427
- this.invalidate();
428
- }
429
-
430
- setPrefix(prefix: string): void {
431
- if (this.prefix === prefix) return;
432
- this.prefix = prefix;
433
- this.invalidate();
434
- }
435
-
436
- render(width: number): string[] {
437
- if (this.cachedLines !== undefined && this.cachedWidth === width) return this.cachedLines;
438
-
439
- const prefixWidth = visibleWidth(this.prefix);
440
- const contentWidth = Math.max(1, width - prefixWidth);
441
- const lines = wrapTextWithAnsi(this.normalizedText, contentWidth).map((line) =>
442
- truncateToWidth(this.prefix + line, width, ""),
443
- );
444
- this.cachedWidth = width;
445
- this.cachedLines = lines;
446
- return lines;
447
- }
448
-
449
- invalidate(): void {
450
- this.cachedWidth = undefined;
451
- this.cachedLines = undefined;
452
- }
453
- }
454
-
455
- /** Affordance next to truncated Input/Output headers — click opens full preview. */
456
- export const SHOW_MORE_LABEL = "[show more]";
457
-
458
- export type ToolIoSection = "input" | "output";
459
-
460
- // Module-local token: /reload creates a fresh token, so stale view instances are
461
- // replaced instead of retaining their old render implementation.
462
- const EXPANDED_TOOL_IO_VIEW_GENERATION = Symbol("ccstyle-expanded-tool-io-view");
463
-
464
- /**
465
- * Expanded tool body with clear Input / Output sections (Grok Build–style).
466
- *
467
- * Visual frame:
468
- * ├ Input [show more]
469
- * │ path: src/a.ts
470
- * │
471
- * └ Output [show more]
472
- * result line…
473
- *
474
- * Reused across re-renders via context.lastComponent when possible.
475
- */
476
- export class ExpandedToolIoView {
477
- readonly [EXPANDED_TOOL_IO_VIEW_GENERATION] = true;
478
- private inputBody: string;
479
- private outputBody: string;
480
- private isError: boolean;
481
- private theme: any;
482
- private maxOutputLines: number;
483
- private maxInputLines: number;
484
- private cachedWidth: number | undefined;
485
- private cachedLines: string[] | undefined;
486
- private hoveredSection: ToolIoSection | null = null;
487
- /** Which sections currently show the [show more] affordance (after last render). */
488
- private truncated: { input: boolean; output: boolean } = { input: false, output: false };
489
- /** 0-based header line indexes that carry [show more] after last render. */
490
- private showMoreHeaderRows: { input?: number; output?: number } = {};
491
-
492
- constructor(
493
- theme: any,
494
- inputBody: string,
495
- outputBody: string,
496
- isError: boolean,
497
- maxOutputLines = config.expandedPreviewMaxLines,
498
- maxInputLines = config.expandedPreviewMaxLines,
499
- ) {
500
- this.theme = theme;
501
- this.inputBody = inputBody;
502
- this.outputBody = outputBody;
503
- this.isError = isError;
504
- this.maxOutputLines = Math.max(1, maxOutputLines);
505
- this.maxInputLines = Math.max(1, maxInputLines);
506
- }
507
-
508
- setContent(
509
- inputBody: string,
510
- outputBody: string,
511
- isError: boolean,
512
- maxOutputLines?: number,
513
- maxInputLines?: number,
514
- ): void {
515
- const nextOut =
516
- maxOutputLines !== undefined ? Math.max(1, maxOutputLines) : this.maxOutputLines;
517
- const nextIn = maxInputLines !== undefined ? Math.max(1, maxInputLines) : this.maxInputLines;
518
- if (
519
- this.inputBody === inputBody &&
520
- this.outputBody === outputBody &&
521
- this.isError === isError &&
522
- this.maxOutputLines === nextOut &&
523
- this.maxInputLines === nextIn
524
- ) {
525
- return;
526
- }
527
- this.inputBody = inputBody;
528
- this.outputBody = outputBody;
529
- this.isError = isError;
530
- this.maxOutputLines = nextOut;
531
- this.maxInputLines = nextIn;
532
- this.invalidate();
533
- }
534
-
535
- getInputBody(): string {
536
- return this.inputBody;
537
- }
538
-
539
- getOutputBody(): string {
540
- return this.outputBody.trim() ? this.outputBody : "Done";
541
- }
542
-
543
- setHoveredSection(section: ToolIoSection | null): void {
544
- if (this.hoveredSection === section) return;
545
- this.hoveredSection = section;
546
- this.invalidate();
547
- }
548
-
549
- /** True when the plain header line is a truncated section with [show more]. */
550
- matchShowMoreLine(plainLine: string): ToolIoSection | null {
551
- const line = plainLine.replace(/\x1b\[[0-9;]*m/g, "");
552
- if (!line.includes(SHOW_MORE_LABEL)) return null;
553
- if (/\bInput\b/.test(line) && this.truncated.input) return "input";
554
- if (/\bOutput\b/.test(line) && this.truncated.output) return "output";
555
- return null;
556
- }
557
-
558
- /** Precise header rows marked for show-more hit testing (last render). */
559
- showMoreHeaderLineIndexes(): ReadonlyArray<{ section: ToolIoSection; line: number }> {
560
- const out: Array<{ section: ToolIoSection; line: number }> = [];
561
- if (this.showMoreHeaderRows.input !== undefined) {
562
- out.push({ section: "input", line: this.showMoreHeaderRows.input });
563
- }
564
- if (this.showMoreHeaderRows.output !== undefined) {
565
- out.push({ section: "output", line: this.showMoreHeaderRows.output });
566
- }
567
- return out;
568
- }
569
-
570
- /** Column range (1-based, visible cells) of [show more] on a rendered header, if present. */
571
- showMoreHitbox(plainLine: string): { startCol: number; endCol: number } | null {
572
- const line = plainLine.replace(/\x1b\[[0-9;]*m/g, "");
573
- const idx = line.indexOf(SHOW_MORE_LABEL);
574
- if (idx < 0) return null;
575
- const before = line.slice(0, idx);
576
- const startCol = visibleWidth(before) + 1;
577
- const endCol = startCol + visibleWidth(SHOW_MORE_LABEL) - 1;
578
- return { startCol, endCol };
579
- }
580
-
581
- render(width: number): string[] {
582
- if (this.cachedLines !== undefined && this.cachedWidth === width) {
583
- return withIoViewMarkers(this, this.cachedLines);
584
- }
585
-
586
- const theme = this.theme;
587
- const safeWidth = Math.max(1, Math.floor(width));
588
- const rail = " │ ";
589
- const railWidth = visibleWidth(rail);
590
- const bodyWidth = toolViewportWidth(safeWidth);
591
- const contentWidth = Math.max(1, bodyWidth - railWidth);
592
- const bodyColor = this.isError ? "error" : "toolOutput";
593
- const lines: string[] = [];
594
- this.truncated = { input: false, output: false };
595
- this.showMoreHeaderRows = {};
596
-
597
- const pushHeader = (
598
- corner: "├" | "└",
599
- label: string,
600
- section: ToolIoSection,
601
- showMore: boolean,
602
- ) => {
603
- const mark = theme.fg("dim", ` ${corner} `);
604
- const title = theme.fg(
605
- "accent",
606
- typeof theme.bold === "function" ? theme.bold(label) : label,
607
- );
608
- const more = showMore
609
- ? theme.fg(this.hoveredSection === section ? "text" : "dim", ` ${SHOW_MORE_LABEL}`)
610
- : "";
611
- if (showMore) this.showMoreHeaderRows[section] = lines.length;
612
- lines.push(truncateToWidth(mark + title + more, safeWidth, ""));
613
- };
614
-
615
- const pushRailLine = (styledContent: string, continued = true) => {
616
- const prefix = continued ? rail : " ";
617
- lines.push(truncateToWidth(theme.fg("dim", prefix) + styledContent, safeWidth, ""));
618
- };
619
-
620
- const pushBlankRail = () => {
621
- lines.push(truncateToWidth(theme.fg("dim", " │"), safeWidth, ""));
622
- };
623
-
624
- /** Style `key: value` input rows — dim keys, readable values. */
625
- const styleInputLine = (rawLine: string): string => {
626
- const match = rawLine.match(/^([A-Za-z_][\w.-]*)(:\s*)(.*)$/);
627
- if (!match) return theme.fg("muted", rawLine);
628
- const [, key, sep, rest] = match;
629
- return theme.fg("dim", key + sep) + theme.fg("muted", rest ?? "");
630
- };
631
-
632
- const pushBody = (
633
- body: string,
634
- opts: { input?: boolean; limit: number; continued?: boolean },
635
- ): boolean /* truncated */ => {
636
- const raw = body.replace(/\t/g, " ").replace(/\n+$/, "");
637
- if (!raw.trim()) {
638
- pushRailLine(theme.fg("dim", "(empty)"), opts.continued);
639
- return false;
640
- }
641
- const sourceLines = raw.split("\n");
642
- const wrapped: string[] = [];
643
- for (const source of sourceLines) {
644
- const styled = opts.input ? styleInputLine(source) : theme.fg(bodyColor, source);
645
- const parts = wrapTextWithAnsi(styled, contentWidth);
646
- if (parts.length === 0) wrapped.push(styled);
647
- else wrapped.push(...parts);
648
- }
649
- // Prefer source-line count so plain multi-line dumps always cap, even when
650
- // theme/wrap measurements disagree slightly.
651
- const truncated = wrapped.length > opts.limit || sourceLines.length > opts.limit;
652
- const visible = truncated ? wrapped.slice(0, Math.min(opts.limit, wrapped.length)) : wrapped;
653
- for (const line of visible) pushRailLine(line, opts.continued);
654
- if (truncated) {
655
- const hidden = Math.max(0, wrapped.length - visible.length);
656
- if (hidden > 0) {
657
- pushRailLine(theme.fg("dim", `… +${hidden} more lines`), opts.continued);
658
- }
659
- }
660
- return truncated;
661
- };
662
-
663
- const hasInput = this.inputBody.trim().length > 0;
664
- const outputText = this.getOutputBody();
665
-
666
- // Decide [show more] from the same truncation rules as pushBody.
667
- const inputWouldTruncate =
668
- hasInput &&
669
- bodyExceedsLineLimit(this.inputBody, this.maxInputLines, contentWidth, true, theme);
670
- const outputWouldTruncate = bodyExceedsLineLimit(
671
- outputText,
672
- this.maxOutputLines,
673
- contentWidth,
674
- false,
675
- theme,
676
- bodyColor,
677
- );
678
-
679
- if (hasInput) {
680
- this.truncated.input = inputWouldTruncate;
681
- pushHeader("├", "Input", "input", inputWouldTruncate);
682
- pushBody(this.inputBody, {
683
- input: true,
684
- limit: this.maxInputLines,
685
- continued: true,
686
- });
687
- pushBlankRail();
688
- this.truncated.output = outputWouldTruncate;
689
- pushHeader("└", "Output", "output", outputWouldTruncate);
690
- pushBody(outputText, { limit: this.maxOutputLines, continued: false });
691
- } else {
692
- this.truncated.output = outputWouldTruncate;
693
- pushHeader("└", "Output", "output", outputWouldTruncate);
694
- pushBody(outputText, { limit: this.maxOutputLines, continued: false });
695
- }
696
-
697
- this.cachedWidth = width;
698
- this.cachedLines = lines;
699
- return withIoViewMarkers(this, lines);
700
- }
701
-
702
- invalidate(): void {
703
- this.cachedWidth = undefined;
704
- this.cachedLines = undefined;
705
- }
706
- }
707
-
708
- function isExpandedToolIoView(value: unknown): value is ExpandedToolIoView {
709
- return Boolean(
710
- value &&
711
- typeof value === "object" &&
712
- (value as ExpandedToolIoView)[EXPANDED_TOOL_IO_VIEW_GENERATION] === true &&
713
- typeof (value as ExpandedToolIoView).getInputBody === "function" &&
714
- typeof (value as ExpandedToolIoView).getOutputBody === "function" &&
715
- typeof (value as ExpandedToolIoView).setHoveredSection === "function" &&
716
- typeof (value as ExpandedToolIoView).render === "function",
717
- );
718
- }
719
-
720
- /** True when body needs truncation at the given line limit (source lines or wrapped rows). */
721
- function bodyExceedsLineLimit(
722
- body: string,
723
- limit: number,
724
- contentWidth: number,
725
- asInput: boolean,
726
- theme: any,
727
- bodyColor = "toolOutput",
728
- ): boolean {
729
- const raw = body.replace(/\t/g, " ").replace(/\n+$/, "");
730
- if (!raw.trim()) return false;
731
- const sourceLines = raw.split("\n");
732
- if (sourceLines.length > limit) return true;
733
- let total = 0;
734
- for (const source of sourceLines) {
735
- let styled: string;
736
- if (asInput) {
737
- const match = source.match(/^([A-Za-z_][\w.-]*)(:\s*)(.*)$/);
738
- styled = match
739
- ? theme.fg("dim", match[1] + match[2]) + theme.fg("muted", match[3] ?? "")
740
- : theme.fg("muted", source);
741
- } else {
742
- styled = theme.fg(bodyColor, source);
743
- }
744
- const parts = wrapTextWithAnsi(styled, contentWidth);
745
- total += Math.max(1, parts.length);
746
- if (total > limit) return true;
747
- }
748
- return false;
749
- }
750
-
751
- export function renderCollapsedToolResult(body: string, collapsedHint = ""): string {
752
- return ` ↳ ${body}${collapsedHint}`;
753
- }
754
-
755
- export function renderCollapsedToolResultToWidth(
756
- body: string,
757
- collapsedHint: string,
758
- width: number,
759
- prefix = " ↳ ",
760
- ): string {
761
- const previewWidth = toolViewportWidth(width);
762
- const bodyWidth = Math.max(1, previewWidth - visibleWidth(prefix) - visibleWidth(collapsedHint));
763
- return truncateToWidth(
764
- prefix + middleTruncateToWidth(body, bodyWidth) + collapsedHint,
765
- previewWidth,
766
- "",
767
- );
768
- }
769
-
770
- /** Pretty-print tool call args for the expanded Input section. */
771
- export function formatToolInputArgs(args: unknown, maxChars = 8_000): string {
772
- if (args === undefined || args === null) return "";
773
- if (typeof args !== "object") {
774
- const text = String(args);
775
- return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text;
776
- }
777
- if (Array.isArray(args)) {
778
- try {
779
- const json = JSON.stringify(args, null, 2);
780
- return json.length > maxChars ? `${json.slice(0, maxChars)}…` : json;
781
- } catch {
782
- return String(args);
783
- }
784
- }
785
-
786
- const entries = Object.entries(args as Record<string, unknown>).filter(
787
- ([, value]) => value !== undefined,
788
- );
789
- if (entries.length === 0) return "";
790
-
791
- // Stable, human-first field order for common tools.
792
- const preferred = [
793
- "path",
794
- "file_path",
795
- "command",
796
- "query",
797
- "pattern",
798
- "url",
799
- "name",
800
- "message",
801
- "content",
802
- "old_string",
803
- "new_string",
804
- ];
805
- entries.sort(([left], [right]) => {
806
- const li = preferred.indexOf(left);
807
- const ri = preferred.indexOf(right);
808
- if (li === -1 && ri === -1) return left.localeCompare(right);
809
- if (li === -1) return 1;
810
- if (ri === -1) return -1;
811
- return li - ri;
812
- });
813
-
814
- const lines: string[] = [];
815
- for (const [key, value] of entries) {
816
- if (typeof value === "string") {
817
- if (value.includes("\n")) {
818
- lines.push(`${key}:`);
819
- for (const line of value.replace(/\t/g, " ").split("\n")) {
820
- lines.push(` ${line}`);
821
- }
822
- } else {
823
- lines.push(`${key}: ${value}`);
824
- }
825
- continue;
826
- }
827
- if (typeof value === "number" || typeof value === "boolean" || value === null) {
828
- lines.push(`${key}: ${String(value)}`);
829
- continue;
830
- }
831
- try {
832
- const json = JSON.stringify(value, null, 2);
833
- if (json.includes("\n")) {
834
- lines.push(`${key}:`);
835
- for (const line of json.split("\n")) lines.push(` ${line}`);
836
- } else {
837
- lines.push(`${key}: ${json}`);
838
- }
839
- } catch {
840
- lines.push(`${key}: [unserializable]`);
841
- }
842
- }
843
- const text = lines.join("\n");
844
- return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text;
845
- }
846
-
847
- function hasExpandableDetail(outputText: string, args: unknown): boolean {
848
- if (hasExpandableResult(outputText)) return true;
849
- return formatToolInputArgs(args).trim().length > 0;
850
- }
851
-
852
- function renderExpandedToolResult(
853
- body: string,
854
- theme: any,
855
- isError: boolean,
856
- lastComponent?: unknown,
857
- args?: unknown,
858
- context?: any,
859
- ): ExpandedToolIoView | ExpandedToolResultText | Text {
860
- const inputBody = formatToolInputArgs(args);
861
- const outputBody = body;
862
- const maxLines = config.expandedPreviewMaxLines;
863
-
864
- // Prefer structured Input/Output when we have args or non-empty output.
865
- if (inputBody.trim() || outputBody.trim()) {
866
- let view: ExpandedToolIoView;
867
- if (isExpandedToolIoView(lastComponent)) {
868
- lastComponent.setContent(inputBody, outputBody, isError, maxLines, maxLines);
869
- view = lastComponent;
870
- } else {
871
- view = new ExpandedToolIoView(theme, inputBody, outputBody, isError, maxLines, maxLines);
872
- }
873
- if (context) rememberIoView(context, view);
874
- return view;
875
- }
876
-
877
- if (context?.state) context.state.ccstyleIoView = undefined;
878
- const color = isError ? "error" : "muted";
879
- return new Text(theme.fg(color, renderCollapsedToolResult("Done")), 0, 0);
880
- }
881
-
882
- export function formatExpandHint(lineCount: number): string {
883
- return ` (${lineCount} more line${lineCount === 1 ? "" : "s"} / click)`;
884
- }
885
-
886
- function expandHint(theme: any, lineCount: number, hovered = false): string {
887
- // Keep interaction guidance neutral; it should not inherit success/error
888
- // coloring from the tool result surrounding it.
889
- return theme.fg(hovered ? "text" : "muted", formatExpandHint(lineCount));
890
- }
891
-
892
- type SgrMousePacket = {
893
- code: number;
894
- col: number;
895
- row: number;
896
- final: "M" | "m";
897
- };
898
-
899
- type FrameToolRender = {
900
- component: any;
901
- lines: string[];
902
- contentBoxLines: number;
903
- };
904
-
905
- /** Final painted placement of one outermost tool/group row after parent layout. */
906
- type FrameToolPlacement = {
907
- component: any;
908
- componentRow: number;
909
- lineIndex: number;
910
- /** Marker-stripped final line text as painted after parent layout. */
911
- finalLine: string;
912
- view?: ExpandedToolIoView;
913
- section?: ToolIoSection;
914
- };
915
-
916
- type InteractionRegion = {
917
- kind: "collapsed-hint" | "expanded-card" | "show-more" | "scroll-bottom";
918
- row: number;
919
- startCol: number;
920
- endCol: number;
921
- component?: any;
922
- view?: ExpandedToolIoView;
923
- section?: ToolIoSection;
924
- };
925
-
926
- type InteractionFrame = { regions: InteractionRegion[] };
927
-
928
- /** Zero-width APC row marker (like pi CURSOR_MARKER); stripped before terminal output. */
929
- const TOOL_FRAME_MARKER_RE = /_cc:t(\d+):(\d+)/g;
930
- const TOOL_VIEW_MARKER_RE = /_cc:v(\d+):([io])/g;
931
- const toolFrameMarker = (id: number, row: number) => `_cc:t${id}:${row}`;
932
- const toolViewMarker = (id: number, section: ToolIoSection) =>
933
- `_cc:v${id}:${section === "input" ? "i" : "o"}`;
934
-
935
- /** Per-frame ExpandedToolIoView ids for unambiguous show-more hit testing. */
936
- type IoViewFrameState = {
937
- viewIds: Map<ExpandedToolIoView, number>;
938
- idToView: Map<number, ExpandedToolIoView>;
939
- nextId: number;
940
- };
941
- let activeIoViewFrame: IoViewFrameState | null = null;
942
-
943
- function frameViewId(view: ExpandedToolIoView): number | null {
944
- if (!activeIoViewFrame) return null;
945
- let id = activeIoViewFrame.viewIds.get(view);
946
- if (id === undefined) {
947
- id = activeIoViewFrame.nextId++;
948
- activeIoViewFrame.viewIds.set(view, id);
949
- activeIoViewFrame.idToView.set(id, view);
950
- }
951
- return id;
952
- }
953
-
954
- /** cachedLines stay clean; only the returned paint copy carries show-more view markers. */
955
- function withIoViewMarkers(view: ExpandedToolIoView, lines: string[]): string[] {
956
- const id = frameViewId(view);
957
- if (id === null) return lines;
958
- // Mark by exact header row from render — never scan body text for Input/Output labels.
959
- const marked = lines.slice();
960
- for (const { section, line } of view.showMoreHeaderLineIndexes()) {
961
- if (line < 0 || line >= marked.length) continue;
962
- marked[line] = `${marked[line]}${toolViewMarker(id, section)}`;
963
- }
964
- return marked;
965
- }
966
-
967
- const TOOL_MOUSE_WIDGET_KEY = "ccstyle-tool-mouse";
968
- const TOOL_MOUSE_MOTION_ENABLE = "\x1b[?1003h\x1b[?1006h";
969
- const TOOL_MOUSE_DISABLE = "\x1b[?1006l\x1b[?1003l\x1b[?1000l";
970
- const ZENTUI_PAGE_UP_INPUT = /^\x1b\[5;9(?::[12])?~$|^\x1b\[57421;9(?::[12])?u$|^\x1b\[1;6A$/;
971
- const ZENTUI_PAGE_DOWN_INPUT = /^\x1b\[6;9(?::[12])?~$|^\x1b\[57422;9(?::[12])?u$|^\x1b\[1;6B$/;
972
- const SCROLL_BOTTOM_SHORTCUT = "ctrl+end";
973
- let toolMouseTui: any = null;
974
- let toolMouseUi: any = null;
975
- let toolMouseInputUnsubscribe: (() => void) | null = null;
976
- let toolMouseRenderPatchTui: any = null;
977
- let toolMouseRenderPatchOriginal: ((...args: any[]) => any) | null = null;
978
- let toolMouseRenderPatchWrapper: ((...args: any[]) => any) | null = null;
979
- let toolMouseRenderPatchState: { active: boolean } | null = null;
980
- let toolMouseRawWrite: ((data: string) => unknown) | null = null;
981
- let scrollButtonVisible = false;
982
- let scrollButtonHovered = false;
983
- let scrollButtonWidget: any = null;
984
- let scrollButtonSyncScheduled = false;
985
- let sessionRenderTimer: ReturnType<typeof setTimeout> | null = null;
986
- let hoveredToolCallId: string | null = null;
987
- let hoveredToolGroup: ToolGroupComponent | null = null;
988
- let latestInteractionFrame: InteractionFrame = { regions: [] };
989
-
990
- function parseSgrMousePackets(data: string): SgrMousePacket[] | null {
991
- const pattern = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
992
- const packets: SgrMousePacket[] = [];
993
- let offset = 0;
994
-
995
- for (const match of data.matchAll(pattern)) {
996
- if (match.index !== offset) return null;
997
- offset = match.index + match[0].length;
998
- packets.push({
999
- code: Number(match[1]),
1000
- col: Number(match[2]),
1001
- row: Number(match[3]),
1002
- final: match[4] as "M" | "m",
1003
- });
1004
- }
1005
-
1006
- return packets.length > 0 && offset === data.length ? packets : null;
1007
- }
1008
-
1009
- function isSgrLeftPress(packet: SgrMousePacket): boolean {
1010
- const baseButton = packet.code & ~(4 | 8 | 16 | 32);
1011
- return packet.final === "M" && baseButton === 0 && (packet.code & 32) === 0;
1012
- }
1013
-
1014
- function stripTerminalSequencesPreservingLayout(value: string): string {
1015
- return value
1016
- .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
1017
- .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "");
1018
- }
1019
-
1020
- function stripTerminalSequences(value: string): string {
1021
- return stripTerminalSequencesPreservingLayout(value).replace(/\s+/g, " ").trim();
1022
- }
1023
-
1024
- function isToolExecutionComponent(value: any): boolean {
1025
- return Boolean(
1026
- value &&
1027
- typeof value === "object" &&
1028
- typeof value.toolCallId === "string" &&
1029
- typeof value.setExpanded === "function" &&
1030
- typeof value.render === "function",
1031
- );
1032
- }
1033
-
1034
- function collectToolComponents(component: any, tools: any[], seen = new Set<any>()): void {
1035
- if (!component || typeof component !== "object" || seen.has(component)) return;
1036
- seen.add(component);
1037
- if (isToolExecutionComponent(component)) {
1038
- tools.push(component);
1039
- return;
1040
- }
1041
- if (!Array.isArray(component.children)) return;
1042
- for (const child of component.children) collectToolComponents(child, tools, seen);
1043
- }
1044
-
1045
- function stripToolFrameMarkers(line: string): string {
1046
- return line.replace(TOOL_FRAME_MARKER_RE, "").replace(TOOL_VIEW_MARKER_RE, "");
1047
- }
1048
-
1049
- function extractToolFramePlacements(
1050
- lines: string[],
1051
- idToComponent: Map<number, any>,
1052
- idToView: Map<number, ExpandedToolIoView>,
1053
- ): { lines: string[]; placements: FrameToolPlacement[] } {
1054
- const placements: FrameToolPlacement[] = [];
1055
- const cleaned = lines.map((line, lineIndex) => {
1056
- const toolMatches = [...line.matchAll(TOOL_FRAME_MARKER_RE)];
1057
- const viewMatches = [...line.matchAll(TOOL_VIEW_MARKER_RE)];
1058
- const finalLine = stripToolFrameMarkers(line);
1059
- let view: ExpandedToolIoView | undefined;
1060
- let section: ToolIoSection | undefined;
1061
- for (const match of viewMatches) {
1062
- const candidate = idToView.get(Number(match[1]));
1063
- if (!candidate) continue;
1064
- view = candidate;
1065
- section = match[2] === "i" ? "input" : "output";
1066
- break;
1067
- }
1068
- for (const match of toolMatches) {
1069
- const component = idToComponent.get(Number(match[1]));
1070
- if (!component) continue;
1071
- placements.push({
1072
- component,
1073
- componentRow: Number(match[2]),
1074
- lineIndex,
1075
- finalLine,
1076
- view,
1077
- section,
1078
- });
1079
- }
1080
- return finalLine;
1081
- });
1082
- return { lines: cleaned, placements };
1083
- }
1084
-
1085
- /** Summary markers used by Pi and ccstyle; unlike the trailing hint, these survive truncation. */
1086
- const COLLAPSED_TOOL_SUMMARY = /^\s*(?:↳|└|⎿|●|✓|✗|…)/;
1087
-
1088
- function toolMouseInteractionActive(): boolean {
1089
- if (config.mode === "off") return false;
1090
- if (isLazyProxyTui(toolMouseTui)) return true;
1091
- return true;
1092
- }
1093
-
1094
- function formatShortcut(shortcut: string): string {
1095
- return shortcut
1096
- .split("+")
1097
- .map((part) =>
1098
- part.length <= 1 ? part.toUpperCase() : `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`,
1099
- )
1100
- .join("+");
1101
- }
1102
-
1103
- function isScrollBottomInput(data: string): boolean {
1104
- return matchesKey(data, SCROLL_BOTTOM_SHORTCUT);
1105
- }
1106
-
1107
- function wheelDirection(data: string): "up" | "down" | null {
1108
- const packets = parseSgrMousePackets(data);
1109
- for (const packet of packets ?? []) {
1110
- if (packet.final !== "M") continue;
1111
- const baseButton = packet.code & ~(4 | 8 | 16 | 32);
1112
- if (baseButton === 64) return "up";
1113
- if (baseButton === 65) return "down";
1114
- }
1115
- return null;
1116
- }
1117
-
1118
- function isScrollNavigationInput(data: string): boolean {
1119
- if (
1120
- matchesKey(data, "pageUp") ||
1121
- matchesKey(data, "pageDown") ||
1122
- ZENTUI_PAGE_UP_INPUT.test(data) ||
1123
- ZENTUI_PAGE_DOWN_INPUT.test(data) ||
1124
- // 官方 fullscreen viewport 的可滚动键(half-page/prompt/top/bottom)。
1125
- [
1126
- "tui.altScreen.pageUp",
1127
- "tui.altScreen.pageDown",
1128
- "tui.altScreen.halfPageUp",
1129
- "tui.altScreen.halfPageDown",
1130
- "tui.altScreen.previousPrompt",
1131
- "tui.altScreen.nextPrompt",
1132
- "tui.altScreen.top",
1133
- "tui.altScreen.bottom",
1134
- ].some((key) => getKeybindings().matches(data, key))
1135
- ) {
1136
- return true;
1137
- }
1138
- const packets = parseSgrMousePackets(data);
1139
- return Boolean(
1140
- packets?.some((packet) => {
1141
- const baseButton = packet.code & ~(4 | 8 | 16 | 32);
1142
- return packet.final === "M" && (baseButton === 64 || baseButton === 65);
1143
- }),
1144
- );
1145
- }
1146
-
1147
- function isAtTranscriptBottom(tui: any): boolean {
1148
- // 惰性 Proxy fullscreen:官方 viewport 以 isFollowingOutput 判定是否在底部。
1149
- if (fullscreenLazyTui(tui)) return isFullscreenAtBottom(tui);
1150
- return true;
1151
- }
1152
-
1153
- function hideScrollButton(tui: any): void {
1154
- const changed = scrollButtonVisible || scrollButtonHovered;
1155
- scrollButtonVisible = false;
1156
- scrollButtonHovered = false;
1157
- if (changed) tui.requestRender?.();
1158
- }
1159
-
1160
- function scheduleScrollButtonSync(tui: any, data: string): void {
1161
- if (
1162
- !fullscreenLazyTui(tui) ||
1163
- !toolMouseInteractionActive() ||
1164
- !isScrollNavigationInput(data) ||
1165
- scrollButtonSyncScheduled
1166
- )
1167
- return;
1168
- scrollButtonSyncScheduled = true;
1169
- const previousLines = tui.previousLines;
1170
- const check = (attempt: number) => {
1171
- scrollButtonSyncScheduled = false;
1172
- if (toolMouseTui !== tui) return;
1173
- // Pi renders on its own frame timer. Inspect the resulting viewport before
1174
- // showing the button so empty or non-scrollable transcripts never flash it.
1175
- const rendered = tui.previousLines !== previousLines;
1176
- // fullscreen 下 isFollowingOutput 是即时状态,无需等待官方帧渲染。
1177
- if (!rendered && attempt < 4 && !fullscreenLazyTui(tui)) {
1178
- scrollButtonSyncScheduled = true;
1179
- const timer = setTimeout(() => check(attempt + 1), 16);
1180
- if (typeof timer === "object" && timer !== null && "unref" in timer) {
1181
- (timer as { unref: () => void }).unref();
1182
- }
1183
- return;
1184
- }
1185
- const nextVisible = !isAtTranscriptBottom(tui);
1186
- if (nextVisible !== scrollButtonVisible) {
1187
- scrollButtonVisible = nextVisible;
1188
- tui.requestRender?.();
1189
- }
1190
- };
1191
- process.nextTick(() => check(0));
1192
- }
1193
-
1194
- function updateScrollButtonFromInput(tui: any, data: string): void {
1195
- if (!fullscreenLazyTui(tui) || !toolMouseInteractionActive()) return;
1196
- if (matchesKey(data, "enter") || matchesKey(data, "return")) hideScrollButton(tui);
1197
- }
1198
-
1199
- function renderComponentTree(component: any, width: number): string[] {
1200
- if (!component || typeof component !== "object") return [];
1201
- try {
1202
- const lines = component.render?.(width);
1203
- if (Array.isArray(lines) && lines.length > 0) return lines;
1204
- } catch {
1205
- // Fall through to children for hidden container renderers.
1206
- }
1207
- if (!Array.isArray(component.children)) return [];
1208
- return component.children.flatMap((child: any) => renderComponentTree(child, width));
1209
- }
1210
-
1211
- const ioViewInvalidators = new WeakMap<ExpandedToolIoView, () => void>();
1212
- let hoveredToolIoView: ExpandedToolIoView | null = null;
1213
- let hoveredToolIoSection: ToolIoSection | null = null;
1214
-
1215
- function rememberIoView(context: any, view: ExpandedToolIoView): void {
1216
- if (!context || typeof context !== "object") return;
1217
- if (typeof context.invalidate === "function") ioViewInvalidators.set(view, context.invalidate);
1218
- if (!context.state || typeof context.state !== "object") context.state = {};
1219
- context.state.ccstyleIoView = view;
1220
- }
1221
-
1222
- function collapsedHintHitbox(line: string): { startCol: number; endCol: number } | null {
1223
- const plain = stripTerminalSequencesPreservingLayout(line);
1224
- const match = /(\([^()\n]* \/ click\)|click to show more)(?=\)?\s*$)/.exec(plain);
1225
- if (!match?.[1]) return null;
1226
- const startCol = visibleWidth(plain.slice(0, match.index)) + 1;
1227
- return { startCol, endCol: startCol + visibleWidth(match[1]) - 1 };
1228
- }
1229
-
1230
- function interactionRegionAt(packet: SgrMousePacket): InteractionRegion | null {
1231
- const matches = latestInteractionFrame.regions.filter(
1232
- (region) =>
1233
- region.row === packet.row && packet.col >= region.startCol && packet.col <= region.endCol,
1234
- );
1235
- return (
1236
- matches.find((region) => region.kind === "show-more") ??
1237
- matches.find((region) => region.kind === "scroll-bottom") ??
1238
- matches.find((region) => region.kind === "collapsed-hint") ??
1239
- matches.find((region) => region.kind === "expanded-card") ??
1240
- null
1241
- );
1242
- }
1243
-
1244
- function tryOpenToolIoShowMore(region: InteractionRegion): boolean {
1245
- const ioView = region.view;
1246
- const section = region.section;
1247
- if (!ioView || !section) return false;
1248
- const ui = toolMouseUi;
1249
- if (!ui || typeof ui.custom !== "function") {
1250
- ui?.notify?.("Full preview requires TUI custom UI", "warning");
1251
- return true;
1252
- }
1253
- const title = section === "input" ? "Tool Input" : "Tool Output";
1254
- const content = section === "input" ? ioView.getInputBody() : ioView.getOutputBody();
1255
- void showTextPreview({ ui }, title, content || "(empty)");
1256
- return true;
1257
- }
1258
-
1259
- function setHoveredToolIo(view: ExpandedToolIoView | null, section: ToolIoSection | null): boolean {
1260
- // resultRendererComponent 可能是 Text/第三方 renderer;reload 后也可能残留旧实例。
1261
- const nextView = isExpandedToolIoView(view) ? view : null;
1262
- const nextSection = nextView ? section : null;
1263
- if (nextView === hoveredToolIoView && nextSection === hoveredToolIoSection) return false;
1264
- if (isExpandedToolIoView(hoveredToolIoView)) {
1265
- hoveredToolIoView.setHoveredSection(null);
1266
- ioViewInvalidators.get(hoveredToolIoView)?.();
1267
- }
1268
- hoveredToolIoView = nextView;
1269
- hoveredToolIoSection = nextSection;
1270
- if (nextView) {
1271
- nextView.setHoveredSection(nextSection);
1272
- ioViewInvalidators.get(nextView)?.();
1273
- }
1274
- return true;
1275
- }
1276
-
1277
- function setHoveredToolGroup(group: ToolGroupComponent | null): boolean {
1278
- if (group === hoveredToolGroup) return false;
1279
- hoveredToolGroup?.setHintHovered(false);
1280
- hoveredToolGroup = group;
1281
- group?.setHintHovered(true);
1282
- return true;
1283
- }
1284
-
1285
- function updateToolSummaryHover(tui: any, packet: SgrMousePacket): void {
1286
- if ((packet.code & 32) === 0 || packet.final !== "M") return;
1287
- const region = interactionRegionAt(packet);
1288
- const nextScrollButtonHovered = region?.kind === "scroll-bottom";
1289
- const scrollButtonChanged = nextScrollButtonHovered !== scrollButtonHovered;
1290
- scrollButtonHovered = nextScrollButtonHovered;
1291
- const component = region?.component;
1292
- const nextToolCallId = region?.kind === "collapsed-hint" ? (component?.toolCallId ?? null) : null;
1293
- const nextGroup = component instanceof ToolGroupComponent ? component : null;
1294
- const nextIoView = region?.kind === "show-more" ? (region.view ?? null) : null;
1295
- const nextIoSection = region?.kind === "show-more" ? (region.section ?? null) : null;
1296
- const changed = nextToolCallId !== hoveredToolCallId;
1297
- hoveredToolCallId = nextToolCallId;
1298
- if (
1299
- scrollButtonChanged ||
1300
- setHoveredToolIo(nextIoView, nextIoSection) ||
1301
- setHoveredToolGroup(nextGroup) ||
1302
- changed
1303
- )
1304
- tui.requestRender?.();
1305
- }
1306
-
1307
- function toggleToolAtMouseClick(tui: any, packet: SgrMousePacket): boolean {
1308
- const region = interactionRegionAt(packet);
1309
- if (!region) return false;
1310
- if (region.kind === "scroll-bottom") return false;
1311
- if (region.kind === "show-more") return tryOpenToolIoShowMore(region);
1312
- const component = region.component;
1313
- if (!component) return false;
1314
- const width = Math.max(1, Number(tui?.terminal?.columns) || 80);
1315
- if (region.kind === "expanded-card") {
1316
- component.setExpanded(false);
1317
- hoveredToolCallId = null;
1318
- setHoveredToolGroup(null);
1319
- setHoveredToolIo(null, null);
1320
- component.invalidate?.();
1321
- tui.requestRender?.();
1322
- return true;
1323
- }
1324
- component.setExpanded(true);
1325
- hoveredToolCallId = null;
1326
- setHoveredToolGroup(null);
1327
- setHoveredToolIo(null, null);
1328
- component.invalidate?.();
1329
- tui.requestRender?.();
1330
- return true;
1331
- }
1332
-
1333
- function renderScrollButton(width: number, theme: any): string[] {
1334
- if (!scrollButtonVisible || !fullscreenLazyTui(toolMouseTui)) return [];
1335
- const shortcut = formatShortcut(SCROLL_BOTTOM_SHORTCUT);
1336
- const label = theme.fg(
1337
- scrollButtonHovered ? "text" : "accent",
1338
- `[ ↓ Back to bottom · ${shortcut} ]`,
1339
- );
1340
- const leftPad = Math.max(0, Math.floor((width - visibleWidth(label)) / 2));
1341
- return [`${" ".repeat(leftPad)}${truncateToWidth(label, width, "…")}`];
1342
- }
1343
-
1344
- /** 惰性 Proxy 官方 fullscreen(TuiAltScreen)判定。 */
1345
- function fullscreenLazyTui(tui: any): boolean {
1346
- return isLazyProxyTui(tui) && tui.mode === "fullscreen";
1347
- }
1348
-
1349
- /** 官方 fullscreen:是否已跟随 transcript 底部(按钮隐藏条件)。 */
1350
- function isFullscreenAtBottom(tui: any): boolean {
1351
- const following = tui.isFollowingOutput ?? tui.getPrimaryScrollView?.()?.isFollowingEnd ?? true;
1352
- return Boolean(following);
1353
- }
1354
-
1355
- const FULLSCREEN_VIEWPORT_PATCH = Symbol("ccstyle.fullscreen-viewport-patch");
1356
- const FULLSCREEN_WHEEL_SCROLL_ORIGINAL = Symbol("ccstyle.fullscreen-wheel-scroll-original");
1357
- const FULLSCREEN_WHEEL_SCROLL_LINES = 3;
1358
-
1359
- /** 布局树点查询:返回 (x,y) 处最深含行 leaf box(屏幕行 → 组件局部行)。 */
1360
- function fullscreenLeafAt(
1361
- layout: any,
1362
- x: number,
1363
- y: number,
1364
- ): { box: any; localRow: number } | null {
1365
- const root = layout?.root;
1366
- if (!root) return null;
1367
- let best: { box: any; localRow: number } | null = null;
1368
- let bestDepth = -1;
1369
- const visit = (box: any, depth: number) => {
1370
- if (!box) return;
1371
- const clip = box.clip;
1372
- if (!clip || x < clip.x || x >= clip.x + clip.width || y < clip.y || y >= clip.y + clip.height)
1373
- return;
1374
- const isLeaf = !Array.isArray(box.children) || box.children.length === 0;
1375
- if (
1376
- isLeaf &&
1377
- y >= box.rect.y &&
1378
- y < box.rect.y + Math.max(1, box.rect.height) &&
1379
- depth > bestDepth
1380
- ) {
1381
- best = { box, localRow: Math.max(0, y - box.rect.y) };
1382
- bestDepth = depth;
1383
- }
1384
- for (const child of box.children ?? []) visit(child, depth + 1);
1385
- };
1386
- visit(root, 0);
1387
- return best;
1388
- }
1389
-
1390
- /** 点击列是否为官方滚动条列(放行官方拖动)。 */
1391
- function isScrollbarColumnAt(layout: any, x: number): boolean {
1392
- let hit = false;
1393
- const visit = (box: any) => {
1394
- if (hit || !box) return;
1395
- if (box.scrollView?.isScrollbarVisible && x === box.rect.x + box.rect.width - 1) {
1396
- hit = true;
1397
- return;
1398
- }
1399
- for (const child of box.children ?? []) visit(child);
1400
- };
1401
- visit(layout?.root);
1402
- return hit;
1403
- }
1404
-
1405
- /**
1406
- * 布局 leaf box 的组件通常是容器(documentContainer/dock 容器等),工具卡与
1407
- * widget 在其 children 内。按局部行遍历组件树,定位实际命中的子组件。
1408
- */
1409
- function componentAtLocalRow(
1410
- component: any,
1411
- localRow: number,
1412
- width: number,
1413
- ): { component: any; row: number } | null {
1414
- if (
1415
- isToolExecutionComponent(component) ||
1416
- component instanceof ToolGroupComponent ||
1417
- component === scrollButtonWidget
1418
- ) {
1419
- return { component, row: localRow };
1420
- }
1421
- if (!Array.isArray(component.children)) return null;
1422
- let offset = 0;
1423
- for (const child of component.children) {
1424
- let lines: string[] = [];
1425
- try {
1426
- const rendered = child.render?.(width);
1427
- if (Array.isArray(rendered)) lines = rendered.map((line) => String(line));
1428
- } catch {
1429
- lines = [];
1430
- }
1431
- if (localRow < offset + lines.length) {
1432
- return (
1433
- componentAtLocalRow(child, localRow - offset, width) ?? {
1434
- component: child,
1435
- row: localRow - offset,
1436
- }
1437
- );
1438
- }
1439
- offset += lines.length;
1440
- }
1441
- return null;
1442
- }
1443
-
1444
- /** fullscreen single-expand:group 作为整体,不继续递归其内部工具。 */
1445
- function collectFullscreenToolCards(component: any, out: any[], seen = new Set<any>()): void {
1446
- if (!component || typeof component !== "object" || seen.has(component)) return;
1447
- seen.add(component);
1448
- if (isToolExecutionComponent(component) || component instanceof ToolGroupComponent) {
1449
- out.push(component);
1450
- return;
1451
- }
1452
- if (!Array.isArray(component.children)) return;
1453
- for (const child of component.children) collectFullscreenToolCards(child, out, seen);
1454
- }
1455
-
1456
- /**
1457
- * 官方 fullscreen 工具卡点击:collapsed 整卡点击展开
1458
- * (有且仅保持一个展开:展开前收起其他工具卡),expanded 整卡二次点击收起,
1459
- * 截断头 [show more] 打开全量预览;回到底部按钮 scrollToBottom。
1460
- * 滚动条列、含 OSC8 链接行、非工具区域放行官方。
1461
- */
1462
- function handleFullscreenToolClick(tui: any, packet: SgrMousePacket): boolean {
1463
- const layout = tui.currentLayout;
1464
- if (!layout?.root) return false;
1465
- // 官方事件坐标 0-based;SGR packet 1-based。
1466
- const x = packet.col - 1;
1467
- const y = packet.row - 1;
1468
- if (isScrollbarColumnAt(layout, x)) return false;
1469
- const hit = fullscreenLeafAt(layout, x, y);
1470
- if (!hit) return false;
1471
- const width = Math.max(1, Number(tui.terminal?.columns) || 80);
1472
- // 布局树用 scroll 的 contentWidth 渲染内容(滚动条占用时 = width-1);
1473
- // 行号定位必须用同一宽度,否则换行差异导致组件行错位。
1474
- const contentWidth =
1475
- typeof hit.box.scrollView?.getContentWidth === "function"
1476
- ? Math.max(1, hit.box.scrollView.getContentWidth(width))
1477
- : width;
1478
- const target = componentAtLocalRow(hit.box.component, hit.localRow, contentWidth);
1479
- if (!target) return false;
1480
- const component = target.component;
1481
- // 回到底部按钮:按组件引用命中,不依赖渲染行缓存。
1482
- if (scrollButtonVisible && component === scrollButtonWidget) {
1483
- tui.scrollToBottom?.();
1484
- hideScrollButton(tui);
1485
- return true;
1486
- }
1487
- const line = hit.box.lines?.[hit.localRow];
1488
- if (typeof line !== "string" || /\x1b]8;[^;]*;/.test(line)) return false;
1489
- const isTool = isToolExecutionComponent(component);
1490
- const isGroup = component instanceof ToolGroupComponent;
1491
- if (!isTool && !isGroup) return false;
1492
- if (!component.expanded) {
1493
- // single-expand:展开前收起其他已展开工具卡/group。
1494
- const others: any[] = [];
1495
- collectFullscreenToolCards(hit.box.component, others);
1496
- for (const other of others) {
1497
- if (other !== component && other.expanded) {
1498
- other.setExpanded(false);
1499
- other.invalidate?.();
1500
- }
1501
- }
1502
- component.setExpanded(true);
1503
- } else {
1504
- // 普通工具截断头 [show more]:打开全量预览(不收起)。
1505
- const view = isTool ? component.resultRendererComponent : null;
1506
- if (isExpandedToolIoView(view)) {
1507
- const plain = stripTerminalSequencesPreservingLayout(line);
1508
- const section = view.matchShowMoreLine(plain);
1509
- if (section) {
1510
- const box = view.showMoreHitbox(plain);
1511
- if (box && x + 1 >= box.startCol && x + 1 <= box.endCol) {
1512
- return tryOpenToolIoShowMore({
1513
- kind: "show-more",
1514
- row: 0,
1515
- startCol: box.startCol,
1516
- endCol: box.endCol,
1517
- component,
1518
- view,
1519
- section,
1520
- });
1521
- }
1522
- }
1523
- }
1524
- // 整卡二次点击:收起(与展开语义对称)。
1525
- component.setExpanded(false);
1526
- }
1527
- // 点击后清 hover 高亮。
1528
- hoveredToolCallId = null;
1529
- setHoveredToolGroup(null);
1530
- setHoveredToolIo(null, null);
1531
- component.invalidate?.();
1532
- tui.requestRender?.();
1533
- return true;
1534
- }
1535
-
1536
- type ComponentRowHit = { component: any; row: number };
1537
-
1538
- /** hover 与点击共用组件定位;同一布局下按容器/宽度/行缓存,避免 motion 重复渲染。 */
1539
- let fullscreenHoverCacheLayout: unknown = null;
1540
- let fullscreenHoverComponentCache = new WeakMap<object, Map<string, ComponentRowHit | null>>();
1541
-
1542
- function cachedFullscreenComponentAtRow(
1543
- layout: any,
1544
- container: any,
1545
- row: number,
1546
- width: number,
1547
- ): ComponentRowHit | null {
1548
- if (!container || typeof container !== "object") return null;
1549
- if (fullscreenHoverCacheLayout !== layout) {
1550
- fullscreenHoverCacheLayout = layout;
1551
- fullscreenHoverComponentCache = new WeakMap();
1552
- }
1553
- let rows = fullscreenHoverComponentCache.get(container);
1554
- if (!rows) {
1555
- rows = new Map();
1556
- fullscreenHoverComponentCache.set(container, rows);
1557
- }
1558
- const key = `${width}:${row}`;
1559
- if (rows.has(key)) return rows.get(key) ?? null;
1560
- const hit = componentAtLocalRow(container, row, width);
1561
- rows.set(key, hit);
1562
- return hit;
1563
- }
1564
-
1565
- /** fullscreen 鼠标悬停目标。 */
1566
- type FullscreenHoverTarget =
1567
- | { kind: "button" }
1568
- | { kind: "group"; component: ToolGroupComponent }
1569
- | {
1570
- kind: "tool";
1571
- component: any;
1572
- view: ExpandedToolIoView | null;
1573
- section: ToolIoSection | null;
1574
- };
1575
-
1576
- /**
1577
- * fullscreen 悬停高亮:collapsed 卡 [click to show more] hint、
1578
- * expanded 卡截断头 [show more]、回到底部按钮。motion 不 consume,官方链照常。
1579
- */
1580
- function handleFullscreenToolHover(tui: any, packet: SgrMousePacket): void {
1581
- if (packet.final !== "M") return;
1582
- const layout = tui.currentLayout;
1583
- if (!layout?.root) return;
1584
- const x = packet.col - 1;
1585
- const y = packet.row - 1;
1586
- let target: FullscreenHoverTarget | null = null;
1587
- const hit = fullscreenLeafAt(layout, x, y);
1588
- if (hit) {
1589
- const line = hit.box.lines?.[hit.localRow];
1590
- // 回到底部按钮:渲染行文本 + 列区间识别(零组件树开销)。
1591
- if (typeof line === "string" && scrollButtonVisible && line.includes("[ ↓")) {
1592
- const plain = stripTerminalSequencesPreservingLayout(line);
1593
- const idx = plain.indexOf("[ ↓");
1594
- if (idx >= 0 && x >= idx && x <= idx + plain.length - 1) {
1595
- target = { kind: "button" };
1596
- }
1597
- } else if (typeof line === "string" && !/\x1b]8;/.test(line)) {
1598
- const width = Math.max(1, Number(tui.terminal?.columns) || 80);
1599
- const contentWidth =
1600
- typeof hit.box.scrollView?.getContentWidth === "function"
1601
- ? Math.max(1, hit.box.scrollView.getContentWidth(width))
1602
- : width;
1603
- // 与点击共用同一定位算法,避免 hover 自建行段与真实组件树错位。
1604
- const componentHit = cachedFullscreenComponentAtRow(
1605
- layout,
1606
- hit.box.component,
1607
- hit.localRow,
1608
- contentWidth,
1609
- );
1610
- const component = componentHit?.component;
1611
- const hintBox = collapsedHintHitbox(line);
1612
- const overHint = Boolean(
1613
- hintBox && packet.col >= hintBox.startCol && packet.col <= hintBox.endCol,
1614
- );
1615
- if (component instanceof ToolGroupComponent) {
1616
- if (overHint) target = { kind: "group", component };
1617
- } else if (isToolExecutionComponent(component)) {
1618
- let view: ExpandedToolIoView | null = null;
1619
- let section: ToolIoSection | null = null;
1620
- if (component.expanded) {
1621
- const resultView = component.resultRendererComponent;
1622
- if (isExpandedToolIoView(resultView)) {
1623
- view = resultView;
1624
- const plain = stripTerminalSequencesPreservingLayout(line);
1625
- const candidate = view.matchShowMoreLine(plain);
1626
- if (candidate) {
1627
- const box = view.showMoreHitbox(plain);
1628
- if (box && x + 1 >= box.startCol && x + 1 <= box.endCol) {
1629
- section = candidate;
1630
- }
1631
- }
1632
- }
1633
- target = { kind: "tool", component, view, section };
1634
- } else if (overHint) {
1635
- target = { kind: "tool", component, view, section };
1636
- }
1637
- }
1638
- }
1639
- }
1640
- applyFullscreenHover(tui, target);
1641
- }
1642
-
1643
- /** 悬停状态变化才触发渲染(motion 事件密集,状态不变跳过)。 */
1644
- function applyFullscreenHover(tui: any, target: FullscreenHoverTarget | null): void {
1645
- let changed = false;
1646
- const nextCallId =
1647
- target?.kind === "tool" && !target.component.expanded
1648
- ? (target.component.toolCallId ?? null)
1649
- : null;
1650
- if (nextCallId !== hoveredToolCallId) {
1651
- hoveredToolCallId = nextCallId;
1652
- changed = true;
1653
- }
1654
- const nextGroup = target?.kind === "group" ? target.component : null;
1655
- if (setHoveredToolGroup(nextGroup)) changed = true;
1656
- const nextView = target?.kind === "tool" ? target.view : null;
1657
- const nextSection = target?.kind === "tool" ? target.section : null;
1658
- if (setHoveredToolIo(nextView, nextSection)) changed = true;
1659
- const nextButton = target?.kind === "button";
1660
- if (nextButton !== scrollButtonHovered) {
1661
- scrollButtonHovered = nextButton;
1662
- changed = true;
1663
- }
1664
- if (changed) tui.requestRender?.();
1665
- }
1666
-
1667
- /**
1668
- * 实例级包装 TuiAltScreen.handleViewportInput(惰性 Proxy 安全):
1669
- * 原型方法取 original(绕开 proxy 函数包装),实例 own property 装 wrapper
1670
- * (constructor arrow 动态查找命中)。仅在 fullscreen 且无 overlay 时先消费
1671
- * 工具卡左键点击,其余全部放行官方 selection/scrollbar/URL/键盘链。
1672
- */
1673
- function patchFullscreenViewportInput(tui: any): void {
1674
- if (tui[FULLSCREEN_VIEWPORT_PATCH] || !isLazyProxyTui(tui)) return;
1675
- const proto = Object.getPrototypeOf(tui);
1676
- const original = proto?.handleViewportInput;
1677
- if (typeof original !== "function") return;
1678
- // 官方原生 routeWheel 已完整处理嵌套 ScrollView;只调整默认步进 1 → 3。
1679
- if (typeof tui.wheelScrollLines === "number") {
1680
- tui[FULLSCREEN_WHEEL_SCROLL_ORIGINAL] = tui.wheelScrollLines;
1681
- tui.wheelScrollLines = FULLSCREEN_WHEEL_SCROLL_LINES;
1682
- }
1683
- tui[FULLSCREEN_VIEWPORT_PATCH] = true;
1684
- tui.handleViewportInput = function (this: any, data: string) {
1685
- if (toolMouseInteractionActive() && tui.mode === "fullscreen") {
1686
- // 滚动输入(wheel/pageUp/end 等)后同步回到底部按钮显隐;
1687
- // 官方 viewport 会消费键盘,扩展监听器无法补偿,必须在这里调度。
1688
- scheduleScrollButtonSync(tui, data);
1689
- const packets = parseSgrMousePackets(data);
1690
- // 官方 fullscreen 会消费全部鼠标;文本预览 overlay 活动时放行给 focused
1691
- // custom component,使 [esc] 点击和滚轮可用。
1692
- if (packets && tui.hasOverlay?.() && hasActiveTextPreview()) return undefined;
1693
- if (packets && !tui.hasOverlay?.()) {
1694
- for (const packet of packets) {
1695
- if (isSgrLeftPress(packet) && handleFullscreenToolClick(tui, packet)) {
1696
- return { consume: true };
1697
- }
1698
- if ((packet.code & 32) !== 0 && packet.final === "M") {
1699
- handleFullscreenToolHover(tui, packet);
1700
- }
1701
- }
1702
- }
1703
- }
1704
- return Reflect.apply(original, this, [data]);
1705
- };
1706
- }
1707
-
1708
- function restoreFullscreenViewportInput(tui: any): void {
1709
- if (!tui || !tui[FULLSCREEN_VIEWPORT_PATCH]) return;
1710
- const proto = Object.getPrototypeOf(tui);
1711
- if (typeof proto?.handleViewportInput === "function") {
1712
- tui.handleViewportInput = proto.handleViewportInput;
1713
- }
1714
- const originalWheelScrollLines = tui[FULLSCREEN_WHEEL_SCROLL_ORIGINAL];
1715
- if (typeof originalWheelScrollLines === "number") {
1716
- tui.wheelScrollLines = originalWheelScrollLines;
1717
- tui[FULLSCREEN_WHEEL_SCROLL_ORIGINAL] = undefined;
1718
- }
1719
- tui[FULLSCREEN_VIEWPORT_PATCH] = false;
1720
- }
1721
-
1722
- function restoreToolMouseRenderPatch(): void {
1723
- if (toolMouseRenderPatchState) toolMouseRenderPatchState.active = false;
1724
- if (
1725
- toolMouseRenderPatchTui &&
1726
- toolMouseRenderPatchOriginal &&
1727
- toolMouseRenderPatchTui.doRender === toolMouseRenderPatchWrapper
1728
- ) {
1729
- toolMouseRenderPatchTui.doRender = toolMouseRenderPatchOriginal;
1730
- }
1731
- toolMouseRenderPatchTui = null;
1732
- toolMouseRenderPatchOriginal = null;
1733
- toolMouseRenderPatchWrapper = null;
1734
- toolMouseRenderPatchState = null;
1735
- toolMouseRawWrite = null;
1736
- latestInteractionFrame = { regions: [] };
1737
- }
1738
-
1739
- function buildInteractionFrame(
1740
- tui: any,
1741
- renderedTools: FrameToolRender[],
1742
- placements: FrameToolPlacement[],
1743
- ): InteractionFrame {
1744
- const width = Math.max(1, Number(tui?.terminal?.columns) || 80);
1745
- // native: full buffer; map with the post-doRender previousViewportTop.
1746
- const lineIndexToScreenRow = (lineIndex: number) =>
1747
- lineIndex - (Number(tui?.previousViewportTop) || 0) + 1;
1748
- const visibleRows = Math.max(1, Number(tui?.terminal?.rows) || Number.POSITIVE_INFINITY);
1749
- const regions: InteractionRegion[] = [];
1750
- const renderedByComponent = new Map<any, FrameToolRender>();
1751
- for (const rendered of renderedTools) renderedByComponent.set(rendered.component, rendered);
1752
- const placementsByComponent = new Map<any, FrameToolPlacement[]>();
1753
- for (const placement of placements) {
1754
- const list = placementsByComponent.get(placement.component) ?? [];
1755
- list.push(placement);
1756
- placementsByComponent.set(placement.component, list);
1757
- }
1758
- for (const [component, componentPlacements] of placementsByComponent) {
1759
- const rendered = renderedByComponent.get(component);
1760
- if (!rendered) continue;
1761
- for (const placement of componentPlacements) {
1762
- const finalRow = lineIndexToScreenRow(placement.lineIndex);
1763
- if (finalRow < 1 || finalRow > visibleRows) continue;
1764
- // Hit columns come from the final painted line (parent may prefix/transform).
1765
- const line = placement.finalLine;
1766
- if (!component.expanded) {
1767
- const box = collapsedHintHitbox(line);
1768
- if (box && COLLAPSED_TOOL_SUMMARY.test(stripTerminalSequences(line))) {
1769
- regions.push({ kind: "collapsed-hint", row: finalRow, ...box, component });
1770
- }
1771
- continue;
1772
- }
1773
- if (placement.view && placement.section) {
1774
- const plain = stripTerminalSequencesPreservingLayout(line);
1775
- const box = placement.view.showMoreHitbox(plain);
1776
- if (box) {
1777
- regions.push({
1778
- kind: "show-more",
1779
- row: finalRow,
1780
- ...box,
1781
- component,
1782
- view: placement.view,
1783
- section: placement.section,
1784
- });
1785
- }
1786
- }
1787
- }
1788
- if (!component.expanded) continue;
1789
- let cardStart = 0;
1790
- if (!(component instanceof ToolGroupComponent)) {
1791
- const box = component.contentBox;
1792
- if (!box || !Array.isArray(component.children) || !component.children.includes(box)) {
1793
- continue;
1794
- }
1795
- if (!rendered.contentBoxLines) continue;
1796
- cardStart = Math.max(0, rendered.lines.length - rendered.contentBoxLines);
1797
- }
1798
- for (const placement of componentPlacements) {
1799
- if (placement.componentRow < cardStart) continue;
1800
- const finalRow = lineIndexToScreenRow(placement.lineIndex);
1801
- if (finalRow >= 1 && finalRow <= visibleRows) {
1802
- regions.push({
1803
- kind: "expanded-card",
1804
- row: finalRow,
1805
- startCol: 1,
1806
- endCol: width,
1807
- component,
1808
- });
1809
- }
1810
- }
1811
- }
1812
- return { regions };
1813
- }
1814
-
1815
- /**
1816
- * 临时包装 outermost 工具/组件的 render 注入零宽 marker,返回待 restore 列表。
1817
- * 调用方必须用 restoreRenderOverride 立即还原(同一次渲染内有效)。
1818
- */
1819
- function wrapToolRendersForFrame(
1820
- outermost: any[],
1821
- renderedTools: FrameToolRender[],
1822
- idToComponent: Map<number, any>,
1823
- ): Array<{ target: any; descriptor?: PropertyDescriptor }> {
1824
- const restores: Array<{ target: any; descriptor?: PropertyDescriptor }> = [];
1825
- let nextId = 0;
1826
- try {
1827
- for (const component of outermost) {
1828
- const originalRender = component.render;
1829
- if (typeof originalRender !== "function") continue;
1830
- const id = nextId++;
1831
- idToComponent.set(id, component);
1832
- const wrappedRender = function (this: any, ...renderArgs: any[]) {
1833
- let contentBoxLines = 0;
1834
- const box = component.contentBox;
1835
- let boxRestore: { target: any; descriptor?: PropertyDescriptor } | undefined;
1836
- if (
1837
- box &&
1838
- Array.isArray(component.children) &&
1839
- component.children.includes(box) &&
1840
- typeof box.render === "function"
1841
- ) {
1842
- const boxOriginal = box.render;
1843
- const boxWrapped = function (this: any, ...boxArgs: any[]) {
1844
- const boxLines = Reflect.apply(boxOriginal, this, boxArgs);
1845
- if (Array.isArray(boxLines)) contentBoxLines = boxLines.length;
1846
- return boxLines;
1847
- };
1848
- const boxDescriptor = defineRenderOverride(box, boxWrapped);
1849
- if (boxDescriptor !== undefined || box.render === boxWrapped) {
1850
- boxRestore = { target: box, descriptor: boxDescriptor };
1851
- }
1852
- }
1853
- try {
1854
- const lines = Reflect.apply(originalRender, this, renderArgs);
1855
- if (!Array.isArray(lines)) return lines;
1856
- renderedTools.push({
1857
- component,
1858
- lines: lines.map((line) => String(line)),
1859
- contentBoxLines,
1860
- });
1861
- return lines.map((line, row) => `${line}${toolFrameMarker(id, row)}`);
1862
- } finally {
1863
- if (boxRestore) restoreRenderOverride(boxRestore.target, boxRestore.descriptor);
1864
- }
1865
- };
1866
- const descriptor = defineRenderOverride(component, wrappedRender);
1867
- if (descriptor !== undefined || component.render === wrappedRender) {
1868
- restores.push({ target: component, descriptor });
1869
- }
1870
- }
1871
- return restores;
1872
- } catch (error) {
1873
- for (const { target, descriptor } of restores.reverse()) {
1874
- restoreRenderOverride(target, descriptor);
1875
- }
1876
- throw error;
1877
- }
1878
- }
1879
-
1880
- function defineRenderOverride(
1881
- target: any,
1882
- wrapped: (...args: any[]) => any,
1883
- ): PropertyDescriptor | undefined {
1884
- const descriptor = Object.getOwnPropertyDescriptor(target, "render");
1885
- try {
1886
- Object.defineProperty(
1887
- target,
1888
- "render",
1889
- descriptor && "value" in descriptor
1890
- ? { ...descriptor, value: wrapped }
1891
- : {
1892
- configurable: true,
1893
- enumerable: descriptor?.enumerable ?? false,
1894
- writable: true,
1895
- value: wrapped,
1896
- },
1897
- );
1898
- return descriptor;
1899
- } catch {
1900
- return undefined;
1901
- }
1902
- }
1903
-
1904
- function restoreRenderOverride(target: any, descriptor: PropertyDescriptor | undefined): void {
1905
- try {
1906
- if (descriptor) Object.defineProperty(target, "render", descriptor);
1907
- else delete target.render;
1908
- } catch {
1909
- // Keep restoring siblings after a hostile descriptor change.
1910
- }
1911
- }
1912
-
1913
- function patchToolMouseMotionAfterRender(tui: any): void {
1914
- // Same tui is not enough: footer/compositor rebuild may replace doRender under us.
1915
- if (
1916
- toolMouseRenderPatchTui === tui &&
1917
- toolMouseRenderPatchState?.active &&
1918
- tui.doRender === toolMouseRenderPatchWrapper
1919
- ) {
1920
- return;
1921
- }
1922
- // 0.84+ 惰性 Proxy:捕获 doRender 会解析到 wrapper 自身(无限递归),跳过。
1923
- if (isLazyProxyTui(tui)) return;
1924
- restoreToolMouseRenderPatch();
1925
- const original = tui?.doRender;
1926
- const terminal = tui?.terminal;
1927
- const rawWrite = typeof terminal?.write === "function" ? terminal.write : undefined;
1928
- if (typeof original !== "function") return;
1929
-
1930
- toolMouseRawWrite = rawWrite ? (data) => Reflect.apply(rawWrite, terminal, [data]) : null;
1931
- const patchState = { active: true };
1932
- const wrapper = function (this: any, ...args: any[]) {
1933
- if (!patchState.active) return Reflect.apply(original, this, args);
1934
- const renderedTools: FrameToolRender[] = [];
1935
- const idToComponent = new Map<number, any>();
1936
- const frame: IoViewFrameState = {
1937
- viewIds: new Map(),
1938
- idToView: new Map(),
1939
- nextId: 0,
1940
- };
1941
- const outermost: any[] = [];
1942
- collectToolComponents(this, outermost);
1943
- const restores = wrapToolRendersForFrame(outermost, renderedTools, idToComponent);
1944
- let placements: FrameToolPlacement[] = [];
1945
- const originalTuiRender = typeof this.render === "function" ? this.render : null;
1946
- let tuiRenderDescriptor: PropertyDescriptor | undefined;
1947
- let sawTuiRender = false;
1948
- if (originalTuiRender) {
1949
- const wrappedTuiRender = function (this: any, ...renderArgs: any[]) {
1950
- const lines = Reflect.apply(originalTuiRender, this, renderArgs);
1951
- if (!Array.isArray(lines)) return lines;
1952
- sawTuiRender = true;
1953
- const extracted = extractToolFramePlacements(
1954
- lines.map((line) => String(line)),
1955
- idToComponent,
1956
- frame.idToView,
1957
- );
1958
- placements = extracted.placements;
1959
- return extracted.lines;
1960
- };
1961
- tuiRenderDescriptor = defineRenderOverride(this, wrappedTuiRender);
1962
- }
1963
- let succeeded = false;
1964
- const previousFrame = activeIoViewFrame;
1965
- activeIoViewFrame = frame;
1966
- try {
1967
- const result = Reflect.apply(original, this, args);
1968
- succeeded = true;
1969
- // Test harnesses may paint via doRender without tui.render; recover markers there.
1970
- if (!sawTuiRender && Array.isArray(this.previousLines)) {
1971
- const extracted = extractToolFramePlacements(
1972
- this.previousLines.map((line: unknown) => String(line)),
1973
- idToComponent,
1974
- frame.idToView,
1975
- );
1976
- this.previousLines = extracted.lines;
1977
- placements = extracted.placements;
1978
- }
1979
- if (toolMouseInteractionActive()) toolMouseRawWrite?.(TOOL_MOUSE_MOTION_ENABLE);
1980
- return result;
1981
- } finally {
1982
- activeIoViewFrame = previousFrame;
1983
- if (originalTuiRender) restoreRenderOverride(this, tuiRenderDescriptor);
1984
- for (const { target, descriptor } of restores.reverse()) {
1985
- restoreRenderOverride(target, descriptor);
1986
- }
1987
- if (succeeded) {
1988
- latestInteractionFrame = buildInteractionFrame(this, renderedTools, placements);
1989
- }
1990
- }
1991
- };
1992
- try {
1993
- tui.doRender = wrapper;
1994
- } catch {
1995
- toolMouseRawWrite = null;
1996
- return;
1997
- }
1998
- toolMouseRenderPatchTui = tui;
1999
- toolMouseRenderPatchOriginal = original;
2000
- toolMouseRenderPatchWrapper = wrapper;
2001
- toolMouseRenderPatchState = patchState;
2002
- if (toolMouseInteractionActive()) toolMouseRawWrite?.(TOOL_MOUSE_MOTION_ENABLE);
2003
- }
2004
-
2005
- function handleToolMouseInput(data: string): { consume: true } | undefined {
2006
- if (!toolMouseTui) return undefined;
2007
- // 惰性 Proxy fullscreen:鼠标由 handleViewportInput 包装消费(官方链之前),
2008
- // 此处只处理键盘(鼠标事件在官方 listener 已被 consume,到不了这里)。
2009
- if (fullscreenLazyTui(toolMouseTui)) {
2010
- scheduleScrollButtonSync(toolMouseTui, data);
2011
- if (isScrollBottomInput(data)) {
2012
- toolMouseTui.scrollToBottom?.();
2013
- hideScrollButton(toolMouseTui);
2014
- return { consume: true };
2015
- }
2016
- return undefined;
2017
- }
2018
- updateScrollButtonFromInput(toolMouseTui, data);
2019
- // Off mode restores native input: wheel keeps scrolling through Pi's normal
2020
- // dispatcher, while hover/click affordances are entirely inactive.
2021
- if (!toolMouseInteractionActive()) return undefined;
2022
- const packets = parseSgrMousePackets(data);
2023
- if (!packets) {
2024
- scheduleScrollButtonSync(toolMouseTui, data);
2025
- return undefined;
2026
- }
2027
-
2028
- let consumed = false;
2029
- for (const packet of packets) {
2030
- updateToolSummaryHover(toolMouseTui, packet);
2031
- if (!isSgrLeftPress(packet)) continue;
2032
- if (toggleToolAtMouseClick(toolMouseTui, packet)) {
2033
- consumed = true;
2034
- }
2035
- }
2036
-
2037
- // Let scrolling, motion, release, and clicks outside tool results reach the
2038
- // normal TUI input chain (including other extensions such as pi-zentui).
2039
- scheduleScrollButtonSync(toolMouseTui, data);
2040
- return consumed ? { consume: true } : undefined;
2041
- }
2042
-
2043
- function teardownToolMouseInteraction(): void {
2044
- if (sessionRenderTimer) {
2045
- clearTimeout(sessionRenderTimer);
2046
- sessionRenderTimer = null;
2047
- }
2048
- toolMouseInputUnsubscribe?.();
2049
- toolMouseInputUnsubscribe = null;
2050
- hoveredToolCallId = null;
2051
- setHoveredToolGroup(null);
2052
- setHoveredToolIo(null, null);
2053
- try {
2054
- // 惰性 Proxy 不启用任何 reporting(regular 会破坏终端回滚,fullscreen 归官方)。
2055
- if (!isLazyProxyTui(toolMouseTui)) {
2056
- toolMouseTui?.terminal?.write?.(TOOL_MOUSE_DISABLE);
2057
- }
2058
- } catch {
2059
- // The terminal may already be closed during shutdown.
2060
- }
2061
- try {
2062
- toolMouseUi?.setWidget?.(TOOL_MOUSE_WIDGET_KEY, undefined);
2063
- } catch {
2064
- // The UI context may already have been reset during /reload.
2065
- }
2066
- restoreToolMouseRenderPatch();
2067
- restoreFullscreenViewportInput(toolMouseTui);
2068
- scrollButtonVisible = false;
2069
- scrollButtonHovered = false;
2070
- scrollButtonWidget = null;
2071
- scrollButtonSyncScheduled = false;
2072
- toolMouseTui = null;
2073
- toolMouseUi = null;
2074
- }
2075
-
2076
- export function installToolMouseInteraction(ctx: any): void {
2077
- teardownToolMouseInteraction();
2078
- if (ctx?.mode !== "tui" || !ctx?.hasUI) return;
2079
- if (typeof ctx.ui?.onTerminalInput !== "function" || typeof ctx.ui?.setWidget !== "function")
2080
- return;
2081
-
2082
- toolMouseUi = ctx.ui;
2083
- // 0.84+ 的 tui 是惰性 Proxy:行级 patch(doRender/handleInput)无法安全捕获。
2084
- // regular 模式不启用鼠标上报(保终端回滚);fullscreen 由官方 viewport 接管。
2085
- ctx.ui.setWidget(TOOL_MOUSE_WIDGET_KEY, (tui: any, theme: any) => {
2086
- toolMouseTui = tui;
2087
- if (isLazyProxyTui(tui)) {
2088
- // 惰性 Proxy:不启用任何 mouse reporting(regular 会破坏终端回滚,
2089
- // fullscreen 官方已启用)。fullscreen 工具卡点击/回到底部按钮由
2090
- // handleViewportInput 实例包装提供;widget render 时重检查 renderer
2091
- // 替换(switchTuiMode 新建实例后重新安装包装)。
2092
- patchFullscreenViewportInput(tui);
2093
- scrollButtonWidget = {
2094
- render: (width: number) => {
2095
- patchFullscreenViewportInput(tui);
2096
- return renderScrollButton(width, theme);
2097
- },
2098
- invalidate() {},
2099
- };
2100
- return scrollButtonWidget;
2101
- }
2102
- // Wrap doRender to capture the live frame for tool click/hover mapping.
2103
- patchToolMouseMotionAfterRender(tui);
2104
- if (toolMouseInteractionActive()) tui?.terminal?.write?.(TOOL_MOUSE_MOTION_ENABLE);
2105
- const widget = {
2106
- render: (width: number) => renderScrollButton(width, theme),
2107
- invalidate() {},
2108
- };
2109
- scrollButtonWidget = widget;
2110
- return widget;
2111
- });
2112
- toolMouseInputUnsubscribe = ctx.ui.onTerminalInput(handleToolMouseInput);
2113
- }
2114
-
2115
- function refreshToolRendererComponents(tui: any): void {
2116
- const tools: any[] = [];
2117
- collectToolComponents(tui, tools);
2118
- for (const tool of tools) tool.invalidate?.();
2119
- }
2120
-
2121
- function scheduleSessionRender(refresh?: () => void): void {
2122
- const tui = toolMouseTui;
2123
- if (!tui || typeof tui.requestRender !== "function") return;
2124
- if (sessionRenderTimer) clearTimeout(sessionRenderTimer);
2125
- // Restored transcripts are populated at different points for startup, reload,
2126
- // and session replacement. Repaint after session_start and the surrounding UI
2127
- // rebuild finish so messages are not left hidden until the next terminal input.
2128
- sessionRenderTimer = setTimeout(() => {
2129
- sessionRenderTimer = null;
2130
- if (toolMouseTui !== tui) return;
2131
- patchToolMouseMotionAfterRender(tui);
2132
- refreshToolRendererComponents(tui);
2133
- refresh?.();
2134
- tui.requestRender(true);
2135
- }, 0);
2136
- }
2137
-
2138
- // Bright green for success icon (truecolor ANSI escape)
2139
- const BRIGHT_GREEN = "\x1b[38;2;80;220;100m";
2140
- const ANSI_FG_RESET = "\x1b[39m";
2141
-
2142
- function refreshCurrentTranscript(ctx?: any, toolGrouping?: ToolGroupingHooks): void {
2143
- toolGrouping?.refresh();
2144
- toolMouseTui?.requestRender?.(true);
2145
- ctx?.ui?.requestRender?.(true);
2146
- }
2147
-
2148
- function applyStyleMode(mode: CompactStyleMode, ctx: any, toolGrouping?: ToolGroupingHooks): void {
2149
- config.mode = mode;
2150
- saveConfig();
2151
- if (mode === "off") {
2152
- // Native rendering mode: drop hover/click state and fully disable mouse
2153
- // reporting so the terminal restores its default scrollback wheel scrolling.
2154
- hoveredToolCallId = null;
2155
- setHoveredToolGroup(null);
2156
- setHoveredToolIo(null, null);
2157
- scrollButtonVisible = false;
2158
- scrollButtonHovered = false;
2159
- // 惰性 Proxy(regular/fullscreen)不持有 reporting:regular 保终端回滚,
2160
- // fullscreen 归官方所有,均不能在此关闭。
2161
- if (toolMouseTui && !isLazyProxyTui(toolMouseTui)) {
2162
- toolMouseTui.terminal?.write?.(TOOL_MOUSE_DISABLE);
2163
- }
2164
- }
2165
- refreshCurrentTranscript(ctx, toolGrouping);
2166
- ctx.ui.notify(`Claude Code style: ${mode}`, "info");
2167
- }
2168
-
2169
- function modeSettingDescription(mode: CompactStyleMode): string {
2170
- if (mode === "off") {
2171
- return "Pi native tool rendering. Diff options below still apply independently.";
2172
- }
2173
- return "Claude Code style with rich edit/write diffs. Tune diff options below.";
2174
- }
2175
-
2176
- function excludeRenderersDescription(names: readonly string[]): string {
2177
- return names.length === 0
2178
- ? "No tools excluded. Agent always keeps its dedicated renderer. Enter to toggle common tools."
2179
- : `Native renderer for: ${names.join(", ")}. Agent is always native. Enter to toggle.`;
2180
- }
2181
-
2182
- function diffViewModeDescription(mode: DiffViewMode): string {
2183
- if (mode === "split") return "Force side-by-side diff when width allows; otherwise unified.";
2184
- if (mode === "unified") return "Always render a single unified diff column.";
2185
- return "Auto: split when terminal is wide enough, otherwise unified.";
2186
- }
2187
-
2188
- function diffIndicatorDescription(mode: DiffIndicatorMode): string {
2189
- if (mode === "classic") return "Classic +/- gutters on changed lines.";
2190
- if (mode === "none") return "No change indicators; rely on color alone.";
2191
- return "Vertical bar indicators on changed lines (default).";
2192
- }
2193
-
2194
- function buildExcludeRenderersSubmenu(
2195
- onClose: () => void,
2196
- onLiveChange: () => void,
2197
- ): {
2198
- render: (width: number) => string[];
2199
- invalidate: () => void;
2200
- handleInput: (data: string) => void;
2201
- } {
2202
- const candidates = [
2203
- ...new Set([...EXCLUDE_RENDERER_CANDIDATES, ...config.excludeRenderers]),
2204
- ].sort((a, b) => a.localeCompare(b));
2205
- const items = candidates.map((name) => ({
2206
- id: name,
2207
- label: name,
2208
- description:
2209
- name === "Agent"
2210
- ? "Agent always uses its dedicated renderer and cannot be forced through ccstyle."
2211
- : `Use Pi native renderer for ${name} instead of Claude Code styling.`,
2212
- currentValue: config.excludeRenderers.includes(name) ? "exclude" : "style",
2213
- values: ["style", "exclude"],
2214
- }));
2215
- const list = new SettingsList(
2216
- items,
2217
- Math.min(8, Math.max(4, items.length)),
2218
- getSettingsListTheme(),
2219
- (id: string, value: string) => {
2220
- const excluded = new Set(config.excludeRenderers);
2221
- if (value === "exclude") excluded.add(id);
2222
- else excluded.delete(id);
2223
- config.excludeRenderers = [...excluded].sort((a, b) => a.localeCompare(b));
2224
- saveConfig();
2225
- onLiveChange();
2226
- },
2227
- () => onClose(),
2228
- { enableSearch: candidates.length > 8 },
2229
- );
2230
- return {
2231
- render: (width: number) => [
2232
- ...list.render(width),
2233
- "",
2234
- // Extra hint: Esc returns to the Style section list.
2235
- truncateToWidth(" Esc back to Style settings", width),
2236
- ],
2237
- invalidate: () => list.invalidate(),
2238
- handleInput: (data: string) => list.handleInput(data),
2239
- };
2240
- }
2241
-
2242
- /** Section tabs for /ccstyle — matches Zentui-style "A / B / C" headers. */
2243
- type CcstyleSection = {
2244
- id: "style" | "editor" | "diff" | "thinking";
2245
- label: string;
2246
- items: any[];
2247
- };
2248
-
2249
- function isForwardTabKey(data: string): boolean {
2250
- return data === "\t" || matchesKey(data, "tab");
2251
- }
2252
-
2253
- function isBackTabKey(data: string): boolean {
2254
- // CSI Z is the common terminal encoding for Shift+Tab.
2255
- return data === "\x1b[Z" || matchesKey(data, "shift+tab");
2256
- }
2257
-
2258
- function renderPanelRule(theme: any, width: number): string {
2259
- return theme.fg("dim", "─".repeat(Math.max(0, width)));
2260
- }
2261
-
2262
- function renderSectionTabBar(
2263
- theme: any,
2264
- sections: readonly { label: string }[],
2265
- activeIndex: number,
2266
- width: number,
2267
- ): string {
2268
- const pieces: string[] = [];
2269
- for (let i = 0; i < sections.length; i++) {
2270
- if (i > 0) pieces.push(theme.fg("dim", " / "));
2271
- const label = sections[i]?.label ?? "";
2272
- pieces.push(
2273
- i === activeIndex
2274
- ? theme.fg("text", typeof theme.bold === "function" ? theme.bold(label) : label)
2275
- : theme.fg("dim", label),
2276
- );
2277
- }
2278
- return truncateToWidth(pieces.join(""), Math.max(0, width));
2279
- }
2280
-
2281
- async function showCcstylePanel(
2282
- ctx: any,
2283
- toolGrouping?: ToolGroupingHooks,
2284
- compactThinking?: CompactThinkingController,
2285
- ): Promise<void> {
2286
- if (ctx?.mode !== "tui" || !ctx?.hasUI || typeof ctx.ui?.custom !== "function") {
2287
- ctx.ui?.notify?.("/ccstyle requires TUI mode", "warning");
2288
- return;
2289
- }
2290
-
2291
- await ctx.ui.custom((tui: any, theme: any, _keybindings: any, done: () => void) => {
2292
- const modeSetting = {
2293
- id: "mode",
2294
- label: "Mode",
2295
- description: modeSettingDescription(config.mode),
2296
- currentValue: config.mode,
2297
- values: ["on", "off"],
2298
- };
2299
- // Tracks whether the Exclude-tools submenu is open so Tab switches sections
2300
- // only at the top level (mirrors Zentui settings: Tab = switch sections).
2301
- let excludeSubmenuOpen = false;
2302
- const excludeSetting = {
2303
- id: "excludeRenderers",
2304
- label: "Exclude tools",
2305
- description: excludeRenderersDescription(config.excludeRenderers),
2306
- currentValue: formatExcludeRenderers(config.excludeRenderers),
2307
- submenu: (_current: string, closeSubmenu: (selected?: string) => void) => {
2308
- excludeSubmenuOpen = true;
2309
- return buildExcludeRenderersSubmenu(
2310
- () => {
2311
- excludeSubmenuOpen = false;
2312
- excludeSetting.currentValue = formatExcludeRenderers(config.excludeRenderers);
2313
- excludeSetting.description = excludeRenderersDescription(config.excludeRenderers);
2314
- closeSubmenu();
2315
- },
2316
- () => {
2317
- excludeSetting.currentValue = formatExcludeRenderers(config.excludeRenderers);
2318
- excludeSetting.description = excludeRenderersDescription(config.excludeRenderers);
2319
- refreshCurrentTranscript(ctx);
2320
- },
2321
- );
2322
- },
2323
- };
2324
- const diffViewSetting = {
2325
- id: "diffViewMode",
2326
- label: "Diff layout",
2327
- description: diffViewModeDescription(config.diffViewMode),
2328
- currentValue: config.diffViewMode,
2329
- values: [...DIFF_VIEW_MODES],
2330
- };
2331
- const diffIndicatorSetting = {
2332
- id: "diffIndicatorMode",
2333
- label: "Diff indicator",
2334
- description: diffIndicatorDescription(config.diffIndicatorMode),
2335
- currentValue: config.diffIndicatorMode,
2336
- values: [...DIFF_INDICATOR_MODES],
2337
- };
2338
- const diffSplitSetting = {
2339
- id: "diffSplitMinWidth",
2340
- label: "Split min width",
2341
- description: "Minimum terminal width before auto/split layout uses side-by-side columns.",
2342
- currentValue: nearestPreset(config.diffSplitMinWidth, DIFF_SPLIT_MIN_WIDTH_VALUES),
2343
- values: [...DIFF_SPLIT_MIN_WIDTH_VALUES],
2344
- };
2345
- const diffCollapsedSetting = {
2346
- id: "diffCollapsedLines",
2347
- label: "Collapsed lines",
2348
- description: "How many diff body lines to show before the expand hint (Ctrl+O / click).",
2349
- currentValue: nearestPreset(config.diffCollapsedLines, DIFF_COLLAPSED_LINES_VALUES),
2350
- values: [...DIFF_COLLAPSED_LINES_VALUES],
2351
- };
2352
- const diffWordWrapSetting = {
2353
- id: "diffWordWrap",
2354
- label: "Diff word wrap",
2355
- description: config.diffWordWrap
2356
- ? "Long diff lines wrap within the panel width."
2357
- : "Long diff lines are truncated to the panel width.",
2358
- currentValue: config.diffWordWrap ? "on" : "off",
2359
- values: ["on", "off"],
2360
- };
2361
- const expandedMaxSetting = {
2362
- id: "expandedPreviewMaxLines",
2363
- label: "Expanded max lines",
2364
- description:
2365
- "Max Output/diff body lines when expanded. Default 40 keeps the TUI compact; raise for large dumps.",
2366
- currentValue: nearestPreset(
2367
- config.expandedPreviewMaxLines,
2368
- EXPANDED_PREVIEW_MAX_LINES_VALUES,
2369
- ),
2370
- values: [...EXPANDED_PREVIEW_MAX_LINES_VALUES],
2371
- };
2372
- const thinkingTitleSetting = {
2373
- id: "useSummaryTitlesAsThinkingTitle",
2374
- label: "Summary title",
2375
- description: "Use the latest provider summary as the active thinking title.",
2376
- currentValue: config.useSummaryTitlesAsThinkingTitle ? "on" : "off",
2377
- values: ["on", "off"],
2378
- };
2379
- const thinkingPreviewSetting = {
2380
- id: "previewLines",
2381
- label: "Preview lines",
2382
- description: "Thinking preview lines; 0 hides the preview body.",
2383
- currentValue: nearestPreset(config.previewLines, THINKING_PREVIEW_LINES_VALUES),
2384
- values: [...THINKING_PREVIEW_LINES_VALUES],
2385
- };
2386
- const thinkingAnimationSetting = {
2387
- id: "animationIntervalMs",
2388
- label: "Animation interval ms",
2389
- description: "Thinking title animation interval for the next thinking run.",
2390
- currentValue: nearestPreset(config.animationIntervalMs, THINKING_ANIMATION_INTERVAL_VALUES),
2391
- values: [...THINKING_ANIMATION_INTERVAL_VALUES],
2392
- };
2393
-
2394
- const onSettingChange = (id: string, value: string) => {
2395
- switch (id) {
2396
- case "mode":
2397
- modeSetting.description = modeSettingDescription(value as CompactStyleMode);
2398
- applyStyleMode(value as CompactStyleMode, ctx, toolGrouping);
2399
- return;
2400
- case "excludeRenderers":
2401
- excludeSetting.currentValue = formatExcludeRenderers(config.excludeRenderers);
2402
- excludeSetting.description = excludeRenderersDescription(config.excludeRenderers);
2403
- return;
2404
- case "diffViewMode":
2405
- config.diffViewMode = value as DiffViewMode;
2406
- diffViewSetting.description = diffViewModeDescription(config.diffViewMode);
2407
- break;
2408
- case "diffIndicatorMode":
2409
- config.diffIndicatorMode = value as DiffIndicatorMode;
2410
- diffIndicatorSetting.description = diffIndicatorDescription(config.diffIndicatorMode);
2411
- break;
2412
- case "diffSplitMinWidth":
2413
- config.diffSplitMinWidth = pickPositiveInt(
2414
- value,
2415
- DEFAULT_CONFIG.diffSplitMinWidth,
2416
- 40,
2417
- 300,
2418
- );
2419
- break;
2420
- case "diffCollapsedLines":
2421
- config.diffCollapsedLines = pickPositiveInt(
2422
- value,
2423
- DEFAULT_CONFIG.diffCollapsedLines,
2424
- 1,
2425
- 500,
2426
- );
2427
- break;
2428
- case "diffWordWrap":
2429
- config.diffWordWrap = value === "on";
2430
- diffWordWrapSetting.description = config.diffWordWrap
2431
- ? "Long diff lines wrap within the panel width."
2432
- : "Long diff lines are truncated to the panel width.";
2433
- break;
2434
- case "expandedPreviewMaxLines":
2435
- config.expandedPreviewMaxLines = pickPositiveInt(
2436
- value,
2437
- DEFAULT_CONFIG.expandedPreviewMaxLines,
2438
- 10,
2439
- 50_000,
2440
- );
2441
- break;
2442
- case "useSummaryTitlesAsThinkingTitle":
2443
- config.useSummaryTitlesAsThinkingTitle = value === "on";
2444
- break;
2445
- case "previewLines":
2446
- config.previewLines = pickPositiveInt(value, DEFAULT_CONFIG.previewLines, 0);
2447
- break;
2448
- case "animationIntervalMs":
2449
- config.animationIntervalMs = pickPositiveNumber(
2450
- value,
2451
- DEFAULT_CONFIG.animationIntervalMs,
2452
- );
2453
- break;
2454
- default:
2455
- return;
2456
- }
2457
- saveConfig();
2458
- compactThinking?.updateConfig(getCompactThinkingConfig());
2459
- refreshCurrentTranscript(ctx);
2460
- ctx.ui.notify(`Updated ${id}: ${value}`, "info");
2461
- };
2462
-
2463
- const sections: CcstyleSection[] = [
2464
- {
2465
- id: "style",
2466
- label: "Style",
2467
- items: [modeSetting, excludeSetting],
2468
- },
2469
- {
2470
- id: "diff",
2471
- label: "Diff",
2472
- items: [
2473
- diffViewSetting,
2474
- diffIndicatorSetting,
2475
- diffSplitSetting,
2476
- diffCollapsedSetting,
2477
- diffWordWrapSetting,
2478
- expandedMaxSetting,
2479
- ],
2480
- },
2481
- {
2482
- id: "thinking",
2483
- label: "Thinking",
2484
- items: [thinkingTitleSetting, thinkingPreviewSetting, thinkingAnimationSetting],
2485
- },
2486
- ];
2487
-
2488
- let activeSection = 0;
2489
- const settingsTheme = getSettingsListTheme();
2490
- const lists = sections.map(
2491
- (section) =>
2492
- new SettingsList(
2493
- section.items,
2494
- Math.min(8, Math.max(section.items.length, 1)),
2495
- settingsTheme,
2496
- onSettingChange,
2497
- () => done(),
2498
- { enableSearch: false },
2499
- ),
2500
- );
2501
-
2502
- const activeList = () => lists[activeSection]!;
2503
-
2504
- const switchSection = (delta: number) => {
2505
- if (excludeSubmenuOpen) return;
2506
- activeSection = (activeSection + delta + sections.length) % sections.length;
2507
- };
2508
-
2509
- return {
2510
- render(width: number): string[] {
2511
- const safeWidth = Math.max(0, Math.floor(width));
2512
- const rule = renderPanelRule(theme, safeWidth);
2513
- const body = activeList().render(safeWidth);
2514
- // Drop SettingsList's built-in hint — the panel footer below is the single source.
2515
- while (body.length > 0 && body[body.length - 1] === "") body.pop();
2516
- const listHintIndex = body.findIndex(
2517
- (line) =>
2518
- typeof line === "string" &&
2519
- (line.includes("Enter/Space to change") || line.includes("Esc to cancel")),
2520
- );
2521
- const listBody = listHintIndex >= 0 ? body.slice(0, listHintIndex) : body;
2522
- while (listBody.length > 0 && listBody[listBody.length - 1] === "") listBody.pop();
2523
-
2524
- // Frame: top rule · tabs · mid rule · settings · mid rule · footer · bottom rule
2525
- return [
2526
- rule,
2527
- renderSectionTabBar(theme, sections, activeSection, safeWidth),
2528
- rule,
2529
- ...listBody,
2530
- rule,
2531
- truncateToWidth(
2532
- theme.fg(
2533
- "dim",
2534
- " Enter/Space to change · Tab/Shift+Tab to switch sections · Esc to close",
2535
- ),
2536
- safeWidth,
2537
- ),
2538
- rule,
2539
- ];
2540
- },
2541
- invalidate() {
2542
- for (const list of lists) list.invalidate();
2543
- },
2544
- handleInput(data: string) {
2545
- if (!excludeSubmenuOpen && isForwardTabKey(data)) {
2546
- switchSection(1);
2547
- tui.requestRender();
2548
- return;
2549
- }
2550
- if (!excludeSubmenuOpen && isBackTabKey(data)) {
2551
- switchSection(-1);
2552
- tui.requestRender();
2553
- return;
2554
- }
2555
- activeList().handleInput?.(data);
2556
- tui.requestRender();
2557
- },
2558
- };
2559
- });
2560
- }
2561
-
2562
- function renderDefault(tool: any, slot: "renderCall" | "renderResult", args: any[], fallback = "") {
2563
- try {
2564
- if (typeof tool?.[slot] === "function") return tool[slot](...args);
2565
- } catch {
2566
- // Fall through to raw fallback.
2567
- }
2568
- return new Text(fallback, 0, 0);
2569
- }
2570
-
2571
- function singleLine(text: string) {
2572
- return {
2573
- render: (width: number) => [truncateToWidth(text, width, "…")],
2574
- invalidate() {},
2575
- };
2576
- }
2577
-
2578
- function insetComponent(component: any): any {
2579
- return {
2580
- render: (width: number) =>
2581
- component.render(Math.max(1, width - 1)).map((line: string) => {
2582
- const nestedMarker = line.replace(/^((?:\x1b\[[0-?]*[ -/]*[@-~])*)↳/, "$1 ↳");
2583
- return ` ${nestedMarker}`;
2584
- }),
2585
- invalidate: () => component.invalidate?.(),
2586
- };
2587
- }
2588
-
2589
- function humanizeToolLabel(label: string): string {
2590
- return label
2591
- .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
2592
- .replace(/[_-]+/g, " ")
2593
- .replace(/\b\w/g, (char) => char.toUpperCase());
2594
- }
2595
-
2596
- function singleToolCallSummary(
2597
- toolName: string,
2598
- label: string,
2599
- args: any,
2600
- ): { main: string; detail: string } {
2601
- const title = label === toolName ? humanizeToolLabel(label) : label;
2602
- if (!args || typeof args !== "object") return { main: title, detail: "" };
2603
- const name = toolName.toLowerCase();
2604
- const value = (fallback: string, ...keys: string[]) => {
2605
- const found = keys.map((key) => args[key]).find((item) => typeof item === "string" && item);
2606
- return `${title} ${oneLine(found || fallback, Infinity)}`;
2607
- };
2608
- if (AGENT_FAMILY_TOOL_NAMES.has(toolName) && args.agent_id) {
2609
- return { main: `${title} ${oneLine(args.agent_id, Infinity)}`, detail: "" };
2610
- }
2611
- // Agents still uses the ccstyle wrapper; Agent keeps its dedicated renderer.
2612
- if (name === "agents") {
2613
- return {
2614
- main: value("launch agents", "description", "prompt"),
2615
- detail: "",
2616
- };
2617
- }
2618
- if (name === "skill") return { main: value("run skill", "name"), detail: "" };
2619
- if (name === "enterplanmode" || name === "enter_plan_mode") {
2620
- return { main: `${title} enable read-only planning`, detail: "" };
2621
- }
2622
- if (name === "exitplanmode" || name === "exit_plan_mode") {
2623
- return { main: `${title} present plan`, detail: "" };
2624
- }
2625
- if (name === "taskcreate") return { main: value("create task", "subject"), detail: "" };
2626
- if (name === "tasklist") return { main: `${title} task list`, detail: "" };
2627
- if (name === "taskget" || name === "taskupdate") {
2628
- return { main: value("task", "taskId", "task_id"), detail: "" };
2629
- }
2630
- if (name === "taskoutput" || name === "taskstop") {
2631
- return { main: value("background task", "task_id", "taskId"), detail: "" };
2632
- }
2633
- if (name === "taskexecute") {
2634
- const ids = Array.isArray(args.task_ids)
2635
- ? args.task_ids
2636
- : Array.isArray(args.taskIds)
2637
- ? args.taskIds
2638
- : [];
2639
- const summary = ids.length
2640
- ? `${ids[0]}${ids.length > 1 ? ` (+${ids.length - 1} tasks)` : ""}`
2641
- : "start tasks";
2642
- return { main: `${title} ${summary}`, detail: "" };
2643
- }
2644
- if (toolName === "read") {
2645
- const details = [
2646
- args.offset !== undefined ? `offset=${args.offset}` : "",
2647
- args.limit !== undefined ? `limit=${args.limit}` : "",
2648
- ].filter(Boolean);
2649
- return {
2650
- main: `${title}${args.path ? ` ${oneLine(args.path, Infinity)}` : ""}`,
2651
- detail: details.length ? ` (${details.join(", ")})` : "",
2652
- };
2653
- }
2654
- const preferred =
2655
- args.path ??
2656
- args.file_path ??
2657
- args.command ??
2658
- args.query ??
2659
- args.question ??
2660
- args.pattern ??
2661
- args.url ??
2662
- args.name ??
2663
- args.tool_use_id ??
2664
- args.toolCallId ??
2665
- args.id ??
2666
- args.message;
2667
- return {
2668
- main:
2669
- preferred !== undefined && preferred !== null && typeof preferred !== "object"
2670
- ? `${title} ${oneLine(preferred, Infinity)}`
2671
- : title,
2672
- detail: "",
2673
- };
2674
- }
2675
-
2676
- export function middleTruncateToWidth(text: string, width: number): string {
2677
- if (visibleWidth(text) <= width) return text;
2678
- if (width <= 1) return "…";
2679
- const chars = Array.from(text);
2680
- const leftWidth = Math.ceil((width - 1) / 2);
2681
- let left = "";
2682
- let right = "";
2683
- for (const char of chars) {
2684
- if (visibleWidth(left + char) > leftWidth) break;
2685
- left += char;
2686
- }
2687
- for (const char of chars.reverse()) {
2688
- if (visibleWidth(left + "…" + char + right) > width) break;
2689
- right = char + right;
2690
- }
2691
- return `${left}…${right}`;
2692
- }
2693
-
2694
- export function shouldRenderRichDiff(
2695
- mode: CompactStyleMode,
2696
- toolName: string,
2697
- isError: boolean,
2698
- ): boolean {
2699
- return mode === "on" && !isError && (toolName === "edit" || toolName === "write");
2700
- }
2701
-
2702
- type ParsedTask = { id: string; status: string; subject: string };
2703
-
2704
- function parseTaskList(text: string): ParsedTask[] {
2705
- return text
2706
- .split("\n")
2707
- .map((line) => line.match(/^#(\d+) \[([^\]]+)] (.+)$/))
2708
- .filter((match): match is RegExpMatchArray => Boolean(match))
2709
- .map((match) => ({ id: match[1]!, status: match[2]!, subject: match[3]! }));
2710
- }
2711
-
2712
- function taskListSummary(tasks: ParsedTask[]): string {
2713
- const counts = { pending: 0, in_progress: 0, completed: 0 };
2714
- for (const task of tasks) {
2715
- if (task.status in counts) counts[task.status as keyof typeof counts]++;
2716
- }
2717
- return [
2718
- `${tasks.length} tasks`,
2719
- counts.in_progress ? `${counts.in_progress} in progress` : "",
2720
- counts.pending ? `${counts.pending} pending` : "",
2721
- counts.completed ? `${counts.completed} completed` : "",
2722
- ]
2723
- .filter(Boolean)
2724
- .join(" • ");
2725
- }
2726
-
2727
- function renderExpandedTaskResult(
2728
- toolName: string,
2729
- text: string,
2730
- theme: any,
2731
- isError: boolean,
2732
- ): any | undefined {
2733
- if (isError) return undefined;
2734
- if (toolName === "TaskList") {
2735
- const tasks = parseTaskList(text);
2736
- if (!tasks.length) return undefined;
2737
- const limit = Math.max(1, config.expandedPreviewMaxLines);
2738
- const rows = tasks.slice(0, limit).map((task) => {
2739
- const color =
2740
- task.status === "completed"
2741
- ? "success"
2742
- : task.status === "in_progress"
2743
- ? "warning"
2744
- : "muted";
2745
- return ` ${theme.fg("accent", `#${task.id}`)} ${theme.fg(color, task.status)} ${theme.fg("dim", task.subject)}`;
2746
- });
2747
- if (tasks.length > rows.length)
2748
- rows.push(theme.fg("muted", ` … ${tasks.length - rows.length} more tasks`));
2749
- return new Text(` ↳ ${theme.fg("muted", taskListSummary(tasks))}\n${rows.join("\n")}`, 0, 0);
2750
- }
2751
- const line = text.trim();
2752
- if (!line || line.includes("\n")) return undefined;
2753
- let formatted: string | undefined;
2754
- let match: RegExpMatchArray | null;
2755
- if (
2756
- toolName === "TaskCreate" &&
2757
- (match = line.match(/^Task #(\d+) created successfully: (.+)$/))
2758
- ) {
2759
- formatted = `${theme.fg("success", "Created task")} ${theme.fg("accent", `#${match[1]}`)} ${theme.fg("muted", match[2])}`;
2760
- } else if (toolName === "TaskUpdate" && (match = line.match(/^Updated task #(\d+) (.+)$/))) {
2761
- formatted = `${theme.fg("success", "Updated task")} ${theme.fg("accent", `#${match[1]}`)} ${theme.fg("muted", match[2])}`;
2762
- } else if (toolName === "TaskExecute") {
2763
- formatted = `${theme.fg("success", "Started")} ${theme.fg("muted", line)}`;
2764
- } else if (toolName === "TaskStop") {
2765
- formatted = `${theme.fg("success", "Stopped")} ${theme.fg("muted", line)}`;
2766
- }
2767
- return formatted ? new Text(` ↳ ${formatted}`, 0, 0) : undefined;
2768
- }
2769
-
2770
- /** Wrap an arbitrary tool definition with ccstyle call/result rendering. */
2771
- function createCcstyleTool(
2772
- originalTool: any,
2773
- writeExecutionMetadata: WriteExecutionMetadataStore,
2774
- ): any {
2775
- const toolName = originalTool.name;
2776
- const label = isMcpToolDefinition(originalTool, toolName)
2777
- ? humanizeMcpToolName(toolName)
2778
- : originalTool.label || toolName;
2779
-
2780
- return {
2781
- ...originalTool,
2782
- renderShell: "self",
2783
- renderCall(args: any, theme: any, context: any) {
2784
- if (config.mode !== "on") {
2785
- return renderDefault(originalTool, "renderCall", [args, theme, context], String(toolName));
2786
- }
2787
-
2788
- const visualState = resolveToolVisualState(context);
2789
- const isPending =
2790
- visualState === "pending" ||
2791
- (!visualState && (context?.isPartial || context?.executionStarted));
2792
- if (isPending) scheduleAnimation(context);
2793
- const rawIcon = isPending ? pendingIcon(toolName) : settledIcon(toolName, visualState);
2794
- const icon =
2795
- visualState === "success"
2796
- ? `${BRIGHT_GREEN}${rawIcon}${ANSI_FG_RESET}`
2797
- : theme.fg(toolIconColor(context), rawIcon);
2798
- const summary = singleToolCallSummary(toolName, label, args);
2799
- let cachedWidth: number | undefined;
2800
- let cachedLine: string | undefined;
2801
- return {
2802
- render(width: number) {
2803
- if (cachedLine !== undefined && cachedWidth === width) return [cachedLine];
2804
- const viewportWidth = toolViewportWidth(width);
2805
- const callWidth = Math.max(0, viewportWidth - visibleWidth(icon) - 2);
2806
- const mainWidth = Math.max(0, callWidth - visibleWidth(summary.detail));
2807
- cachedWidth = width;
2808
- cachedLine = ` ${icon} ${theme.fg("toolTitle", middleTruncateToWidth(summary.main, mainWidth))}${theme.fg("muted", summary.detail)}`;
2809
- return [truncateToWidth(cachedLine, viewportWidth, "")];
2810
- },
2811
- invalidate() {},
2812
- };
2813
- },
2814
- renderResult(result: any, options: any, theme: any, context: any) {
2815
- if (config.mode !== "on") {
2816
- return renderDefault(
2817
- originalTool,
2818
- "renderResult",
2819
- [result, options, theme, context],
2820
- textFromResult(result),
2821
- );
2822
- }
2823
-
2824
- if (options?.isPartial) {
2825
- return new Text(theme.fg("muted", " ↳ Pending…"), 0, 0);
2826
- }
2827
-
2828
- const isError = options?.isError || context?.isError;
2829
- setToolVisualState(context, isError ? "error" : "success");
2830
- const expanded = isToolExpanded(options, context);
2831
- const toolCallId = context?.toolCallId;
2832
- if (shouldRenderRichDiff(config.mode, toolName, Boolean(isError))) {
2833
- // Pass getter so Diff indicator / wrap / limits update on the next paint
2834
- // without recreating the tool result component.
2835
- const richResult = renderRichToolResult(
2836
- toolName,
2837
- result,
2838
- {
2839
- ...options,
2840
- expanded,
2841
- // Live hover state for the collapsed hint row (muted → text on hover).
2842
- isHovered: () => !!toolCallId && toolCallId === hoveredToolCallId,
2843
- },
2844
- theme,
2845
- context,
2846
- writeExecutionMetadata,
2847
- getToolDisplayConfig,
2848
- );
2849
- if (richResult) return insetComponent(richResult);
2850
- }
2851
-
2852
- const text = textFromResult(result, expanded);
2853
- const args = context?.args;
2854
- if (expanded) {
2855
- const taskResult = renderExpandedTaskResult(toolName, text, theme, Boolean(isError));
2856
- if (taskResult) return taskResult;
2857
- }
2858
- const tasks = !isError && toolName === "TaskList" ? parseTaskList(text) : [];
2859
- const outputLines = outputLineCount(result) || countLines(text);
2860
- const lineWord = outputLines === 1 ? "line" : "lines";
2861
- const action = toolName === "read" ? "loaded" : "returned";
2862
- const rendered = tasks.length
2863
- ? taskListSummary(tasks)
2864
- : isError
2865
- ? text
2866
- ? oneLine(text)
2867
- : "Failed"
2868
- : outputLines
2869
- ? `${outputLines} ${lineWord} ${action}`
2870
- : "Done";
2871
- const expandable = !expanded && (tasks.length > 0 || hasExpandableDetail(text, args));
2872
- const hintPrefix = expandable ? theme.fg("muted", " • ") : "";
2873
- const hint = expandable ? hintPrefix + theme.fg("muted", "click to show more") : "";
2874
- const hoveredHint = expandable ? hintPrefix + theme.fg("text", "click to show more") : "";
2875
- if (expanded) {
2876
- return renderExpandedToolResult(
2877
- text || "",
2878
- theme,
2879
- Boolean(isError),
2880
- context?.lastComponent,
2881
- args,
2882
- context,
2883
- );
2884
- }
2885
- if (context?.state) context.state.ccstyleIoView = undefined;
2886
- let cachedWidth: number | undefined;
2887
- let cachedLine: string | undefined;
2888
- let cachedHoveredLine: string | undefined;
2889
- return {
2890
- render(width: number) {
2891
- if (cachedLine === undefined || cachedWidth !== width) {
2892
- cachedWidth = width;
2893
- cachedLine = theme.fg(
2894
- isError ? "error" : "muted",
2895
- renderCollapsedToolResultToWidth(rendered, hint, width),
2896
- );
2897
- cachedHoveredLine = theme.fg(
2898
- isError ? "error" : "muted",
2899
- renderCollapsedToolResultToWidth(rendered, hoveredHint, width),
2900
- );
2901
- }
2902
- return [toolCallId && hoveredToolCallId === toolCallId ? cachedHoveredLine! : cachedLine];
2903
- },
2904
- invalidate() {},
2905
- };
2906
- },
2907
- };
2908
- }
2909
-
2910
- /**
2911
- * Apart from the write override used to capture pre-write content, renderers are
2912
- * applied through ToolExecutionComponent. Patch its lookup once so tools use the
2913
- * same ccstyle fallback shell by default. Tools named in excludeRenderers keep
2914
- * their original renderer.
2915
- */
2916
- const GLOBAL_TOOL_RENDER_PATCH = Symbol.for("pi.ccstyle.global-tool-render-patch");
2917
- const COMPONENT_TOOL_RENDER_MODE = Symbol.for("pi.ccstyle.component-tool-render-mode");
2918
- const COMPONENT_TOOL_SELF_SHELL_MODE = Symbol.for("pi.ccstyle.component-tool-self-shell-mode");
2919
- const AGENT_FAMILY_TOOL_NAMES = new Set([
2920
- "Agent",
2921
- "Agents",
2922
- "get_subagent_result",
2923
- "steer_subagent",
2924
- ]);
2925
- // pi-subagents 等扩展为 Agent 提供专用渲染器(displayName/运行统计),ccstyle 必须保留,不能 wrap。
2926
- const DEDICATED_RENDERER_TOOLS = new Set(["Agent"]);
2927
-
2928
- type ToolRenderMethods = {
2929
- hasRendererDefinition: (...args: any[]) => boolean;
2930
- getRenderShell: (...args: any[]) => "default" | "self";
2931
- getCallRenderer: (...args: any[]) => any;
2932
- getResultRenderer: (...args: any[]) => any;
2933
- };
2934
-
2935
- type GlobalToolRenderPatch = {
2936
- version: 2;
2937
- prototype: any;
2938
- owner: object;
2939
- active: boolean;
2940
- enabled: () => boolean;
2941
- mode: () => CompactStyleMode;
2942
- wrap: (tool: any) => any;
2943
- byDefinition: WeakMap<object, any>;
2944
- byName: Map<string, any>;
2945
- downstream: ToolRenderMethods;
2946
- installed: ToolRenderMethods;
2947
- // Keep the legacy aliases so an older extension build can read this Symbol.
2948
- originalHasRendererDefinition: ToolRenderMethods["hasRendererDefinition"];
2949
- originalGetRenderShell: ToolRenderMethods["getRenderShell"];
2950
- originalGetCallRenderer: ToolRenderMethods["getCallRenderer"];
2951
- originalGetResultRenderer: ToolRenderMethods["getResultRenderer"];
2952
- };
2953
-
2954
- export function isMcpToolDefinition(definition: any, toolName: string): boolean {
2955
- const label = typeof definition?.label === "string" ? definition.label.trim() : "";
2956
- if (/^MCP(?::|$)/i.test(label)) return true;
2957
- if (toolName === "mcp" || /^mcp[_:-]|[_:-]mcp[_:-]/i.test(toolName)) return true;
2958
- if (label) return false;
2959
- const description = typeof definition?.description === "string" ? definition.description : "";
2960
- return /\bModel Context Protocol\b/i.test(description);
2961
- }
2962
-
2963
- export function humanizeMcpToolName(toolName: string): string {
2964
- const words = toolName
2965
- .replace(/^mcp(?:[_:-]+)+/i, "")
2966
- .split(/[_:-]+/)
2967
- .filter(Boolean);
2968
- return words.length
2969
- ? words.map((word) => word[0]!.toUpperCase() + word.slice(1)).join(" ")
2970
- : "MCP";
2971
- }
2972
-
2973
- /** Return true when this tool must keep its original renderer. */
2974
- export function preservesOriginalRenderer(
2975
- extensionDefinition: any,
2976
- toolName: string,
2977
- builtInToolDefinition?: any,
2978
- excludeRenderers: readonly string[] = config.excludeRenderers,
2979
- ): boolean {
2980
- if (!excludeRenderers.includes(toolName)) return false;
2981
- return [extensionDefinition, builtInToolDefinition].some(
2982
- (definition) =>
2983
- definition?.renderShell === "self" ||
2984
- typeof definition?.renderCall === "function" ||
2985
- typeof definition?.renderResult === "function",
2986
- );
2987
- }
2988
-
2989
- function syncToolShell(component: any, shell: "default" | "self"): void {
2990
- const target = shell === "self" ? component.selfRenderContainer : component.contentBox;
2991
- if (!target || !Array.isArray(component.children)) return;
2992
- const candidates = new Set(
2993
- [component.contentText, component.contentBox, component.selfRenderContainer].filter(Boolean),
2994
- );
2995
- const indexes = component.children
2996
- .map((child: any, index: number) => (candidates.has(child) ? index : -1))
2997
- .filter((index: number) => index >= 0);
2998
- const targetIndex = indexes[0];
2999
- // During construction getRenderShell() runs immediately before Pi mounts the
3000
- // selected shell. Do not mount it here or the constructor will add it twice.
3001
- if (targetIndex === undefined) return;
3002
- component.children[targetIndex] = target;
3003
- for (const index of indexes.sort((left: number, right: number) => right - left)) {
3004
- if (index !== targetIndex) component.children.splice(index, 1);
3005
- }
3006
- }
3007
-
3008
- function shouldGloballyStyleTool(component: any, patch: GlobalToolRenderPatch): boolean {
3009
- const extensionDefinition = component.toolDefinition;
3010
- const builtInDefinition = component.builtInToolDefinition;
3011
- const definition = extensionDefinition ?? builtInDefinition;
3012
- const toolName = String(component.toolName || definition?.name || "");
3013
- const useCcstyle =
3014
- patch.mode() === "on" &&
3015
- !DEDICATED_RENDERER_TOOLS.has(toolName) &&
3016
- !preservesOriginalRenderer(extensionDefinition, toolName, builtInDefinition);
3017
- component[COMPONENT_TOOL_RENDER_MODE] = useCcstyle;
3018
- return useCcstyle;
3019
- }
3020
-
3021
- function shouldUseSelfShell(component: any, _patch: GlobalToolRenderPatch): boolean {
3022
- component[COMPONENT_TOOL_SELF_SHELL_MODE] = false;
3023
- return false;
3024
- }
3025
-
3026
- function getGloballyStyledTool(component: any, patch: GlobalToolRenderPatch): any {
3027
- const definition = component.toolDefinition ?? component.builtInToolDefinition;
3028
- if (definition && typeof definition === "object") {
3029
- let wrapped = patch.byDefinition.get(definition);
3030
- if (!wrapped) {
3031
- wrapped = patch.wrap(definition);
3032
- patch.byDefinition.set(definition, wrapped);
3033
- }
3034
- return wrapped;
3035
- }
3036
-
3037
- const name = String(component.toolName || "tool");
3038
- let wrapped = patch.byName.get(name);
3039
- if (!wrapped) {
3040
- wrapped = patch.wrap({ name, label: name });
3041
- patch.byName.set(name, wrapped);
3042
- }
3043
- return wrapped;
3044
- }
3045
-
3046
- function prototypeToolRenderMethods(prototype: any): ToolRenderMethods {
3047
- return {
3048
- hasRendererDefinition: prototype.hasRendererDefinition,
3049
- getRenderShell: prototype.getRenderShell,
3050
- getCallRenderer: prototype.getCallRenderer,
3051
- getResultRenderer: prototype.getResultRenderer,
3052
- };
3053
- }
3054
-
3055
- function isOwnershipAwarePatch(value: any): value is GlobalToolRenderPatch {
3056
- if (!value || value.version !== 2 || !value.installed || !value.downstream) return false;
3057
- return ["hasRendererDefinition", "getRenderShell", "getCallRenderer", "getResultRenderer"].every(
3058
- (name) =>
3059
- typeof value.installed[name] === "function" && typeof value.downstream[name] === "function",
3060
- );
3061
- }
3062
-
3063
- function isLegacyInstalledWrapper(method: unknown, downstreamField: string): boolean {
3064
- if (typeof method !== "function") return false;
3065
- try {
3066
- const source = Function.prototype.toString.call(method);
3067
- return (
3068
- source.includes(downstreamField) &&
3069
- (source.includes("shouldGloballyStyleTool") ||
3070
- source.includes("shouldUseSelfShell") ||
3071
- source.includes("getGloballyStyledTool"))
3072
- );
3073
- } catch {
3074
- return false;
3075
- }
3076
- }
3077
-
3078
- function downstreamForGlobalToolInstall(prototype: any, previous: any): ToolRenderMethods {
3079
- const current = prototypeToolRenderMethods(prototype);
3080
- if (!previous || previous.prototype !== prototype) return current;
3081
- if (isOwnershipAwarePatch(previous)) {
3082
- return {
3083
- hasRendererDefinition:
3084
- current.hasRendererDefinition === previous.installed.hasRendererDefinition
3085
- ? previous.downstream.hasRendererDefinition
3086
- : current.hasRendererDefinition,
3087
- getRenderShell:
3088
- current.getRenderShell === previous.installed.getRenderShell
3089
- ? previous.downstream.getRenderShell
3090
- : current.getRenderShell,
3091
- getCallRenderer:
3092
- current.getCallRenderer === previous.installed.getCallRenderer
3093
- ? previous.downstream.getCallRenderer
3094
- : current.getCallRenderer,
3095
- getResultRenderer:
3096
- current.getResultRenderer === previous.installed.getResultRenderer
3097
- ? previous.downstream.getResultRenderer
3098
- : current.getResultRenderer,
3099
- };
3100
- }
3101
-
3102
- // Pre-v2 Symbol state did not retain wrapper references. Recognize its known
3103
- // wrappers when possible; otherwise preserve the current method as external.
3104
- const legacyDownstream = (method: Function, field: string): Function => {
3105
- const saved = previous[field];
3106
- return typeof saved === "function" && isLegacyInstalledWrapper(method, field) ? saved : method;
3107
- };
3108
- return {
3109
- hasRendererDefinition: legacyDownstream(
3110
- current.hasRendererDefinition,
3111
- "originalHasRendererDefinition",
3112
- ) as ToolRenderMethods["hasRendererDefinition"],
3113
- getRenderShell: legacyDownstream(
3114
- current.getRenderShell,
3115
- "originalGetRenderShell",
3116
- ) as ToolRenderMethods["getRenderShell"],
3117
- getCallRenderer: legacyDownstream(
3118
- current.getCallRenderer,
3119
- "originalGetCallRenderer",
3120
- ) as ToolRenderMethods["getCallRenderer"],
3121
- getResultRenderer: legacyDownstream(
3122
- current.getResultRenderer,
3123
- "originalGetResultRenderer",
3124
- ) as ToolRenderMethods["getResultRenderer"],
3125
- };
3126
- }
3127
-
3128
- function disconnectGlobalToolRenderPatch(patch: any): void {
3129
- if (!patch || typeof patch !== "object") return;
3130
- patch.active = false;
3131
- patch.enabled = () => false;
3132
- patch.mode = () => "off";
3133
- patch.wrap = (tool: any) => tool;
3134
- patch.byDefinition = new WeakMap();
3135
- if (patch.byName && typeof patch.byName.clear === "function") patch.byName.clear();
3136
- else patch.byName = new Map();
3137
- }
3138
-
3139
- function installGlobalToolRendering(
3140
- writeExecutionMetadata: WriteExecutionMetadataStore,
3141
- ): GlobalToolRenderPatch {
3142
- const prototype = (ToolExecutionComponent as any).prototype;
3143
- const host = globalThis as any;
3144
- const previous = host[GLOBAL_TOOL_RENDER_PATCH];
3145
- const downstream = downstreamForGlobalToolInstall(prototype, previous);
3146
- // Any wrapper retained by an external owner must become a callback-free
3147
- // pass-through before the new installation is linked above it.
3148
- disconnectGlobalToolRenderPatch(previous);
3149
-
3150
- const patch: GlobalToolRenderPatch = {
3151
- version: 2,
3152
- prototype,
3153
- owner: {},
3154
- active: true,
3155
- enabled: () => config.mode === "on",
3156
- mode: () => config.mode,
3157
- wrap: (tool: any) => createCcstyleTool(tool, writeExecutionMetadata),
3158
- byDefinition: new WeakMap(),
3159
- byName: new Map(),
3160
- downstream,
3161
- installed: undefined as any,
3162
- originalHasRendererDefinition: downstream.hasRendererDefinition,
3163
- originalGetRenderShell: downstream.getRenderShell,
3164
- originalGetCallRenderer: downstream.getCallRenderer,
3165
- originalGetResultRenderer: downstream.getResultRenderer,
3166
- };
3167
-
3168
- patch.installed = {
3169
- hasRendererDefinition: function (this: any, ...args: any[]) {
3170
- if (patch.active && shouldGloballyStyleTool(this, patch)) return true;
3171
- return patch.downstream.hasRendererDefinition.apply(this, args);
3172
- },
3173
- getRenderShell: function (this: any, ...args: any[]) {
3174
- if (!patch.active) return patch.downstream.getRenderShell.apply(this, args);
3175
- const useSelfShell = shouldUseSelfShell(this, patch);
3176
- const useCcstyle = shouldGloballyStyleTool(this, patch);
3177
- const shell =
3178
- useSelfShell || (useCcstyle && !this.expanded)
3179
- ? "self"
3180
- : useCcstyle
3181
- ? "default"
3182
- : patch.downstream.getRenderShell.apply(this, args);
3183
- syncToolShell(this, shell);
3184
- return shell;
3185
- },
3186
- getCallRenderer: function (this: any, ...args: any[]) {
3187
- if (patch.active && shouldGloballyStyleTool(this, patch)) {
3188
- return getGloballyStyledTool(this, patch).renderCall;
3189
- }
3190
- return patch.downstream.getCallRenderer.apply(this, args);
3191
- },
3192
- getResultRenderer: function (this: any, ...args: any[]) {
3193
- if (patch.active && shouldGloballyStyleTool(this, patch)) {
3194
- return getGloballyStyledTool(this, patch).renderResult;
3195
- }
3196
- return patch.downstream.getResultRenderer.apply(this, args);
3197
- },
3198
- };
3199
-
3200
- prototype.hasRendererDefinition = patch.installed.hasRendererDefinition;
3201
- prototype.getRenderShell = patch.installed.getRenderShell;
3202
- prototype.getCallRenderer = patch.installed.getCallRenderer;
3203
- prototype.getResultRenderer = patch.installed.getResultRenderer;
3204
- host[GLOBAL_TOOL_RENDER_PATCH] = patch;
3205
- return patch;
3206
- }
3207
-
3208
- function deactivateGlobalToolRendering(patch: GlobalToolRenderPatch): void {
3209
- if (!patch.active) return;
3210
- disconnectGlobalToolRenderPatch(patch);
3211
- const prototype = patch.prototype;
3212
- if (prototype.hasRendererDefinition === patch.installed.hasRendererDefinition) {
3213
- prototype.hasRendererDefinition = patch.downstream.hasRendererDefinition;
3214
- }
3215
- if (prototype.getRenderShell === patch.installed.getRenderShell) {
3216
- prototype.getRenderShell = patch.downstream.getRenderShell;
3217
- }
3218
- if (prototype.getCallRenderer === patch.installed.getCallRenderer) {
3219
- prototype.getCallRenderer = patch.downstream.getCallRenderer;
3220
- }
3221
- if (prototype.getResultRenderer === patch.installed.getResultRenderer) {
3222
- prototype.getResultRenderer = patch.downstream.getResultRenderer;
3223
- }
3224
- }
3225
-
3226
- const GLOBAL_COMPACTION_RENDER_PATCH = Symbol.for("pi.ccstyle.compaction-render-patch");
3227
-
3228
- type LegacyCompactionRenderPatch = {
3229
- enabled?: () => boolean;
3230
- };
3231
-
3232
- /** Disable the pre-native compaction monkey patch left alive by /reload. */
3233
- function deactivateLegacyCompactionRendering() {
3234
- const patch = (globalThis as any)[GLOBAL_COMPACTION_RENDER_PATCH] as
3235
- | LegacyCompactionRenderPatch
3236
- | undefined;
3237
- if (patch) patch.enabled = () => false;
3238
- }
3239
-
3240
- export default function (
3241
- pi: ExtensionAPI,
3242
- configOverride?: Partial<Config>,
3243
- compactThinking?: CompactThinkingController,
3244
- ) {
3245
- // The optional override keeps integration tests independent from the user's global config.
3246
- if (configOverride) config = normalizeConfig({ ...config, ...configOverride });
3247
- const writeExecutionMetadata = new WriteExecutionMetadataStore();
3248
- let installation:
3249
- | {
3250
- globalToolRendering: GlobalToolRenderPatch;
3251
- toolGrouping: ToolGroupingHooks;
3252
- }
3253
- | undefined;
3254
- const ensureTuiInstallation = (ctx: any) => {
3255
- if (ctx?.mode !== "tui" || !ctx?.hasUI) return undefined;
3256
- // 渲染层(工具样式/分组)是原型与组件级 patch,fullscreen 官方布局
3257
- // 同样渲染这些组件,因此两种模式都安装。
3258
- if (installation) return installation;
3259
- const globalToolRendering = installGlobalToolRendering(writeExecutionMetadata);
3260
- const toolGrouping = installToolGrouping(() => config.mode === "on");
3261
- deactivateLegacyCompactionRendering();
3262
- installation = { globalToolRendering, toolGrouping };
3263
- return installation;
3264
- };
3265
-
3266
- pi.registerCommand("ccstyle", {
3267
- description: "Configure Claude Code style and rich diff options",
3268
- getArgumentCompletions: (prefix) => {
3269
- const topLevel = [
3270
- { value: "on", label: "on", description: "Enable Claude Code style" },
3271
- { value: "off", label: "off", description: "Use Pi's native renderer" },
3272
- { value: "status", label: "status", description: "Show full configuration" },
3273
- { value: "panel", label: "panel", description: "Open interactive settings panel" },
3274
- ];
3275
- return topLevel.filter((item) => item.value.startsWith(prefix));
3276
- },
3277
- handler: async (args, ctx) => {
3278
- const arg = args.trim().toLowerCase();
3279
- if (!arg || arg === "panel" || arg === "on" || arg === "off") {
3280
- if (ctx?.mode !== "tui" || !ctx?.hasUI) {
3281
- ctx.ui?.notify?.("/ccstyle requires TUI mode", "warning");
3282
- return;
3283
- }
3284
- const hooks = ensureTuiInstallation(ctx);
3285
- if (!hooks) return;
3286
- if (!arg || arg === "panel") {
3287
- await showCcstylePanel(ctx, hooks.toolGrouping, compactThinking);
3288
- } else {
3289
- applyStyleMode(arg, ctx, hooks.toolGrouping);
3290
- }
3291
- return;
3292
- }
3293
- if (arg === "status") {
3294
- ctx.ui.notify(`Claude Code style: ${formatConfigStatus(config)}`, "info");
3295
- return;
3296
- }
3297
- ctx.ui.notify("Usage: /ccstyle [on|off|status|panel]", "warning");
3298
- },
3299
- });
3300
-
3301
- pi.on("session_start", async (event, ctx) => {
3302
- // 延迟到 session_start 注册 write override:加载阶段 getAllTools 不可用且其他扩展
3303
- // 尚未注册工具,无法检测外部 write 所有者(如 pi-spark),直接注册会与对方撞名。
3304
- // session_start 时所有扩展已加载完毕,installWriteOverride 内部会检测并让位。
3305
- installWriteOverride(pi, writeExecutionMetadata);
3306
- const hooks = ensureTuiInstallation(ctx);
3307
- // 鼠标交互独立于渲染层:fullscreen 渲染层让位(hooks undefined)但
3308
- // 工具点击/回到底部适配仍需安装;保持在渲染层安装之后以维持原顺序。
3309
- if (ctx?.mode === "tui" && ctx?.hasUI) installToolMouseInteraction(ctx);
3310
- if (!hooks) return;
3311
- hooks.toolGrouping.setTheme(ctx.ui.theme);
3312
- ctx.ui.setStatus("ccstyle", undefined);
3313
- scheduleSessionRender();
3314
- });
3315
-
3316
- pi.on("session_compact", async (event, ctx) => {
3317
- const hooks = ensureTuiInstallation(ctx);
3318
- // Compaction rebuilds the transcript without session_start. Rebind after
3319
- // other TUI extensions may have replaced the root input dispatcher.
3320
- if (ctx?.mode === "tui" && ctx?.hasUI) installToolMouseInteraction(ctx);
3321
- if (!hooks) return;
3322
- hooks.toolGrouping.setTheme(ctx.ui.theme);
3323
- scheduleSessionRender();
3324
- });
3325
-
3326
- pi.on("tool_execution_start", async (event, ctx) => {
3327
- installation?.toolGrouping.setTheme(ctx.ui.theme);
3328
- });
3329
-
3330
- pi.on("session_shutdown", async (event, ctx) => {
3331
- writeExecutionMetadata.clear();
3332
- // 鼠标交互独立于渲染层:fullscreen 下 installation 为 undefined,
3333
- // 但 onTerminalInput 监听与 handleViewportInput 包装仍需释放。
3334
- teardownToolMouseInteraction();
3335
- const current = installation;
3336
- if (
3337
- !current ||
3338
- (globalThis as any)[GLOBAL_TOOL_RENDER_PATCH] !== current.globalToolRendering ||
3339
- !current.globalToolRendering.active
3340
- )
3341
- return;
3342
- deactivateGlobalToolRendering(current.globalToolRendering);
3343
- current.toolGrouping.shutdown();
3344
- deactivateLegacyCompactionRendering();
3345
- clearAllAnimations();
3346
- installation = undefined;
3347
- });
3348
- }