@oh-my-pi/pi-coding-agent 17.1.6 → 17.1.8

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 (130) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dist/{CHANGELOG-x9zt79k8.md → CHANGELOG-vt8ene9g.md} +58 -0
  3. package/dist/cli.js +4309 -7287
  4. package/dist/template-dys3vk5b.js +1671 -0
  5. package/dist/template-f8wx9vfn.css +1355 -0
  6. package/dist/template-qat058wr.html +55 -0
  7. package/dist/tool-views.generated-jdfmzwmn.js +35 -0
  8. package/dist/types/advisor/advise-tool.d.ts +0 -7
  9. package/dist/types/advisor/runtime.d.ts +0 -9
  10. package/dist/types/async/job-manager.d.ts +11 -0
  11. package/dist/types/cleanse/agent.d.ts +19 -0
  12. package/dist/types/cleanse/balance.d.ts +7 -0
  13. package/dist/types/cleanse/checkers.d.ts +13 -0
  14. package/dist/types/cleanse/index.d.ts +16 -0
  15. package/dist/types/cleanse/loop.d.ts +16 -0
  16. package/dist/types/cleanse/parsers.d.ts +13 -0
  17. package/dist/types/cleanse/progress.d.ts +14 -0
  18. package/dist/types/cleanse/types.d.ts +64 -0
  19. package/dist/types/commands/cleanse.d.ts +23 -0
  20. package/dist/types/config/settings-schema.d.ts +16 -1
  21. package/dist/types/cursor.d.ts +2 -1
  22. package/dist/types/export/html/index.d.ts +2 -0
  23. package/dist/types/extensibility/extensions/runner.d.ts +4 -0
  24. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +27 -1
  25. package/dist/types/extensibility/shared-events.d.ts +18 -1
  26. package/dist/types/modes/components/custom-editor.d.ts +5 -0
  27. package/dist/types/modes/interactive-mode.d.ts +4 -3
  28. package/dist/types/session/agent-session-types.d.ts +5 -5
  29. package/dist/types/session/agent-session.d.ts +26 -1
  30. package/dist/types/session/async-job-delivery.d.ts +8 -0
  31. package/dist/types/session/model-controls.d.ts +4 -4
  32. package/dist/types/session/session-tools.d.ts +44 -6
  33. package/dist/types/session/streaming-output.d.ts +7 -2
  34. package/dist/types/session/turn-recovery.d.ts +1 -1
  35. package/dist/types/task/isolation-ownership.d.ts +34 -0
  36. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +1 -2
  37. package/dist/types/tools/browser/tab-worker.d.ts +1 -4
  38. package/dist/types/tools/index.d.ts +8 -3
  39. package/dist/types/tools/output-meta.d.ts +5 -0
  40. package/dist/types/tools/read.d.ts +9 -1
  41. package/dist/types/tools/xdev.d.ts +53 -67
  42. package/dist/types/tui/output-block.d.ts +5 -5
  43. package/dist/types/utils/cpuprofile.d.ts +51 -0
  44. package/dist/types/utils/inspect-image-mode.d.ts +29 -0
  45. package/dist/types/utils/profile-tree.d.ts +47 -0
  46. package/dist/types/utils/sample-profile.d.ts +67 -0
  47. package/package.json +16 -12
  48. package/scripts/bundle-dist.ts +3 -1
  49. package/src/advisor/advise-tool.ts +0 -11
  50. package/src/advisor/runtime.ts +0 -12
  51. package/src/async/job-manager.ts +30 -7
  52. package/src/cleanse/agent.ts +226 -0
  53. package/src/cleanse/balance.ts +79 -0
  54. package/src/cleanse/checkers.ts +996 -0
  55. package/src/cleanse/index.ts +190 -0
  56. package/src/cleanse/loop.ts +51 -0
  57. package/src/cleanse/parsers.ts +726 -0
  58. package/src/cleanse/progress.ts +50 -0
  59. package/src/cleanse/prompts/assignment.md +47 -0
  60. package/src/cleanse/types.ts +72 -0
  61. package/src/cli/update-cli.ts +3 -0
  62. package/src/cli/usage-cli.ts +29 -4
  63. package/src/cli/worktree-cli.ts +28 -11
  64. package/src/cli-commands.ts +1 -0
  65. package/src/cli.ts +2 -1
  66. package/src/commands/cleanse.ts +45 -0
  67. package/src/config/settings-schema.ts +15 -1
  68. package/src/config/settings.ts +35 -0
  69. package/src/cursor.ts +4 -3
  70. package/src/discovery/builtin-rules/index.ts +2 -0
  71. package/src/discovery/builtin-rules/ts-no-local-is-record.md +48 -0
  72. package/src/discovery/claude-plugins.ts +144 -34
  73. package/src/export/html/index.ts +17 -10
  74. package/src/extensibility/extensions/runner.ts +26 -0
  75. package/src/extensibility/extensions/wrapper.ts +74 -42
  76. package/src/extensibility/hooks/tool-wrapper.ts +11 -4
  77. package/src/extensibility/legacy-pi-ai-shim.ts +46 -1
  78. package/src/extensibility/shared-events.ts +18 -1
  79. package/src/launch/broker.ts +14 -4
  80. package/src/modes/acp/acp-agent.ts +12 -9
  81. package/src/modes/acp/acp-event-mapper.ts +38 -4
  82. package/src/modes/components/custom-editor.ts +39 -16
  83. package/src/modes/components/settings-selector.ts +9 -1
  84. package/src/modes/components/tips.txt +2 -1
  85. package/src/modes/components/tool-execution.ts +8 -7
  86. package/src/modes/controllers/extension-ui-controller.ts +4 -4
  87. package/src/modes/controllers/selector-controller.ts +7 -2
  88. package/src/modes/interactive-mode.ts +36 -47
  89. package/src/modes/prompt-action-autocomplete.ts +5 -3
  90. package/src/prompts/goals/guided-goal-interview.md +41 -6
  91. package/src/prompts/system/vibe-mode-active.md +4 -1
  92. package/src/prompts/tools/browser.md +1 -0
  93. package/src/prompts/tools/goal.md +1 -1
  94. package/src/sdk.ts +47 -50
  95. package/src/session/agent-session-types.ts +5 -5
  96. package/src/session/agent-session.ts +151 -11
  97. package/src/session/async-job-delivery.ts +8 -0
  98. package/src/session/model-controls.ts +9 -9
  99. package/src/session/session-advisors.ts +2 -7
  100. package/src/session/session-history-format.ts +31 -6
  101. package/src/session/session-listing.ts +66 -4
  102. package/src/session/session-tools.ts +158 -52
  103. package/src/session/streaming-output.ts +18 -6
  104. package/src/session/turn-recovery.ts +4 -4
  105. package/src/slash-commands/builtin-registry.ts +74 -2
  106. package/src/task/isolation-ownership.ts +106 -0
  107. package/src/task/worktree.ts +8 -0
  108. package/src/tools/ask.ts +3 -3
  109. package/src/tools/ast-edit.ts +9 -2
  110. package/src/tools/bash.ts +16 -9
  111. package/src/tools/browser/cmux/cmux-tab.ts +9 -14
  112. package/src/tools/browser/tab-worker.ts +12 -35
  113. package/src/tools/browser.ts +2 -2
  114. package/src/tools/gh-renderer.ts +3 -3
  115. package/src/tools/index.ts +46 -17
  116. package/src/tools/output-meta.ts +20 -0
  117. package/src/tools/read.ts +87 -13
  118. package/src/tools/write.ts +51 -7
  119. package/src/tools/xdev.ts +198 -210
  120. package/src/tui/code-cell.ts +4 -4
  121. package/src/tui/output-block.ts +25 -8
  122. package/src/utils/cpuprofile.ts +235 -0
  123. package/src/utils/inspect-image-mode.ts +39 -0
  124. package/src/utils/profile-tree.ts +111 -0
  125. package/src/utils/sample-profile.ts +437 -0
  126. package/src/utils/shell-snapshot-fn-env.sh +5 -2
  127. package/src/web/search/render.ts +2 -2
  128. package/dist/types/goals/guided-setup.d.ts +0 -30
  129. package/src/goals/guided-setup.ts +0 -171
  130. package/src/prompts/goals/guided-goal-system.md +0 -33
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Parser and renderer for V8 `.cpuprofile` files (emitted by `node --cpu-prof`,
3
+ * `bun --cpu-prof`, Chrome DevTools, and the CDP `Profiler` domain).
4
+ *
5
+ * The raw file is a JSON blob with a flat node table and up to millions of
6
+ * sample/timestamp entries — useless to read directly. `renderCpuProfile`
7
+ * converts it into a compact bottleneck summary:
8
+ *
9
+ * - the hot-path call tree, pruned to frames with meaningful self time,
10
+ * with pass-through chains collapsed and direct recursion flattened
11
+ * - `(idle)` time excluded from on-CPU totals
12
+ * - a profile-wide "top functions by self time" table
13
+ *
14
+ * Consumed by the read tool: `*.cpuprofile` reads show the summary, `:raw`
15
+ * returns the original JSON.
16
+ */
17
+
18
+ import { formatPct, mergeInto, type ProfileNode, type RenderTreeContext, renderProfileNode } from "./profile-tree";
19
+
20
+ /** Matches paths the read tool should treat as V8 CPU profiles. */
21
+ export function isCpuProfilePath(filePath: string): boolean {
22
+ return /\.cpuprofile$/i.test(filePath);
23
+ }
24
+
25
+ /** Call-site metadata of one profile node. `lineNumber` is 0-based. */
26
+ export interface CpuProfileCallFrame {
27
+ functionName: string;
28
+ url?: string;
29
+ lineNumber?: number;
30
+ }
31
+
32
+ /** One node in the flat profile tree; `children` are node ids. */
33
+ export interface CpuProfileNode {
34
+ id: number;
35
+ callFrame: CpuProfileCallFrame;
36
+ hitCount?: number;
37
+ children?: number[];
38
+ }
39
+
40
+ /** Parsed V8 CPU profile. `startTime`/`endTime`/`timeDeltas` are microseconds. */
41
+ export interface CpuProfile {
42
+ nodes: CpuProfileNode[];
43
+ startTime: number;
44
+ endTime: number;
45
+ samples?: number[];
46
+ timeDeltas?: number[];
47
+ }
48
+
49
+ function isRecord(value: unknown): value is Record<string, unknown> {
50
+ return typeof value === "object" && value !== null;
51
+ }
52
+
53
+ /**
54
+ * Parse a `.cpuprofile` JSON blob. Accepts both the bare profile and the CDP
55
+ * `Profiler.stop` result shape (`{ profile: {...} }`). Returns null when the
56
+ * text is not a structurally valid V8 CPU profile.
57
+ */
58
+ export function parseCpuProfile(text: string): CpuProfile | null {
59
+ let data: unknown;
60
+ try {
61
+ data = JSON.parse(text);
62
+ } catch {
63
+ return null;
64
+ }
65
+ if (isRecord(data) && !("nodes" in data) && isRecord(data.profile)) data = data.profile;
66
+ if (!isRecord(data)) return null;
67
+ const { nodes, startTime, endTime, samples, timeDeltas } = data;
68
+ if (!Array.isArray(nodes) || nodes.length === 0) return null;
69
+ if (typeof startTime !== "number" || typeof endTime !== "number") return null;
70
+ for (const node of nodes) {
71
+ if (!isRecord(node) || typeof node.id !== "number") return null;
72
+ if (!isRecord(node.callFrame) || typeof node.callFrame.functionName !== "string") return null;
73
+ }
74
+ return {
75
+ nodes: nodes as unknown as CpuProfileNode[],
76
+ startTime,
77
+ endTime,
78
+ samples: Array.isArray(samples) ? (samples as number[]) : undefined,
79
+ timeDeltas: Array.isArray(timeDeltas) ? (timeDeltas as number[]) : undefined,
80
+ };
81
+ }
82
+
83
+ /** Fraction of total on-CPU time a subtree needs to stay visible. */
84
+ const PRUNE_FRACTION = 0.02;
85
+ const TOP_FUNCTIONS = 20;
86
+
87
+ /** Trim a call-frame URL for display: drop `file://`, keep the tail. */
88
+ function shortUrl(url: string): string {
89
+ let u = url.startsWith("file://") ? url.slice("file://".length) : url;
90
+ const nm = u.lastIndexOf("node_modules/");
91
+ if (nm > 0) return u.slice(nm);
92
+ const parts = u.split("/");
93
+ if (parts.length > 4) u = `…/${parts.slice(-3).join("/")}`;
94
+ return u;
95
+ }
96
+
97
+ function frameLabel(frame: CpuProfileCallFrame): string {
98
+ const name = frame.functionName || "(anonymous)";
99
+ if (!frame.url) return name;
100
+ const line = typeof frame.lineNumber === "number" && frame.lineNumber >= 0 ? `:${frame.lineNumber + 1}` : "";
101
+ return `${name} (${shortUrl(frame.url)}${line})`;
102
+ }
103
+
104
+ /**
105
+ * Self time per node id in microseconds. Prefers the sample/delta streams
106
+ * (each delta is attributed to the sample that closes its interval); falls
107
+ * back to `hitCount × average interval` for profiles without samples.
108
+ */
109
+ function selfMicros(profile: CpuProfile): Map<number, number> {
110
+ const self = new Map<number, number>();
111
+ const { samples, timeDeltas } = profile;
112
+ if (samples && timeDeltas && samples.length > 0) {
113
+ const n = Math.min(samples.length, timeDeltas.length);
114
+ for (let i = 0; i < n; i++) {
115
+ const delta = timeDeltas[i];
116
+ // V8 occasionally emits negative/zero deltas around timer adjustments.
117
+ if (typeof delta !== "number" || delta <= 0) continue;
118
+ const id = samples[i];
119
+ self.set(id, (self.get(id) ?? 0) + delta);
120
+ }
121
+ return self;
122
+ }
123
+ let totalHits = 0;
124
+ for (const node of profile.nodes) totalHits += node.hitCount ?? 0;
125
+ if (totalHits === 0) return self;
126
+ const interval = (profile.endTime - profile.startTime) / totalHits;
127
+ for (const node of profile.nodes) {
128
+ if (node.hitCount) self.set(node.id, node.hitCount * interval);
129
+ }
130
+ return self;
131
+ }
132
+
133
+ /** Meta-frames that represent time off the JS stack rather than user code. */
134
+ const IDLE_FRAME = "(idle)";
135
+ const ROOT_FRAME = "(root)";
136
+
137
+ function formatMs(micros: number): string {
138
+ return (micros / 1000).toFixed(1);
139
+ }
140
+
141
+ /**
142
+ * Render a V8 CPU profile as an agent-friendly bottleneck summary.
143
+ * Returns null when `text` is not a CPU profile (caller falls back to the
144
+ * plain-text path).
145
+ */
146
+ export function renderCpuProfile(text: string): string | null {
147
+ const profile = parseCpuProfile(text);
148
+ if (!profile) return null;
149
+
150
+ const byId = new Map<number, CpuProfileNode>();
151
+ const referenced = new Set<number>();
152
+ for (const node of profile.nodes) {
153
+ byId.set(node.id, node);
154
+ for (const child of node.children ?? []) referenced.add(child);
155
+ }
156
+ const self = selfMicros(profile);
157
+
158
+ // Guard against malformed child cycles; V8 output is a proper tree.
159
+ const visited = new Set<number>();
160
+ const build = (node: CpuProfileNode): ProfileNode => {
161
+ visited.add(node.id);
162
+ const children: ProfileNode[] = [];
163
+ for (const childId of node.children ?? []) {
164
+ const rawChild = byId.get(childId);
165
+ if (!rawChild || visited.has(childId)) continue;
166
+ const child = build(rawChild);
167
+ const existing = children.find(c => c.key === child.key);
168
+ if (existing) mergeInto(existing, child);
169
+ else children.push(child);
170
+ }
171
+ const isIdle = node.callFrame.functionName === IDLE_FRAME;
172
+ let value = isIdle ? 0 : (self.get(node.id) ?? 0);
173
+ for (const child of children) value += child.value;
174
+ const label = frameLabel(node.callFrame);
175
+ return { key: label, label, value, recursion: 0, children };
176
+ };
177
+
178
+ // Promote past the synthetic "(root)" frame so hot paths start at real code.
179
+ const roots: ProfileNode[] = [];
180
+ for (const node of profile.nodes) {
181
+ if (referenced.has(node.id) || visited.has(node.id)) continue;
182
+ const built = build(node);
183
+ if (node.callFrame.functionName === ROOT_FRAME) roots.push(...built.children);
184
+ else roots.push(built);
185
+ }
186
+
187
+ const totalCpu = roots.reduce((sum, root) => sum + root.value, 0);
188
+ if (totalCpu <= 0) return null;
189
+ const duration = Math.max(0, profile.endTime - profile.startTime);
190
+ const sampleCount = profile.samples?.length ?? 0;
191
+ const avgInterval = sampleCount > 0 ? duration / sampleCount : 0;
192
+
193
+ const out: string[] = [];
194
+ let header = `V8 CPU profile: ${(duration / 1e6).toFixed(2)} s wall clock`;
195
+ if (sampleCount > 0) header += `, ${sampleCount} samples (avg interval ${Math.round(avgInterval)} µs)`;
196
+ out.push(header);
197
+ out.push(
198
+ `On-CPU total: ${(totalCpu / 1e6).toFixed(2)} s (${formatPct(totalCpu, duration)} of wall clock). Values below are on-CPU milliseconds (idle time excluded).`,
199
+ );
200
+
201
+ const ctx: RenderTreeContext = {
202
+ out,
203
+ total: totalCpu,
204
+ minValue: Math.max(3 * avgInterval, totalCpu * PRUNE_FRACTION),
205
+ formatValue: formatMs,
206
+ valueWidth: Math.max(8, formatMs(totalCpu).length),
207
+ };
208
+ out.push("");
209
+ out.push("## Hot paths");
210
+ const kept = roots.filter(root => root.value >= ctx.minValue).sort((a, b) => b.value - a.value);
211
+ for (const root of kept) renderProfileNode(root, 0, ctx);
212
+ if (kept.length === 0) out.push(` (no call path above ${formatMs(ctx.minValue)} ms on-CPU)`);
213
+
214
+ const totals = new Map<string, number>();
215
+ for (const node of profile.nodes) {
216
+ const micros = self.get(node.id) ?? 0;
217
+ if (micros <= 0) continue;
218
+ const name = node.callFrame.functionName;
219
+ if (name === IDLE_FRAME || name === ROOT_FRAME) continue;
220
+ const label = frameLabel(node.callFrame);
221
+ totals.set(label, (totals.get(label) ?? 0) + micros);
222
+ }
223
+ const ranked = [...totals.entries()].sort((a, b) => b[1] - a[1]);
224
+ if (ranked.length > 0) {
225
+ out.push("");
226
+ out.push("## Top functions by self time (idle time excluded)");
227
+ for (const [label, micros] of ranked.slice(0, TOP_FUNCTIONS)) {
228
+ out.push(`${formatMs(micros).padStart(ctx.valueWidth)} ${formatPct(micros, totalCpu).padStart(6)} ${label}`);
229
+ }
230
+ }
231
+
232
+ out.push("");
233
+ out.push("[Summarized view of a V8 .cpuprofile. Use ':raw' to read the original JSON.]");
234
+ return out.join("\n");
235
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Effective-state resolution for the `inspect_image` tool.
3
+ *
4
+ * The tool delegates image understanding to a (possibly different)
5
+ * vision-capable model. That indirection is only useful when the active model
6
+ * cannot consume images itself; when it can (`model.input` includes
7
+ * `"image"`), the tool is redundant and its presence actively degrades the
8
+ * `read` tool, which reduces image reads to metadata-only plus an
9
+ * inspect_image suggestion. `auto` mode therefore exposes the tool only for
10
+ * models without native image input. `on`/`off` force registration regardless
11
+ * of model capability. A session-scoped override (the `/vision` command)
12
+ * takes precedence over the persisted setting for the current session only.
13
+ */
14
+ import type { Model } from "@oh-my-pi/pi-ai";
15
+ import type { Settings } from "../config/settings";
16
+
17
+ export type InspectImageMode = "auto" | "on" | "off";
18
+
19
+ export const INSPECT_IMAGE_MODES = ["auto", "on", "off"] as const;
20
+
21
+ /** Minimal session surface needed to resolve the effective inspect_image state. */
22
+ export interface InspectImageModeContext {
23
+ settings: Pick<Settings, "get">;
24
+ getActiveModel?: () => Model | undefined;
25
+ getInspectImageModeOverride?: () => InspectImageMode | undefined;
26
+ }
27
+
28
+ /**
29
+ * Whether the `inspect_image` tool should be registered/active right now.
30
+ * `auto` registers it only when the active model lacks native image input;
31
+ * an unresolved model is treated as text-only so the tool stays available.
32
+ */
33
+ export function isInspectImageToolActive(session: InspectImageModeContext): boolean {
34
+ const mode = session.getInspectImageModeOverride?.() ?? session.settings.get("inspect_image.mode");
35
+ if (mode === "on") return true;
36
+ if (mode === "off") return false;
37
+ const model = session.getActiveModel?.();
38
+ return !(model?.input?.includes("image") ?? false);
39
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Shared display-tree machinery for profile summaries (macOS `sample` reports,
3
+ * V8 `.cpuprofile` files). Callers build {@link ProfileNode} trees with a
4
+ * domain-specific metric (on-CPU samples, self microseconds, …); this module
5
+ * merges same-key siblings, flattens direct recursion, collapses pass-through
6
+ * chains, and renders the pruned hot-path tree.
7
+ */
8
+
9
+ /** Display node: merged, recursion-flattened mirror of a profile subtree. */
10
+ export interface ProfileNode {
11
+ /** Merge/recursion identity (demangled symbol, function@call-site, …). */
12
+ key: string;
13
+ /** Human-readable label; recursion and truncation decorations are applied at render time. */
14
+ label: string;
15
+ /** Inclusive metric for this subtree (on-CPU samples, self µs, …); drives pruning and display. */
16
+ value: number;
17
+ /** Levels of direct recursion flattened into this node. */
18
+ recursion: number;
19
+ children: ProfileNode[];
20
+ }
21
+
22
+ /** Labels longer than this are head-truncated (huge C++ templates, data URLs). */
23
+ const MAX_LABEL_CHARS = 160;
24
+
25
+ /** Merge `b` into `a` (same key), combining values and child lists. */
26
+ export function mergeInto(a: ProfileNode, b: ProfileNode): void {
27
+ a.value += b.value;
28
+ a.recursion = Math.max(a.recursion, b.recursion);
29
+ for (const child of b.children) {
30
+ const existing = a.children.find(c => c.key === child.key);
31
+ if (existing) mergeInto(existing, child);
32
+ else a.children.push(child);
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Flatten direct recursion: children carrying the node's own key are dissolved
38
+ * into it (their children promoted and merged), so a 15-deep recursive spine
39
+ * renders as one annotated node.
40
+ */
41
+ export function flattenRecursion(node: ProfileNode): void {
42
+ while (node.children.some(child => child.key === node.key)) {
43
+ node.recursion++;
44
+ const next: ProfileNode[] = [];
45
+ for (const child of node.children) {
46
+ const promoted = child.key === node.key ? child.children : [child];
47
+ for (const item of promoted) {
48
+ const existing = next.find(c => c.key === item.key);
49
+ if (existing) mergeInto(existing, item);
50
+ else next.push(item);
51
+ }
52
+ }
53
+ node.children = next;
54
+ }
55
+ }
56
+
57
+ /** `n` as a percentage of `total`, one decimal (`"12.3%"`); `"0%"` when total is empty. */
58
+ export function formatPct(n: number, total: number): string {
59
+ if (total <= 0) return "0%";
60
+ return `${((100 * n) / total).toFixed(1)}%`;
61
+ }
62
+
63
+ /** Options for {@link renderProfileNode}. */
64
+ export interface RenderTreeContext {
65
+ out: string[];
66
+ /** Denominator for percentage annotations. */
67
+ total: number;
68
+ /** Minimum subtree value a child needs to stay visible. */
69
+ minValue: number;
70
+ /** Format a metric value for the left column. */
71
+ formatValue: (value: number) => string;
72
+ /** Left-column width (characters) for formatted values. */
73
+ valueWidth: number;
74
+ }
75
+
76
+ function decoratedLabel(node: ProfileNode): string {
77
+ let label = node.label.length > MAX_LABEL_CHARS ? `${node.label.slice(0, MAX_LABEL_CHARS - 1)}…` : node.label;
78
+ if (node.recursion > 0) label += ` [recursive ×${node.recursion + 1}]`;
79
+ return label;
80
+ }
81
+
82
+ /**
83
+ * Render one hot-path subtree: pass-through chains (single kept child, no own
84
+ * contribution above `minValue`) collapse into a single `a › b › c` line, and
85
+ * children below `minValue` are pruned.
86
+ */
87
+ export function renderProfileNode(node: ProfileNode, indent: number, ctx: RenderTreeContext): void {
88
+ const chain: ProfileNode[] = [node];
89
+ let cur = node;
90
+ for (;;) {
91
+ flattenRecursion(cur);
92
+ if (cur.recursion > 0) break;
93
+ const kept = cur.children.filter(child => child.value >= ctx.minValue);
94
+ if (kept.length !== 1 || cur.value - kept[0].value >= ctx.minValue) break;
95
+ cur = kept[0];
96
+ chain.push(cur);
97
+ }
98
+ flattenRecursion(cur);
99
+
100
+ const labels = chain.map(decoratedLabel);
101
+ const path =
102
+ labels.length <= 4
103
+ ? labels.join(" › ")
104
+ : `${labels[0]} › ⋯${labels.length - 2} frames⋯ › ${labels[labels.length - 1]}`;
105
+ const value = ctx.formatValue(chain[0].value).padStart(ctx.valueWidth);
106
+ const pct = formatPct(chain[0].value, ctx.total).padStart(6);
107
+ ctx.out.push(`${value} ${pct} ${" ".repeat(indent)}${path}`);
108
+
109
+ const kept = cur.children.filter(child => child.value >= ctx.minValue).sort((a, b) => b.value - a.value);
110
+ for (const child of kept) renderProfileNode(child, indent + 1, ctx);
111
+ }