pi-one-ui 0.1.1

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 (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -0
  3. package/extensions/app/config/renderer.ts +452 -0
  4. package/extensions/app/config/shell.ts +2484 -0
  5. package/extensions/app/ownership.ts +18 -0
  6. package/extensions/app/panel.ts +533 -0
  7. package/extensions/app/presets.ts +36 -0
  8. package/extensions/features/agent-summary/core.ts +194 -0
  9. package/extensions/features/agent-summary/index.ts +37 -0
  10. package/extensions/features/compact-thinking.ts +1262 -0
  11. package/extensions/features/context.ts +857 -0
  12. package/extensions/features/reference/index.ts +643 -0
  13. package/extensions/features/reference/session.ts +278 -0
  14. package/extensions/features/reference/subagent.ts +218 -0
  15. package/extensions/features/shell/aliases.ts +28 -0
  16. package/extensions/features/shell/flush-docked-bash.ts +39 -0
  17. package/extensions/features/shell/startup-header.ts +357 -0
  18. package/extensions/features/shell/working-message.ts +226 -0
  19. package/extensions/index.ts +52 -0
  20. package/extensions/shell/accent-rail-editor.ts +112 -0
  21. package/extensions/shell/accent-rail-layout-patch.ts +530 -0
  22. package/extensions/shell/completion-menu.ts +186 -0
  23. package/extensions/shell/editor-metadata-format.ts +287 -0
  24. package/extensions/shell/editor-transfer.ts +73 -0
  25. package/extensions/shell/extension-status.ts +102 -0
  26. package/extensions/shell/footer-format.ts +388 -0
  27. package/extensions/shell/footer-layout.ts +144 -0
  28. package/extensions/shell/footer.ts +1035 -0
  29. package/extensions/shell/format.ts +577 -0
  30. package/extensions/shell/git.ts +434 -0
  31. package/extensions/shell/icons.ts +261 -0
  32. package/extensions/shell/index.ts +1659 -0
  33. package/extensions/shell/interaction-summary.ts +1194 -0
  34. package/extensions/shell/live-context.ts +82 -0
  35. package/extensions/shell/minimalist-editor.ts +548 -0
  36. package/extensions/shell/package-version.ts +675 -0
  37. package/extensions/shell/project-refresh.ts +130 -0
  38. package/extensions/shell/project-state.ts +60 -0
  39. package/extensions/shell/prototype-patch-registry.ts +183 -0
  40. package/extensions/shell/runtime.ts +915 -0
  41. package/extensions/shell/selector-border.ts +109 -0
  42. package/extensions/shell/session-lifecycle.ts +60 -0
  43. package/extensions/shell/settings-command.ts +2144 -0
  44. package/extensions/shell/settings-previews.ts +137 -0
  45. package/extensions/shell/state.ts +83 -0
  46. package/extensions/shell/style.ts +480 -0
  47. package/extensions/shell/telemetry.ts +90 -0
  48. package/extensions/shell/ui.ts +1326 -0
  49. package/extensions/shell/user-message-osc.ts +279 -0
  50. package/extensions/shell/user-message-styles.ts +266 -0
  51. package/extensions/shell/user-message.ts +226 -0
  52. package/extensions/shell/working-line-messages.ts +24 -0
  53. package/extensions/shell/working-line-spinners.ts +54 -0
  54. package/extensions/shell/working-line.ts +1603 -0
  55. package/extensions/tools/ansi-color.ts +44 -0
  56. package/extensions/tools/ansi-text.ts +52 -0
  57. package/extensions/tools/component-tree.ts +37 -0
  58. package/extensions/tools/format.ts +28 -0
  59. package/extensions/tools/fullscreen-detect.ts +11 -0
  60. package/extensions/tools/patch-keys.ts +158 -0
  61. package/extensions/tools/sgr-mouse.ts +59 -0
  62. package/extensions/tools/tool-loading-icon.ts +20 -0
  63. package/extensions/tools/tool-result-sanitize.ts +27 -0
  64. package/extensions/transcript/index.ts +65 -0
  65. package/extensions/transcript/renderer/compact-mode.ts +1443 -0
  66. package/extensions/transcript/renderer/default-mode.ts +735 -0
  67. package/extensions/transcript/renderer/index.ts +286 -0
  68. package/extensions/transcript/renderer/markdown-enhance.ts +325 -0
  69. package/extensions/transcript/renderer/mouse/hover.ts +175 -0
  70. package/extensions/transcript/renderer/mouse/interaction.ts +1016 -0
  71. package/extensions/transcript/renderer/mouse/layout.ts +207 -0
  72. package/extensions/transcript/renderer/mouse/packets.ts +96 -0
  73. package/extensions/transcript/renderer/mouse/scroll.ts +211 -0
  74. package/extensions/transcript/renderer/tool/diff/ATTRIBUTION.md +28 -0
  75. package/extensions/transcript/renderer/tool/diff/ansi-utils.ts +128 -0
  76. package/extensions/transcript/renderer/tool/diff/diff-component.ts +112 -0
  77. package/extensions/transcript/renderer/tool/diff/diff-edit-render.ts +255 -0
  78. package/extensions/transcript/renderer/tool/diff/diff-header.ts +184 -0
  79. package/extensions/transcript/renderer/tool/diff/diff-highlight.ts +131 -0
  80. package/extensions/transcript/renderer/tool/diff/diff-inline.ts +350 -0
  81. package/extensions/transcript/renderer/tool/diff/diff-layout.ts +870 -0
  82. package/extensions/transcript/renderer/tool/diff/diff-limits.ts +189 -0
  83. package/extensions/transcript/renderer/tool/diff/diff-palette.ts +436 -0
  84. package/extensions/transcript/renderer/tool/diff/diff-parse.ts +536 -0
  85. package/extensions/transcript/renderer/tool/diff/diff-presentation.ts +79 -0
  86. package/extensions/transcript/renderer/tool/diff/diff-renderer.ts +29 -0
  87. package/extensions/transcript/renderer/tool/diff/diff-text.ts +57 -0
  88. package/extensions/transcript/renderer/tool/diff/diff-write-render.ts +629 -0
  89. package/extensions/transcript/renderer/tool/diff/index.ts +147 -0
  90. package/extensions/transcript/renderer/tool/diff/line-width-safety.ts +100 -0
  91. package/extensions/transcript/renderer/tool/diff/shiki-highlight.ts +104 -0
  92. package/extensions/transcript/renderer/tool/diff/write-execution.ts +124 -0
  93. package/extensions/transcript/renderer/tool/grouping.ts +619 -0
  94. package/extensions/transcript/renderer/tool/message-display.ts +183 -0
  95. package/extensions/transcript/renderer/tool/names.ts +195 -0
  96. package/extensions/transcript/renderer/tool/result.ts +842 -0
  97. package/extensions/transcript/renderer/tool/show-more-hint.ts +42 -0
  98. package/extensions/transcript/renderer/transcript-refresh.ts +79 -0
  99. package/package.json +58 -0
  100. package/themes/cc-dark.json +91 -0
  101. package/themes/cc-light.json +91 -0
@@ -0,0 +1,619 @@
1
+ import {
2
+ AssistantMessageComponent,
3
+ ToolExecutionComponent,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ Container,
7
+ Spacer,
8
+ truncateToWidth,
9
+ visibleWidth,
10
+ type Component,
11
+ } from "@earendil-works/pi-tui";
12
+ import {
13
+ TOOL_LOADING_INTERVAL_MS,
14
+ toolLoadingIcon,
15
+ } from "../../../tools/tool-loading-icon.ts";
16
+ import { isToolTuiFullscreen, showMoreHintText } from "./show-more-hint.ts";
17
+ import {
18
+ stripAnsi,
19
+ stripBackgroundAnsi,
20
+ stripLeadingStatusIcon,
21
+ } from "../../../tools/ansi-text.ts";
22
+ import { walkComponentTree } from "../../../tools/component-tree.ts";
23
+ import { humanizeToolLabel, toolCallSummary } from "./names.ts";
24
+ import {
25
+ patchRegistry,
26
+ TOOL_GROUPING_GENERATION_KEY as GENERATION_KEY,
27
+ TOOL_GROUPING_PARENT_KEY as PARENT_KEY,
28
+ TOOL_GROUPING_PATCH_KEY as PATCH_KEY,
29
+ } from "../../../tools/patch-keys.ts";
30
+
31
+ const NON_GROUPABLE = new Set(["edit", "write", "apply_patch"]);
32
+
33
+ type Patch = {
34
+ owner: object;
35
+ active: boolean;
36
+ prototype: any;
37
+ original: { addChild: Function; removeChild: Function; clear: Function };
38
+ installed: { addChild: Function; removeChild: Function; clear: Function };
39
+ groups: Set<ToolGroupComponent>;
40
+ enabled: () => boolean;
41
+ generation: number;
42
+ lastEnabled: boolean;
43
+ theme?: any;
44
+ animationTimer: ReturnType<typeof setTimeout> | null;
45
+ };
46
+
47
+ function toolName(tool: any): string {
48
+ return String(tool?.toolName ?? tool?.toolDefinition?.name ?? "tool");
49
+ }
50
+
51
+ function isGroupable(value: unknown): boolean {
52
+ return (
53
+ value instanceof ToolExecutionComponent &&
54
+ !NON_GROUPABLE.has(toolName(value))
55
+ );
56
+ }
57
+
58
+ function isIgnorable(value: unknown): boolean {
59
+ if (value instanceof Spacer) return true;
60
+ if (!(value instanceof AssistantMessageComponent)) return false;
61
+ const children = (value as any).contentContainer?.children;
62
+ return Array.isArray(children) && children.length === 0;
63
+ }
64
+
65
+ function previousSibling(
66
+ children: any[],
67
+ start: number,
68
+ ): { child: any; index: number } | undefined {
69
+ let skipped = 0;
70
+ for (let index = start; index >= 0; index--) {
71
+ const child = children[index];
72
+ if (isIgnorable(child) && skipped < 3) {
73
+ skipped++;
74
+ continue;
75
+ }
76
+ return { child, index };
77
+ }
78
+ return undefined;
79
+ }
80
+
81
+ type ToolStatus = "pending" | "success" | "error";
82
+
83
+ function status(tool: any): ToolStatus {
84
+ if (tool?.result?.isError) return "error";
85
+ if (tool?.isPartial === true || (tool?.executionStarted && !tool?.result))
86
+ return "pending";
87
+ return tool?.result ? "success" : "pending";
88
+ }
89
+
90
+ function statusIcon(value: ToolStatus): string {
91
+ if (value === "success") return "✓";
92
+ if (value === "error") return "✗";
93
+ return toolLoadingIcon();
94
+ }
95
+
96
+ function scheduleGroupAnimation(patch: Patch): void {
97
+ if (patch.animationTimer || !patch.active) return;
98
+ patch.animationTimer = setTimeout(() => {
99
+ patch.animationTimer = null;
100
+ if (!patch.active) return;
101
+ for (const group of patch.groups) {
102
+ if (
103
+ (group.children as any[]).some(
104
+ (tool) => tool?.executionStarted && status(tool) === "pending",
105
+ )
106
+ )
107
+ group.invalidate();
108
+ }
109
+ }, TOOL_LOADING_INTERVAL_MS);
110
+ patch.animationTimer.unref?.();
111
+ }
112
+
113
+ function visibleLines(lines: string[]): string[] {
114
+ return lines.filter((line) => stripAnsi(line).trim());
115
+ }
116
+
117
+ function stripLeadingSpaces(line: string, count: number): string {
118
+ let offset = 0;
119
+ let removed = 0;
120
+ let ansi = "";
121
+ while (offset < line.length) {
122
+ const control = line.slice(offset).match(/^\x1b\[[0-?]*[ -/]*[@-~]/)?.[0];
123
+ if (control) {
124
+ ansi += control;
125
+ offset += control.length;
126
+ continue;
127
+ }
128
+ if (removed < count && line[offset] === " ") {
129
+ removed++;
130
+ offset++;
131
+ continue;
132
+ }
133
+ break;
134
+ }
135
+ return ansi + line.slice(offset);
136
+ }
137
+
138
+ /** 生成一行铺满 width 的 slot 背景行;bgAnsiOverride 可替换背景 ANSI(用于提亮等)。 */
139
+ export function paddedBackgroundRow(
140
+ theme: any,
141
+ slot: string,
142
+ content: string,
143
+ width: number,
144
+ bgAnsiOverride?: string,
145
+ ): string {
146
+ const innerWidth = Math.max(0, width - 2);
147
+ const clipped = truncateToWidth(stripBackgroundAnsi(content), innerWidth, "");
148
+ const row = ` ${clipped}${" ".repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} `;
149
+ const bgAnsi =
150
+ bgAnsiOverride ||
151
+ (typeof theme?.bg === "function"
152
+ ? theme.getBgAnsi?.(slot) ||
153
+ theme.bg(slot, "").match(/^\x1b\[[0-?]*[ -/]*[@-~]/)?.[0] ||
154
+ ""
155
+ : "");
156
+ const stable = bgAnsi
157
+ ? row.replace(/\x1b\[(?:0)?m/g, (reset) => reset + bgAnsi)
158
+ : row;
159
+ if (!bgAnsi)
160
+ return typeof theme?.bg === "function" ? theme.bg(slot, stable) : row;
161
+ return `${bgAnsi}${stable}\x1b[49m`;
162
+ }
163
+
164
+ function toolSummary(tool: any): { main: string; detail: string } {
165
+ return toolCallSummary(toolName(tool), tool?.args ?? {}, {
166
+ variant: "grouping",
167
+ });
168
+ }
169
+
170
+ function toolNameList(tools: any[]): string {
171
+ const counts = new Map<string, number>();
172
+ for (const tool of tools)
173
+ counts.set(toolName(tool), (counts.get(toolName(tool)) ?? 0) + 1);
174
+ return [...counts]
175
+ .map(([name, count]) => `${name}${count > 1 ? `×${count}` : ""}`)
176
+ .join(", ");
177
+ }
178
+
179
+ let nextGroupId = 1;
180
+
181
+ type SettledGroupCache = {
182
+ width: number;
183
+ hover: boolean;
184
+ theme: unknown;
185
+ fullscreen: boolean;
186
+ children: readonly unknown[];
187
+ args: unknown[];
188
+ results: unknown[];
189
+ lines: string[];
190
+ };
191
+
192
+ export class ToolGroupComponent extends Container {
193
+ readonly toolCallId = `ccstyle-tool-group-${nextGroupId++}`;
194
+ readonly toolName = "Tool group";
195
+ private _expanded = false;
196
+ /** 分组是否展开(只读;测试与外部读状态用)。 */
197
+ get expanded(): boolean {
198
+ return this._expanded;
199
+ }
200
+ private hintHovered = false;
201
+ private readonly patch: Patch;
202
+ /** 仅缓存已完成且折叠的分组;pending / expanded 每帧现算。 */
203
+ private settledCache: SettledGroupCache | undefined;
204
+
205
+ constructor(patch: Patch) {
206
+ super();
207
+ this.patch = patch;
208
+ patch.groups.add(this);
209
+ }
210
+
211
+ addTool(tool: any): void {
212
+ this.settledCache = undefined;
213
+ this.children.push(tool);
214
+ tool[PARENT_KEY] = this;
215
+ }
216
+
217
+ releaseTools(): any[] {
218
+ this.settledCache = undefined;
219
+ const tools = [...this.children];
220
+ this.children.length = 0;
221
+ this.patch.groups.delete(this);
222
+ return tools;
223
+ }
224
+
225
+ removeTool(tool: any): void {
226
+ this.settledCache = undefined;
227
+ const index = this.children.indexOf(tool);
228
+ if (index >= 0) this.children.splice(index, 1);
229
+ if (tool?.[PARENT_KEY] === this) delete tool[PARENT_KEY];
230
+ }
231
+
232
+ setExpanded(expanded: boolean): void {
233
+ if (this._expanded !== expanded) this.settledCache = undefined;
234
+ this._expanded = expanded;
235
+ for (const tool of this.children)
236
+ (
237
+ tool as Component & { setExpanded?: (expanded: boolean) => void }
238
+ ).setExpanded?.(expanded);
239
+ }
240
+
241
+ setHintHovered(hovered: boolean): void {
242
+ if (this.hintHovered !== hovered) this.settledCache = undefined;
243
+ this.hintHovered = hovered;
244
+ }
245
+
246
+ /**
247
+ * 展开时按局部行定位内部组件(null = 行属于 group 自身:空行/头行/尾行)。
248
+ * 行数计算与 render 保持一致:宽度 width-2 + 空行过滤。
249
+ */
250
+ childAtRow(
251
+ localRow: number,
252
+ width: number,
253
+ ): { component: any; row: number } | null {
254
+ if (!this._expanded || localRow < 2) return null;
255
+ let offset = 2;
256
+ for (const tool of this.children) {
257
+ let lines: string[] = [];
258
+ try {
259
+ const rendered = tool.render?.(Math.max(1, width - 2));
260
+ if (Array.isArray(rendered))
261
+ lines = visibleLines(rendered.map((line) => String(line)));
262
+ } catch {
263
+ lines = [];
264
+ }
265
+ const lineCount = Math.max(1, lines.length);
266
+ if (localRow < offset + lineCount) {
267
+ return { component: tool, row: localRow - offset };
268
+ }
269
+ offset += lineCount;
270
+ }
271
+ return null;
272
+ }
273
+
274
+ invalidate(): void {
275
+ this.settledCache = undefined;
276
+ for (const tool of this.children) tool.invalidate?.();
277
+ }
278
+
279
+ private settledCacheHit(width: number): string[] | undefined {
280
+ const cache = this.settledCache;
281
+ if (!cache || this._expanded) return;
282
+ if (
283
+ cache.width !== width ||
284
+ cache.hover !== this.hintHovered ||
285
+ cache.theme !== this.patch.theme ||
286
+ cache.fullscreen !== isToolTuiFullscreen()
287
+ ) {
288
+ return;
289
+ }
290
+ const tools = this.children as any[];
291
+ if (cache.children.length !== tools.length) return;
292
+ for (let i = 0; i < tools.length; i++) {
293
+ const tool = tools[i];
294
+ if (
295
+ cache.children[i] !== tool ||
296
+ cache.args[i] !== tool?.args ||
297
+ cache.results[i] !== tool?.result ||
298
+ status(tool) === "pending"
299
+ ) {
300
+ return;
301
+ }
302
+ }
303
+ return cache.lines;
304
+ }
305
+
306
+ private storeSettledCache(width: number, lines: string[]): void {
307
+ this.settledCache = {
308
+ width,
309
+ hover: this.hintHovered,
310
+ theme: this.patch.theme,
311
+ fullscreen: isToolTuiFullscreen(),
312
+ children: [...this.children],
313
+ args: (this.children as any[]).map((tool) => tool?.args),
314
+ results: (this.children as any[]).map((tool) => tool?.result),
315
+ lines,
316
+ };
317
+ }
318
+
319
+ render(width: number): string[] {
320
+ const cached = this.settledCacheHit(width);
321
+ if (cached) return cached;
322
+ const theme = this.patch.theme;
323
+ const fg = (color: string, text: string) =>
324
+ theme?.fg?.(color, text) ?? text;
325
+ const counts = { pending: 0, success: 0, error: 0 };
326
+ for (const tool of this.children) counts[status(tool)]++;
327
+ const countText = (["pending", "success", "error"] as const)
328
+ .filter((key) => counts[key])
329
+ .map((key) => {
330
+ const label =
331
+ key === "pending" ? "running" : key === "success" ? "done" : "failed";
332
+ const color = key === "pending" ? "accent" : key;
333
+ return `${fg(color, String(counts[key]))} ${label}`;
334
+ })
335
+ .join(` ${fg("dim", "•")} `);
336
+ const names = new Set(this.children.map(toolName));
337
+ const label =
338
+ names.size === 1
339
+ ? humanizeToolLabel(toolName(this.children[0]))
340
+ : "Multiple Tools";
341
+ const overall: ToolStatus = counts.error
342
+ ? "error"
343
+ : counts.pending
344
+ ? "pending"
345
+ : "success";
346
+ if (
347
+ (this.children as any[]).some(
348
+ (tool) => tool?.executionStarted && status(tool) === "pending",
349
+ )
350
+ )
351
+ scheduleGroupAnimation(this.patch);
352
+ const overallColor = overall === "pending" ? "accent" : overall;
353
+ const nameList =
354
+ names.size > 1 ? ` ${fg("dim", `• ${toolNameList(this.children)}`)}` : "";
355
+ // 圆点保持 dim;hover 只高亮可点击文字。
356
+ const hint = `${fg("dim", "•")} ${fg(this.hintHovered ? "text" : "dim", showMoreHintText())}`;
357
+ const lines = [
358
+ "",
359
+ truncateToWidth(
360
+ ` ${fg(overallColor, "●")} ${label}: ${countText}${nameList} ${hint}`,
361
+ width,
362
+ "…",
363
+ ),
364
+ ];
365
+ const total = this.children.length;
366
+ const expandedLines: string[] = [];
367
+ for (let index = 0; index < total; index++) {
368
+ const tool = this.children[index];
369
+ const toolStatus = status(tool);
370
+ const color = toolStatus === "pending" ? "accent" : toolStatus;
371
+ const branch = index === total - 1 ? "└" : "├";
372
+ const continuation = index === total - 1 ? " " : "│ ";
373
+ if (!this._expanded) {
374
+ const summary = toolSummary(tool);
375
+ lines.push(
376
+ truncateToWidth(
377
+ ` ${fg("dim", branch)} ${fg(color, statusIcon(toolStatus))} ${fg("toolTitle", summary.main)}${fg("dim", summary.detail)}`,
378
+ width,
379
+ "…",
380
+ ),
381
+ );
382
+ continue;
383
+ }
384
+ const rendered = visibleLines(tool.render(Math.max(1, width - 2)));
385
+ if (rendered.length) {
386
+ rendered[0] = stripLeadingStatusIcon(rendered[0])
387
+ .replace(/^ +/, "")
388
+ .replace(/^((?:\x1b\[[0-?]*[ -/]*[@-~])*) +/, "$1");
389
+ }
390
+ const childLines = rendered.length ? rendered : [toolSummary(tool).main];
391
+ for (let lineIndex = 0; lineIndex < childLines.length; lineIndex++) {
392
+ const content =
393
+ // 续行只剥外层 Box 的 1 格 left pad,保留 Input/Output 相对缩进
394
+ lineIndex === 0
395
+ ? childLines[lineIndex]
396
+ : stripLeadingSpaces(childLines[lineIndex], 1);
397
+ const prefix =
398
+ lineIndex === 0
399
+ ? `${fg("dim", branch)} ${fg(color, statusIcon(toolStatus))} `
400
+ : fg("dim", continuation);
401
+ expandedLines.push(prefix + content);
402
+ }
403
+ }
404
+ if (this._expanded) {
405
+ // 展开面板统一用 user message 背景色(ccstyle 约定),不按状态区分。
406
+ const backgroundSlot = "userMessageBg";
407
+ for (const line of expandedLines) {
408
+ lines.push(paddedBackgroundRow(theme, backgroundSlot, line, width));
409
+ }
410
+ lines.push(paddedBackgroundRow(theme, backgroundSlot, "", width));
411
+ } else if (counts.pending === 0) {
412
+ this.storeSettledCache(width, lines);
413
+ }
414
+ return lines;
415
+ }
416
+ }
417
+
418
+ function ungroup(patch: Patch): void {
419
+ for (const group of [...patch.groups]) {
420
+ const parent = (group as any)[PARENT_KEY];
421
+ const children = parent?.children;
422
+ if (!Array.isArray(children)) {
423
+ patch.groups.delete(group);
424
+ continue;
425
+ }
426
+ const index = children.indexOf(group);
427
+ if (index < 0) {
428
+ patch.groups.delete(group);
429
+ continue;
430
+ }
431
+ const tools = group.releaseTools();
432
+ for (const tool of tools) tool[PARENT_KEY] = parent;
433
+ children.splice(index, 1, ...tools);
434
+ }
435
+ }
436
+
437
+ function normalizeGroup(patch: Patch, group: ToolGroupComponent): void {
438
+ if (group.children.length > 1) return;
439
+ const parent = (group as any)[PARENT_KEY];
440
+ const index = parent?.children?.indexOf(group) ?? -1;
441
+ const tools = group.releaseTools();
442
+ delete (group as any)[PARENT_KEY];
443
+ if (index < 0) {
444
+ for (const tool of tools) delete tool[PARENT_KEY];
445
+ return;
446
+ }
447
+ if (tools.length === 1) {
448
+ tools[0][PARENT_KEY] = parent;
449
+ parent.children.splice(index, 1, tools[0]);
450
+ } else {
451
+ parent.children.splice(index, 1);
452
+ }
453
+ }
454
+
455
+ function maybeGroup(patch: Patch, parent: any, component: any): void {
456
+ if (
457
+ !patch.active ||
458
+ !patch.enabled() ||
459
+ parent instanceof ToolGroupComponent ||
460
+ !isGroupable(component)
461
+ )
462
+ return;
463
+ component[GENERATION_KEY] = patch.generation;
464
+ const children = parent?.children;
465
+ if (!Array.isArray(children)) return;
466
+ const index = children.indexOf(component);
467
+ const prior = previousSibling(children, index - 1);
468
+ if (!prior) return;
469
+ if (
470
+ prior.child instanceof ToolGroupComponent &&
471
+ (prior.child as any).patch === patch
472
+ ) {
473
+ children.splice(index, 1);
474
+ prior.child.addTool(component);
475
+ return;
476
+ }
477
+ if (
478
+ !isGroupable(prior.child) ||
479
+ prior.child[GENERATION_KEY] !== patch.generation
480
+ )
481
+ return;
482
+ const group = new ToolGroupComponent(patch);
483
+ group.addTool(prior.child);
484
+ group.addTool(component);
485
+ (group as any)[PARENT_KEY] = parent;
486
+ children[prior.index] = group;
487
+ children.splice(index, 1);
488
+ }
489
+
490
+ /** /reload 不会重新 addChild;扫描当前 mounted roots,把已有工具重新送入同一分组规则。 */
491
+ function regroup(patch: Patch, root: any): void {
492
+ if (!patch.active || !patch.enabled() || !root) return;
493
+ walkComponentTree(root, (value: any) => {
494
+ // 分组卡与可分组工具是分组边界:不继续下钻(与原有遍历过滤一致)。
495
+ if (value instanceof ToolGroupComponent || isGroupable(value)) return false;
496
+ const children = value.children;
497
+ if (Array.isArray(children)) {
498
+ for (const child of [...children]) {
499
+ if (child && typeof child === "object") child[PARENT_KEY] = value;
500
+ maybeGroup(patch, value, child);
501
+ }
502
+ }
503
+ });
504
+ }
505
+
506
+ export type ToolGroupingHooks = {
507
+ setTheme(theme: any): void;
508
+ refresh(root?: any): void;
509
+ shutdown(): void;
510
+ };
511
+
512
+ export function installToolGrouping(
513
+ getEnabled: () => boolean,
514
+ ): ToolGroupingHooks {
515
+ const prototype = Container.prototype as any;
516
+ const previous = patchRegistry.get<Patch>(PATCH_KEY);
517
+ if (previous) {
518
+ previous.active = false;
519
+ previous.enabled = () => false;
520
+ if (previous.animationTimer) clearTimeout(previous.animationTimer);
521
+ previous.animationTimer = null;
522
+ ungroup(previous);
523
+ }
524
+ const original = {
525
+ addChild:
526
+ previous && prototype.addChild === previous.installed.addChild
527
+ ? previous.original.addChild
528
+ : prototype.addChild,
529
+ removeChild:
530
+ previous && prototype.removeChild === previous.installed.removeChild
531
+ ? previous.original.removeChild
532
+ : prototype.removeChild,
533
+ clear:
534
+ previous && prototype.clear === previous.installed.clear
535
+ ? previous.original.clear
536
+ : prototype.clear,
537
+ };
538
+ const patch: Patch = {
539
+ owner: {},
540
+ active: true,
541
+ prototype,
542
+ original,
543
+ installed: undefined as any,
544
+ groups: new Set(),
545
+ enabled: getEnabled,
546
+ generation: 0,
547
+ lastEnabled: getEnabled(),
548
+ animationTimer: null,
549
+ };
550
+ patch.installed = {
551
+ addChild: function (this: any, component: any) {
552
+ const result = patch.original.addChild.call(this, component);
553
+ if (component && typeof component === "object")
554
+ component[PARENT_KEY] = this;
555
+ maybeGroup(patch, this, component);
556
+ return result;
557
+ },
558
+ removeChild: function (this: any, component: any) {
559
+ const group = component?.[PARENT_KEY];
560
+ if (
561
+ group instanceof ToolGroupComponent &&
562
+ (group as any)[PARENT_KEY] === this
563
+ ) {
564
+ group.removeTool(component);
565
+ normalizeGroup(patch, group);
566
+ return;
567
+ }
568
+ const result = patch.original.removeChild.call(this, component);
569
+ if (component?.[PARENT_KEY] === this) delete component[PARENT_KEY];
570
+ if (this instanceof ToolGroupComponent) normalizeGroup(patch, this);
571
+ if (component instanceof ToolGroupComponent) {
572
+ for (const tool of component.releaseTools()) delete tool[PARENT_KEY];
573
+ }
574
+ return result;
575
+ },
576
+ clear: function (this: any) {
577
+ for (const child of [...(this.children ?? [])]) {
578
+ if (child instanceof ToolGroupComponent) {
579
+ for (const tool of child.releaseTools()) delete tool[PARENT_KEY];
580
+ }
581
+ if (child?.[PARENT_KEY] === this) delete child[PARENT_KEY];
582
+ }
583
+ if (this instanceof ToolGroupComponent) patch.groups.delete(this);
584
+ return patch.original.clear.call(this);
585
+ },
586
+ };
587
+ prototype.addChild = patch.installed.addChild;
588
+ prototype.removeChild = patch.installed.removeChild;
589
+ prototype.clear = patch.installed.clear;
590
+ patchRegistry.install(PATCH_KEY, patch);
591
+ return {
592
+ setTheme(theme: any) {
593
+ patch.theme = theme;
594
+ },
595
+ refresh(root?: any) {
596
+ const enabled = patch.enabled();
597
+ if (enabled !== patch.lastEnabled) {
598
+ patch.lastEnabled = enabled;
599
+ if (enabled) patch.generation++;
600
+ }
601
+ if (enabled) regroup(patch, root);
602
+ else ungroup(patch);
603
+ },
604
+ shutdown() {
605
+ if (!patch.active) return;
606
+ patch.active = false;
607
+ if (patch.animationTimer) clearTimeout(patch.animationTimer);
608
+ patch.animationTimer = null;
609
+ patch.enabled = () => false;
610
+ ungroup(patch);
611
+ if (prototype.addChild === patch.installed.addChild)
612
+ prototype.addChild = patch.original.addChild;
613
+ if (prototype.removeChild === patch.installed.removeChild)
614
+ prototype.removeChild = patch.original.removeChild;
615
+ if (prototype.clear === patch.installed.clear)
616
+ prototype.clear = patch.original.clear;
617
+ },
618
+ };
619
+ }