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

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 (180) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/dist/{CHANGELOG-vt8ene9g.md → CHANGELOG-be1f2t8h.md} +92 -0
  3. package/dist/cli.js +9986 -9776
  4. package/dist/{template-dys3vk5b.js → template-8vdv6xb4.js} +1 -1
  5. package/dist/types/advisor/runtime.d.ts +8 -2
  6. package/dist/types/advisor/transcript-recorder.d.ts +15 -0
  7. package/dist/types/auto-thinking/classifier.d.ts +9 -2
  8. package/dist/types/cli/args.d.ts +2 -0
  9. package/dist/types/cli/session-picker.d.ts +14 -7
  10. package/dist/types/collab/guest.d.ts +21 -2
  11. package/dist/types/commands/launch.d.ts +6 -0
  12. package/dist/types/config/settings-schema.d.ts +63 -8
  13. package/dist/types/cursor-bridge-tools.d.ts +54 -0
  14. package/dist/types/cursor.d.ts +193 -8
  15. package/dist/types/edit/edit-clipboard.d.ts +19 -0
  16. package/dist/types/edit/hashline/diff.d.ts +9 -1
  17. package/dist/types/edit/index.d.ts +8 -1
  18. package/dist/types/edit/streaming.d.ts +8 -1
  19. package/dist/types/extensibility/extensions/runner.d.ts +19 -2
  20. package/dist/types/extensibility/extensions/types.d.ts +25 -1
  21. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +2 -0
  22. package/dist/types/internal-urls/local-protocol.d.ts +3 -2
  23. package/dist/types/launch/terminal-output-worker-client.d.ts +5 -0
  24. package/dist/types/launch/terminal-output-worker-protocol.d.ts +14 -0
  25. package/dist/types/launch/terminal-output-worker.d.ts +1 -0
  26. package/dist/types/lsp/client.d.ts +2 -0
  27. package/dist/types/main.d.ts +2 -0
  28. package/dist/types/mcp/manager.d.ts +33 -4
  29. package/dist/types/modes/components/codex-reset-fireworks.d.ts +51 -0
  30. package/dist/types/modes/components/read-tool-group.d.ts +12 -0
  31. package/dist/types/modes/components/session-selector.d.ts +6 -0
  32. package/dist/types/modes/components/status-line/component.d.ts +23 -0
  33. package/dist/types/modes/components/tool-execution.d.ts +16 -7
  34. package/dist/types/modes/components/usage-row.d.ts +2 -0
  35. package/dist/types/modes/controllers/mcp-command-controller.d.ts +26 -1
  36. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  37. package/dist/types/modes/interactive-mode.d.ts +4 -2
  38. package/dist/types/modes/rpc/rpc-client.d.ts +7 -0
  39. package/dist/types/modes/rpc/rpc-types.d.ts +16 -0
  40. package/dist/types/modes/theme/theme.d.ts +12 -1
  41. package/dist/types/modes/types.d.ts +2 -1
  42. package/dist/types/sdk.d.ts +1 -0
  43. package/dist/types/session/agent-session-events.d.ts +2 -0
  44. package/dist/types/session/agent-session-types.d.ts +36 -1
  45. package/dist/types/session/agent-session.d.ts +4 -2
  46. package/dist/types/session/claude-session-store.d.ts +13 -0
  47. package/dist/types/session/codex-session-store.d.ts +14 -0
  48. package/dist/types/session/credential-pin.d.ts +58 -0
  49. package/dist/types/session/foreign-session-import.d.ts +15 -0
  50. package/dist/types/session/foreign-session-jsonl.d.ts +9 -0
  51. package/dist/types/session/foreign-session-store.d.ts +23 -0
  52. package/dist/types/session/session-advisors.d.ts +47 -2
  53. package/dist/types/session/session-entries.d.ts +19 -1
  54. package/dist/types/session/session-handoff.d.ts +4 -1
  55. package/dist/types/session/session-loader.d.ts +2 -0
  56. package/dist/types/session/session-maintenance.d.ts +1 -1
  57. package/dist/types/session/session-manager.d.ts +21 -0
  58. package/dist/types/session/session-tools.d.ts +25 -1
  59. package/dist/types/session/turn-recovery.d.ts +10 -4
  60. package/dist/types/system-prompt.d.ts +2 -1
  61. package/dist/types/task/executor.d.ts +11 -2
  62. package/dist/types/task/render.d.ts +7 -0
  63. package/dist/types/thinking.d.ts +18 -6
  64. package/dist/types/tools/acp-bridge.d.ts +28 -5
  65. package/dist/types/tools/grep.d.ts +17 -1
  66. package/dist/types/tools/hub/launch.d.ts +5 -1
  67. package/dist/types/tools/index.d.ts +4 -1
  68. package/dist/types/tools/path-utils.d.ts +19 -0
  69. package/dist/types/tools/read.d.ts +2 -0
  70. package/dist/types/tools/xdev.d.ts +2 -1
  71. package/dist/types/utils/changelog.d.ts +19 -0
  72. package/dist/types/utils/git.d.ts +10 -0
  73. package/dist/types/utils/jj.d.ts +11 -4
  74. package/dist/types/web/search/types.d.ts +2 -2
  75. package/package.json +12 -12
  76. package/src/advisor/runtime.ts +138 -12
  77. package/src/advisor/transcript-recorder.ts +56 -0
  78. package/src/auto-thinking/classifier.ts +48 -10
  79. package/src/cli/args.ts +6 -0
  80. package/src/cli/flag-tables.ts +2 -0
  81. package/src/cli/gallery-fixtures/fs.ts +25 -0
  82. package/src/cli/session-picker.ts +34 -17
  83. package/src/cli/ttsr-cli.ts +19 -1
  84. package/src/cli.ts +9 -0
  85. package/src/collab/guest.ts +37 -4
  86. package/src/collab/host.ts +1 -0
  87. package/src/commands/launch.ts +6 -0
  88. package/src/config/model-registry.ts +103 -25
  89. package/src/config/settings-schema.ts +65 -6
  90. package/src/config/settings.ts +25 -0
  91. package/src/cursor-bridge-tools.ts +81 -0
  92. package/src/cursor.ts +463 -12
  93. package/src/edit/edit-clipboard.ts +23 -0
  94. package/src/edit/hashline/diff.ts +49 -10
  95. package/src/edit/hashline/execute.ts +38 -6
  96. package/src/edit/hashline/filesystem.ts +27 -3
  97. package/src/edit/index.ts +12 -2
  98. package/src/edit/renderer.ts +4 -4
  99. package/src/edit/streaming.ts +17 -2
  100. package/src/export/html/template.js +1 -1
  101. package/src/extensibility/extensions/runner.ts +70 -0
  102. package/src/extensibility/extensions/types.ts +30 -0
  103. package/src/extensibility/legacy-pi-coding-agent-shim.ts +12 -13
  104. package/src/internal-urls/local-protocol.ts +3 -2
  105. package/src/internal-urls/mcp-protocol.ts +4 -1
  106. package/src/launch/terminal-output-worker-client.ts +53 -0
  107. package/src/launch/terminal-output-worker-protocol.ts +11 -0
  108. package/src/launch/terminal-output-worker.ts +23 -0
  109. package/src/lsp/client.ts +18 -3
  110. package/src/lsp/index.ts +4 -0
  111. package/src/lsp/render.ts +1 -1
  112. package/src/main.ts +127 -56
  113. package/src/mcp/manager.ts +120 -18
  114. package/src/modes/acp/acp-agent.ts +15 -9
  115. package/src/modes/components/chat-transcript-builder.ts +30 -11
  116. package/src/modes/components/codex-reset-fireworks.ts +369 -0
  117. package/src/modes/components/read-tool-group.ts +159 -17
  118. package/src/modes/components/session-selector.ts +19 -3
  119. package/src/modes/components/status-line/component.ts +381 -54
  120. package/src/modes/components/tool-execution.ts +78 -31
  121. package/src/modes/components/usage-row.ts +10 -5
  122. package/src/modes/controllers/event-controller.ts +26 -4
  123. package/src/modes/controllers/mcp-command-controller.ts +68 -3
  124. package/src/modes/controllers/selector-controller.ts +94 -36
  125. package/src/modes/controllers/tan-command-controller.ts +14 -0
  126. package/src/modes/interactive-mode.ts +31 -17
  127. package/src/modes/rpc/rpc-client.ts +18 -0
  128. package/src/modes/rpc/rpc-mode.ts +16 -1
  129. package/src/modes/rpc/rpc-types.ts +11 -0
  130. package/src/modes/theme/theme.ts +151 -6
  131. package/src/modes/types.ts +2 -1
  132. package/src/modes/utils/ui-helpers.ts +38 -21
  133. package/src/prompts/system/auto-thinking-difficulty.md +4 -2
  134. package/src/prompts/system/plan-mode-active.md +2 -2
  135. package/src/prompts/system/system-prompt.md +3 -0
  136. package/src/prompts/system/xdev-mount-notice.md +1 -1
  137. package/src/prompts/tools/bash.md +15 -17
  138. package/src/prompts/tools/checkpoint.md +1 -1
  139. package/src/prompts/tools/glob.md +7 -6
  140. package/src/prompts/tools/grep.md +7 -6
  141. package/src/sdk.ts +178 -15
  142. package/src/session/agent-session-events.ts +1 -0
  143. package/src/session/agent-session-types.ts +40 -1
  144. package/src/session/agent-session.ts +302 -175
  145. package/src/session/claude-session-store.ts +426 -0
  146. package/src/session/codex-session-store.ts +673 -0
  147. package/src/session/credential-pin.ts +93 -0
  148. package/src/session/foreign-session-import.ts +52 -0
  149. package/src/session/foreign-session-jsonl.ts +29 -0
  150. package/src/session/foreign-session-store.ts +26 -0
  151. package/src/session/model-controls.ts +11 -3
  152. package/src/session/session-advisors.ts +140 -16
  153. package/src/session/session-entries.ts +21 -1
  154. package/src/session/session-handoff.ts +20 -3
  155. package/src/session/session-loader.ts +25 -9
  156. package/src/session/session-maintenance.ts +59 -11
  157. package/src/session/session-manager.ts +61 -0
  158. package/src/session/session-tools.ts +107 -5
  159. package/src/session/settings-stream-fn.ts +1 -0
  160. package/src/session/turn-recovery.ts +97 -51
  161. package/src/slash-commands/builtin-registry.ts +131 -3
  162. package/src/system-prompt.ts +3 -2
  163. package/src/task/executor.ts +17 -4
  164. package/src/task/render.ts +20 -4
  165. package/src/thinking.ts +29 -10
  166. package/src/tools/acp-bridge.ts +52 -8
  167. package/src/tools/browser.ts +5 -0
  168. package/src/tools/checkpoint.ts +0 -13
  169. package/src/tools/grep.ts +64 -8
  170. package/src/tools/hub/launch.ts +10 -15
  171. package/src/tools/index.ts +28 -4
  172. package/src/tools/path-utils.ts +88 -0
  173. package/src/tools/read.ts +3 -0
  174. package/src/tools/write.ts +9 -3
  175. package/src/tools/xdev.ts +39 -14
  176. package/src/utils/changelog.ts +144 -4
  177. package/src/utils/git.ts +61 -14
  178. package/src/utils/jj.ts +19 -13
  179. package/src/web/search/providers/codex.ts +34 -0
  180. package/src/web/search/types.ts +1 -1
@@ -0,0 +1,369 @@
1
+ import {
2
+ type Component,
3
+ getSegmenter,
4
+ matchesKey,
5
+ type OverlayHandle,
6
+ type OverlayOptions,
7
+ truncateToWidth,
8
+ visibleWidth,
9
+ } from "@oh-my-pi/pi-tui";
10
+ import { type ThemeColor, theme } from "../theme/theme";
11
+
12
+ const FRAME_INTERVAL_MS = 85;
13
+ const FRAME_COUNT = 34;
14
+
15
+ const FIREWORK_THEME_COLORS = {
16
+ cyan: "mdLink",
17
+ dim: "dim",
18
+ gold: "warning",
19
+ green: "success",
20
+ pink: "accent",
21
+ violet: "thinkingXhigh",
22
+ white: "text",
23
+ } as const satisfies Record<string, ThemeColor>;
24
+
25
+ type FireworkColor = keyof typeof FIREWORK_THEME_COLORS;
26
+
27
+ /** The active Codex account fields retained between status refreshes. */
28
+ export interface CodexResetUsageSnapshot {
29
+ /** When this usage report was observed, if supplied by the provider. */
30
+ observedAt?: number;
31
+ /** Weekly usage, its quota identity, and its previously scheduled reset deadline. */
32
+ sevenDay?: { percent: number; resetsAt?: number; tier?: string; plan?: string };
33
+ savedResets?: number;
34
+ }
35
+
36
+ /** A detected Codex quota event that can trigger the fireworks presentation. */
37
+ export type CodexResetFireworksEvent =
38
+ | { kind: "unscheduled-weekly-reset" }
39
+ | { kind: "saved-reset-banked"; added: number; available: number };
40
+
41
+ interface CanvasCell {
42
+ glyph: string;
43
+ color: FireworkColor;
44
+ priority: number;
45
+ }
46
+
47
+ interface FireworkBurst {
48
+ x: number;
49
+ y: number;
50
+ start: number;
51
+ color: FireworkColor;
52
+ }
53
+
54
+ interface ActiveFireworks {
55
+ component: CodexResetFireworksComponent;
56
+ overlay: OverlayHandle;
57
+ }
58
+
59
+ interface CodexResetFireworksHost {
60
+ ui: {
61
+ showOverlay(component: Component, options?: OverlayOptions): OverlayHandle;
62
+ setFocus(component: Component): void;
63
+ requestRender(): void;
64
+ readonly terminal: { readonly rows: number };
65
+ };
66
+ }
67
+
68
+ const BURSTS: readonly FireworkBurst[] = [
69
+ { x: 0.17, y: 0.46, start: 5, color: "pink" },
70
+ { x: 0.48, y: 0.2, start: 9, color: "cyan" },
71
+ { x: 0.78, y: 0.42, start: 13, color: "gold" },
72
+ { x: 0.31, y: 0.24, start: 17, color: "violet" },
73
+ { x: 0.65, y: 0.28, start: 21, color: "green" },
74
+ { x: 0.88, y: 0.18, start: 25, color: "pink" },
75
+ ];
76
+
77
+ /**
78
+ * Compare consecutive reports for one Codex account. A saved-reset grant takes
79
+ * precedence when both changes arrive in the same report. A verified decrease,
80
+ * or a prior positive balance becoming unavailable, suppresses the weekly event
81
+ * because the user may have redeemed a credit. Other weekly usage drops are
82
+ * celebrated only before the previously scheduled reset deadline.
83
+ */
84
+ export function detectCodexResetFireworks(
85
+ previous: CodexResetUsageSnapshot,
86
+ current: CodexResetUsageSnapshot,
87
+ ): CodexResetFireworksEvent | undefined {
88
+ const previousSavedResets = previous.savedResets;
89
+ const currentSavedResets = current.savedResets;
90
+ if (previousSavedResets !== undefined) {
91
+ if (currentSavedResets === undefined) {
92
+ if (previousSavedResets > 0) return undefined;
93
+ } else {
94
+ if (currentSavedResets > previousSavedResets) {
95
+ return {
96
+ kind: "saved-reset-banked",
97
+ added: currentSavedResets - previousSavedResets,
98
+ available: currentSavedResets,
99
+ };
100
+ }
101
+ if (currentSavedResets < previousSavedResets) return undefined;
102
+ }
103
+ }
104
+
105
+ if (!previous.sevenDay || !current.sevenDay) return undefined;
106
+ if (previous.sevenDay.tier !== current.sevenDay.tier || previous.sevenDay.plan !== current.sevenDay.plan) {
107
+ return undefined;
108
+ }
109
+ const previousWeeklyPercent = Math.round(Math.max(0, Math.min(100, previous.sevenDay.percent)));
110
+ const currentWeeklyPercent = Math.round(Math.max(0, Math.min(100, current.sevenDay.percent)));
111
+ if (previousWeeklyPercent === 0 || currentWeeklyPercent >= previousWeeklyPercent) return undefined;
112
+
113
+ const scheduledResetAt = previous.sevenDay.resetsAt;
114
+ if (
115
+ scheduledResetAt === undefined ||
116
+ !Number.isFinite(scheduledResetAt) ||
117
+ typeof current.observedAt !== "number" ||
118
+ !Number.isFinite(current.observedAt) ||
119
+ current.observedAt >= scheduledResetAt
120
+ ) {
121
+ return undefined;
122
+ }
123
+ return { kind: "unscheduled-weekly-reset" };
124
+ }
125
+
126
+ function setCell(
127
+ canvas: Array<Array<CanvasCell | undefined>>,
128
+ x: number,
129
+ y: number,
130
+ glyph: string,
131
+ color: FireworkColor,
132
+ priority: number,
133
+ ): void {
134
+ const row = canvas[y];
135
+ if (!row || x < 0 || x >= row.length) return;
136
+ const current = row[x];
137
+ if (!current || priority >= current.priority) row[x] = { glyph, color, priority };
138
+ }
139
+
140
+ function drawText(
141
+ canvas: Array<Array<CanvasCell | undefined>>,
142
+ x: number,
143
+ y: number,
144
+ text: string,
145
+ color: FireworkColor,
146
+ priority: number,
147
+ ): void {
148
+ let column = x;
149
+ for (const { segment } of getSegmenter().segment(text)) {
150
+ const width = visibleWidth(segment);
151
+ if (width <= 0) continue;
152
+ setCell(canvas, column, y, segment, color, priority);
153
+ for (let continuation = 1; continuation < width; continuation++) {
154
+ setCell(canvas, column + continuation, y, "", color, priority);
155
+ }
156
+ column += width;
157
+ }
158
+ }
159
+
160
+ function drawBanner(
161
+ canvas: Array<Array<CanvasCell | undefined>>,
162
+ left: number,
163
+ artWidth: number,
164
+ height: number,
165
+ event: CodexResetFireworksEvent,
166
+ ): void {
167
+ if (height < 3 || artWidth < 8) return;
168
+ const panelWidth = Math.min(62, artWidth);
169
+ const panelLeft = left + Math.floor((artWidth - panelWidth) / 2);
170
+ const top = height - 3;
171
+ const innerWidth = panelWidth - 2;
172
+ const titleText =
173
+ event.kind === "unscheduled-weekly-reset" ? " O P E N A I R E S E T " : " S A V E D R E S E T ";
174
+ const subtitleText =
175
+ event.kind === "unscheduled-weekly-reset"
176
+ ? "Weekly usage cleared early · ESC to return"
177
+ : event.added === 1
178
+ ? `New reset banked · ${event.available} available · ESC to return`
179
+ : `${event.added} resets banked · ${event.available} available · ESC to return`;
180
+ const title = truncateToWidth(titleText, innerWidth, "");
181
+ const subtitle = truncateToWidth(subtitleText, innerWidth, "");
182
+ const titleOffset = Math.floor((innerWidth - visibleWidth(title)) / 2);
183
+ const subtitleOffset = Math.floor((innerWidth - visibleWidth(subtitle)) / 2);
184
+
185
+ drawText(canvas, panelLeft, top, `╭${"─".repeat(innerWidth)}╮`, "violet", 20);
186
+ drawText(canvas, panelLeft + 1 + titleOffset, top, title, "gold", 21);
187
+ drawText(canvas, panelLeft, top + 1, `│${" ".repeat(innerWidth)}│`, "violet", 20);
188
+ drawText(canvas, panelLeft + 1 + subtitleOffset, top + 1, subtitle, "cyan", 21);
189
+ drawText(canvas, panelLeft, top + 2, `╰${"─".repeat(innerWidth)}╯`, "violet", 20);
190
+ }
191
+
192
+ function drawStars(
193
+ canvas: Array<Array<CanvasCell | undefined>>,
194
+ left: number,
195
+ artWidth: number,
196
+ skyHeight: number,
197
+ frame: number,
198
+ ): void {
199
+ if (skyHeight <= 0) return;
200
+ const count = Math.min(26, Math.max(5, Math.floor(artWidth / 3)));
201
+ for (let index = 0; index < count; index++) {
202
+ const x = left + ((index * 37 + 11) % artWidth);
203
+ const y = (index * 7 + 2) % skyHeight;
204
+ const bright = (index + Math.floor(frame / 3)) % 5 === 0;
205
+ setCell(canvas, x, y, bright ? "+" : ".", bright ? "white" : "dim", bright ? 2 : 1);
206
+ }
207
+ }
208
+
209
+ function drawBurst(
210
+ canvas: Array<Array<CanvasCell | undefined>>,
211
+ burst: FireworkBurst,
212
+ left: number,
213
+ artWidth: number,
214
+ skyHeight: number,
215
+ frame: number,
216
+ ): void {
217
+ if (skyHeight <= 1) return;
218
+ const centerX = left + Math.round((artWidth - 1) * burst.x);
219
+ const centerY = Math.max(0, Math.min(skyHeight - 2, Math.round((skyHeight - 1) * burst.y)));
220
+ const age = frame - burst.start;
221
+
222
+ if (age >= -6 && age < 0) {
223
+ const progress = (age + 6) / 6;
224
+ const y = skyHeight - 1 - Math.round(progress * (skyHeight - 1 - centerY));
225
+ setCell(canvas, centerX, y, "^", "white", 8);
226
+ setCell(canvas, centerX, y + 1, "|", burst.color, 7);
227
+ setCell(canvas, centerX, y + 2, ".", "gold", 6);
228
+ return;
229
+ }
230
+ if (age < 0 || age > 8) return;
231
+
232
+ const radius = age === 0 ? 0 : 0.8 + age * 0.92;
233
+ const gravity = Math.floor((age * age) / 22);
234
+ const glyphs = ["@", "*", "*", "+", "o", "o", ".", ".", "."] as const;
235
+ const particleColor: FireworkColor = age <= 5 ? burst.color : age <= 7 ? "gold" : "dim";
236
+
237
+ for (let particle = 0; particle < 20; particle++) {
238
+ const angle = (particle / 20) * Math.PI * 2 + burst.start * 0.17;
239
+ const x = centerX + Math.round(Math.cos(angle) * radius * 1.75);
240
+ const y = centerY + Math.round(Math.sin(angle) * radius * 0.58 + gravity);
241
+ setCell(canvas, x, y, glyphs[age], particleColor, 10);
242
+ if (age >= 2 && age <= 6) {
243
+ const trailRadius = Math.max(0, radius - 1.4);
244
+ const trailX = centerX + Math.round(Math.cos(angle) * trailRadius * 1.75);
245
+ const trailY = centerY + Math.round(Math.sin(angle) * trailRadius * 0.58 + gravity);
246
+ setCell(canvas, trailX, trailY, ".", "dim", 5);
247
+ }
248
+ }
249
+ if (age <= 2) setCell(canvas, centerX, centerY, age === 0 ? "@" : "+", "white", 12);
250
+ }
251
+
252
+ function renderCanvas(canvas: Array<Array<CanvasCell | undefined>>): string[] {
253
+ return canvas.map(row => {
254
+ let output = "";
255
+ let run = "";
256
+ let runColor: FireworkColor | undefined;
257
+ for (const cell of row) {
258
+ if (cell && cell.color !== runColor) {
259
+ if (run) output += runColor ? theme.fg(FIREWORK_THEME_COLORS[runColor], run) : run;
260
+ run = "";
261
+ runColor = cell.color;
262
+ }
263
+ run += cell?.glyph ?? " ";
264
+ }
265
+ if (run) output += runColor ? theme.fg(FIREWORK_THEME_COLORS[runColor], run) : run;
266
+ return output;
267
+ });
268
+ }
269
+
270
+ /** Render one deterministic animation frame for the top-third overlay. */
271
+ function renderCodexResetFireworks(
272
+ width: number,
273
+ height: number,
274
+ frame: number,
275
+ event: CodexResetFireworksEvent,
276
+ ): string[] {
277
+ const safeWidth = Math.max(1, Math.floor(width));
278
+ const safeHeight = Math.max(1, Math.floor(height));
279
+ const artWidth = Math.min(96, safeWidth);
280
+ const left = Math.floor((safeWidth - artWidth) / 2);
281
+ const skyHeight = Math.max(0, safeHeight - 3);
282
+ const canvas = Array.from({ length: safeHeight }, () => new Array<CanvasCell | undefined>(safeWidth));
283
+
284
+ drawStars(canvas, left, artWidth, skyHeight, frame);
285
+ for (const burst of BURSTS) drawBurst(canvas, burst, left, artWidth, skyHeight, frame);
286
+ drawBanner(canvas, left, artWidth, safeHeight, event);
287
+ return renderCanvas(canvas);
288
+ }
289
+
290
+ class CodexResetFireworksComponent implements Component {
291
+ #timer: NodeJS.Timeout | undefined;
292
+ #done = Promise.withResolvers<void>();
293
+ #frame = 0;
294
+ #disposed = false;
295
+
296
+ constructor(
297
+ readonly host: CodexResetFireworksHost,
298
+ readonly event: CodexResetFireworksEvent,
299
+ ) {}
300
+
301
+ run(): Promise<void> {
302
+ this.#timer ??= setInterval(() => {
303
+ if (this.#disposed) return;
304
+ this.#frame = (this.#frame + 1) % FRAME_COUNT;
305
+ this.host.ui.requestRender();
306
+ }, FRAME_INTERVAL_MS);
307
+ this.host.ui.requestRender();
308
+ return this.#done.promise;
309
+ }
310
+
311
+ dispose(): void {
312
+ if (this.#disposed) return;
313
+ this.#disposed = true;
314
+ if (this.#timer) {
315
+ clearInterval(this.#timer);
316
+ this.#timer = undefined;
317
+ }
318
+ this.#done.resolve();
319
+ }
320
+
321
+ handleInput(data: string): void {
322
+ if (matchesKey(data, "escape") || matchesKey(data, "esc")) this.#done.resolve();
323
+ }
324
+
325
+ render(width: number): readonly string[] {
326
+ const height = Math.max(1, Math.floor(this.host.ui.terminal.rows * 0.33));
327
+ return renderCodexResetFireworks(width, height, this.#frame, this.event);
328
+ }
329
+ }
330
+
331
+ /** Owns the at-most-one modal celebration lifecycle for an interactive session. */
332
+ export class CodexResetFireworksController {
333
+ #active: ActiveFireworks | undefined;
334
+
335
+ constructor(private readonly host: CodexResetFireworksHost) {}
336
+
337
+ /** Present a celebration unless another one already owns the modal overlay. */
338
+ show(event: CodexResetFireworksEvent): boolean {
339
+ if (this.#active) return false;
340
+ const component = new CodexResetFireworksComponent(this.host, event);
341
+ const overlay = this.host.ui.showOverlay(component, {
342
+ anchor: "top-center",
343
+ width: "100%",
344
+ maxHeight: "33%",
345
+ margin: 0,
346
+ });
347
+ this.#active = { component, overlay };
348
+ this.host.ui.setFocus(component);
349
+ void component.run().then(() => this.#finish(component));
350
+ return true;
351
+ }
352
+
353
+ /** Stop the active celebration and release its overlay, if present. */
354
+ dispose(): void {
355
+ const active = this.#active;
356
+ if (!active) return;
357
+ this.#active = undefined;
358
+ active.component.dispose();
359
+ active.overlay.hide();
360
+ }
361
+
362
+ #finish(component: CodexResetFireworksComponent): void {
363
+ const active = this.#active;
364
+ if (!active || active.component !== component) return;
365
+ this.#active = undefined;
366
+ component.dispose();
367
+ active.overlay.hide();
368
+ }
369
+ }
@@ -1,4 +1,5 @@
1
1
  import * as path from "node:path";
2
+ import type { AssistantMessage, Usage } from "@oh-my-pi/pi-ai";
2
3
  import type { Component } from "@oh-my-pi/pi-tui";
3
4
  import { Container, Text } from "@oh-my-pi/pi-tui";
4
5
  import { InternalUrlRouter, XD_URL_PREFIX } from "../../internal-urls";
@@ -6,7 +7,9 @@ import { getLanguageFromPath, theme } from "../../modes/theme/theme";
6
7
  import { parseLineRanges, selectorLineRanges, splitPathAndSel } from "../../tools/path-utils";
7
8
  import { PREVIEW_LIMITS, shortenPath } from "../../tools/render-utils";
8
9
  import { fileHyperlink, renderCodeCell, tryResolveInternalUrlSync } from "../../tui";
10
+ import { canonicalizeMessage } from "../../utils/thinking-display";
9
11
  import type { ToolExecutionHandle } from "./tool-execution";
12
+ import { formatUsageRow } from "./usage-row";
10
13
 
11
14
  /**
12
15
  * Extract the read call's target path. `path` is the canonical arg; `file_path`
@@ -39,6 +42,33 @@ export function readArgsCollapseIntoGroup(args: unknown): boolean {
39
42
  return target.startsWith(XD_URL_PREFIX) || !InternalUrlRouter.instance().canHandle(target);
40
43
  }
41
44
 
45
+ /**
46
+ * Return the collapsed read calls that can own a turn's usage row. Mixed-tool
47
+ * turns and visible content after a read keep the standalone row so request
48
+ * metrics retain their transcript ordering.
49
+ */
50
+ export function groupedReadUsageCallIds(message: AssistantMessage): string[] | undefined {
51
+ const toolCallIds: string[] = [];
52
+ let sawToolCall = false;
53
+ for (const content of message.content) {
54
+ if (content.type === "toolCall") {
55
+ if (content.name !== "read" || !readArgsCollapseIntoGroup(content.arguments)) return undefined;
56
+ sawToolCall = true;
57
+ toolCallIds.push(content.id);
58
+ continue;
59
+ }
60
+ if (
61
+ sawToolCall &&
62
+ (content.type === "image" ||
63
+ (content.type === "text" && canonicalizeMessage(content.text)) ||
64
+ (content.type === "thinking" && canonicalizeMessage(content.thinking)))
65
+ ) {
66
+ return undefined;
67
+ }
68
+ }
69
+ return toolCallIds.length > 0 ? toolCallIds : undefined;
70
+ }
71
+
42
72
  type ReadRenderArgs = {
43
73
  path?: string;
44
74
  file_path?: string;
@@ -94,6 +124,14 @@ type ReadEntry = {
94
124
  codeLineNumbers?: Array<number | null>;
95
125
  };
96
126
 
127
+ type ReadUsageRow = {
128
+ toolCallIds: readonly string[];
129
+ usage: Usage;
130
+ durationMs?: number;
131
+ ttftMs?: number;
132
+ timestamp?: number;
133
+ };
134
+
97
135
  /** Number of code lines to show in collapsed preview mode */
98
136
  const COLLAPSED_PREVIEW_LINES = PREVIEW_LIMITS.OUTPUT_COLLAPSED;
99
137
 
@@ -290,6 +328,8 @@ function formatMergedSelectorParts(selectors: string[]): string {
290
328
 
291
329
  export class ReadToolGroupComponent extends Container implements ToolExecutionHandle {
292
330
  #entries = new Map<string, ReadEntry>();
331
+ #usageRows = new Map<string, ReadUsageRow>();
332
+ #usageBatchByToolCallId = new Map<string, string>();
293
333
  #text: Text;
294
334
  #expanded = false;
295
335
  #showContentPreview: boolean;
@@ -394,6 +434,33 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
394
434
  this.#updateDisplay();
395
435
  }
396
436
 
437
+ /**
438
+ * Nest one request's usage beneath the last visible read call from that
439
+ * request. Parallel reads share one row rather than duplicating request totals.
440
+ */
441
+ attachUsage(
442
+ toolCallIds: readonly string[],
443
+ usage: Usage,
444
+ durationMs?: number,
445
+ ttftMs?: number,
446
+ timestamp?: number,
447
+ ): boolean {
448
+ const attachedToolCallIds: string[] = [];
449
+ let anchorId: string | undefined;
450
+ for (const toolCallId of toolCallIds) {
451
+ if (!this.#entries.has(toolCallId)) continue;
452
+ attachedToolCallIds.push(toolCallId);
453
+ anchorId = toolCallId;
454
+ }
455
+ if (!anchorId) return false;
456
+ for (const toolCallId of attachedToolCallIds) {
457
+ this.#usageBatchByToolCallId.set(toolCallId, anchorId);
458
+ }
459
+ this.#usageRows.set(anchorId, { toolCallIds: attachedToolCallIds, usage, durationMs, ttftMs, timestamp });
460
+ this.#updateDisplay();
461
+ return true;
462
+ }
463
+
397
464
  setArgsComplete(_toolCallId?: string): void {
398
465
  this.#updateDisplay();
399
466
  }
@@ -427,13 +494,15 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
427
494
  if (!this.#shouldRenderPreviewRow(row)) {
428
495
  const statusSymbol = this.#formatStatus(this.#statusForTargets(row.targets));
429
496
  const pathDisplay = this.#formatRowPath(row);
430
- this.#text.setText(
431
- ` ${statusSymbol} ${theme.fg("toolTitle", theme.bold("Read"))} ${pathDisplay}`.trimEnd(),
432
- );
497
+ const lines = [` ${statusSymbol} ${theme.fg("toolTitle", theme.bold("Read"))} ${pathDisplay}`.trimEnd()];
498
+ const usageRows = this.#usageRowsBySummaryRow(displayRows).get(0) ?? [];
499
+ this.#appendUsageRows(lines, usageRows, " ");
500
+ this.#text.setText(lines.join("\n"));
433
501
  this.addChild(this.#text);
434
502
  }
435
503
  for (const entry of this.#previewEntriesForRow(row)) {
436
504
  this.#addContentPreview(entry);
505
+ this.#addPreviewUsage(entry);
437
506
  }
438
507
  return;
439
508
  }
@@ -443,8 +512,9 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
443
512
  const entriesWithoutPreview = entries.filter(entry => !this.#shouldRenderPreview(entry));
444
513
  const summaryTargets = this.#displayTargetsForEntries(entriesWithoutPreview);
445
514
  const rows = this.#buildSummaryRows(summaryTargets);
515
+ const usageRowsBySummaryRow = this.#usageRowsBySummaryRow(rows);
446
516
  for (const [index, row] of rows.entries()) {
447
- this.#appendSummaryRow(lines, row, index, rows.length);
517
+ this.#appendSummaryRow(lines, row, index, rows.length, usageRowsBySummaryRow.get(index) ?? []);
448
518
  }
449
519
 
450
520
  this.#text.setText(lines.join("\n"));
@@ -453,6 +523,7 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
453
523
  for (const entry of entries) {
454
524
  if (this.#shouldRenderPreview(entry)) {
455
525
  this.#addContentPreview(entry);
526
+ this.#addPreviewUsage(entry);
456
527
  }
457
528
  }
458
529
  }
@@ -480,27 +551,37 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
480
551
  }
481
552
 
482
553
  #buildSummaryRows(targets: ReadDisplayTarget[]): ReadSummaryRow[] {
483
- const selectorTargetsByBasePath = new Map<string, ReadDisplayTarget[]>();
554
+ const selectorTargetsByBasePathAndBatch = new Map<string, Map<string | undefined, ReadDisplayTarget[]>>();
484
555
  for (const target of targets) {
485
556
  if (!target.selector) continue;
486
- const existing = selectorTargetsByBasePath.get(target.basePath);
557
+ let targetsByBatch = selectorTargetsByBasePathAndBatch.get(target.basePath);
558
+ if (!targetsByBatch) {
559
+ targetsByBatch = new Map<string | undefined, ReadDisplayTarget[]>();
560
+ selectorTargetsByBasePathAndBatch.set(target.basePath, targetsByBatch);
561
+ }
562
+ const batchId = this.#usageBatchByToolCallId.get(target.entry.toolCallId);
563
+ const existing = targetsByBatch.get(batchId);
487
564
  if (existing) existing.push(target);
488
- else selectorTargetsByBasePath.set(target.basePath, [target]);
565
+ else targetsByBatch.set(batchId, [target]);
489
566
  }
490
567
 
491
- const mergeableBasePaths = new Set<string>();
492
- for (const [basePath, baseTargets] of selectorTargetsByBasePath) {
493
- if (basePath && baseTargets.length > 1) {
494
- mergeableBasePaths.add(basePath);
568
+ const mergedTargetsByTarget = new Map<ReadDisplayTarget, ReadDisplayTarget[]>();
569
+ for (const [basePath, targetsByBatch] of selectorTargetsByBasePathAndBatch) {
570
+ if (!basePath) continue;
571
+ for (const groupedTargets of targetsByBatch.values()) {
572
+ if (groupedTargets.length <= 1) continue;
573
+ for (const target of groupedTargets) {
574
+ mergedTargetsByTarget.set(target, groupedTargets);
575
+ }
495
576
  }
496
577
  }
497
578
 
498
- const emittedMergedRows = new Set<string>();
579
+ const emittedMergedTargets = new Set<ReadDisplayTarget[]>();
499
580
  const rows: ReadSummaryRow[] = [];
500
581
  for (const target of targets) {
501
- if (target.selector && mergeableBasePaths.has(target.basePath)) {
502
- if (!emittedMergedRows.has(target.basePath)) {
503
- const mergedTargets = selectorTargetsByBasePath.get(target.basePath) ?? [target];
582
+ const mergedTargets = mergedTargetsByTarget.get(target);
583
+ if (mergedTargets) {
584
+ if (!emittedMergedTargets.has(mergedTargets)) {
504
585
  rows.push({
505
586
  targetPath: `${target.basePath}:${formatMergedSelectorParts(
506
587
  mergedTargets
@@ -510,7 +591,7 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
510
591
  basePath: target.basePath,
511
592
  targets: mergedTargets,
512
593
  });
513
- emittedMergedRows.add(target.basePath);
594
+ emittedMergedTargets.add(mergedTargets);
514
595
  }
515
596
  continue;
516
597
  }
@@ -519,9 +600,58 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
519
600
  return rows;
520
601
  }
521
602
 
522
- #appendSummaryRow(lines: string[], row: ReadSummaryRow, index: number, total: number): void {
603
+ #appendSummaryRow(
604
+ lines: string[],
605
+ row: ReadSummaryRow,
606
+ index: number,
607
+ total: number,
608
+ usageRows: ReadUsageRow[],
609
+ ): void {
523
610
  const connector = index === total - 1 ? theme.tree.last : theme.tree.branch;
524
611
  lines.push(` ${theme.fg("dim", connector)} ${this.#formatRow(row)}`.trimEnd());
612
+
613
+ const connectorWidth = Bun.stringWidth(connector);
614
+ const continuation =
615
+ index === total - 1
616
+ ? " ".repeat(connectorWidth)
617
+ : `${theme.tree.vertical}${" ".repeat(Math.max(0, connectorWidth - Bun.stringWidth(theme.tree.vertical)))}`;
618
+ this.#appendUsageRows(lines, usageRows, ` ${continuation} `);
619
+ }
620
+
621
+ #usageRowsBySummaryRow(rows: ReadSummaryRow[]): Map<number, ReadUsageRow[]> {
622
+ const lastRowIndexByToolCallId = new Map<string, number>();
623
+ for (const [index, row] of rows.entries()) {
624
+ for (const target of row.targets) {
625
+ lastRowIndexByToolCallId.set(target.entry.toolCallId, index);
626
+ }
627
+ }
628
+
629
+ const usageRowsByIndex = new Map<number, ReadUsageRow[]>();
630
+ for (const usageRow of this.#usageRows.values()) {
631
+ let lastRowIndex: number | undefined;
632
+ for (const toolCallId of usageRow.toolCallIds) {
633
+ const index = lastRowIndexByToolCallId.get(toolCallId);
634
+ if (index !== undefined && (lastRowIndex === undefined || index > lastRowIndex)) {
635
+ lastRowIndex = index;
636
+ }
637
+ }
638
+ if (lastRowIndex === undefined) continue;
639
+ const usageRows = usageRowsByIndex.get(lastRowIndex);
640
+ if (usageRows) usageRows.push(usageRow);
641
+ else usageRowsByIndex.set(lastRowIndex, [usageRow]);
642
+ }
643
+ return usageRowsByIndex;
644
+ }
645
+
646
+ #appendUsageRows(lines: string[], usageRows: ReadUsageRow[], prefix: string): void {
647
+ for (const usageRow of usageRows) {
648
+ lines.push(
649
+ theme.fg(
650
+ "dim",
651
+ `${prefix}${formatUsageRow(usageRow.usage, usageRow.durationMs, usageRow.ttftMs, usageRow.timestamp)}`,
652
+ ),
653
+ );
654
+ }
525
655
  }
526
656
 
527
657
  #formatRow(row: ReadSummaryRow): string {
@@ -659,6 +789,18 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
659
789
  this.addChild(component);
660
790
  }
661
791
 
792
+ #addPreviewUsage(entry: ReadEntry): void {
793
+ const usageRow = this.#usageRows.get(entry.toolCallId);
794
+ if (!usageRow) return;
795
+ this.addChild(
796
+ new Text(
797
+ theme.fg("dim", formatUsageRow(usageRow.usage, usageRow.durationMs, usageRow.ttftMs, usageRow.timestamp)),
798
+ 3,
799
+ 0,
800
+ ),
801
+ );
802
+ }
803
+
662
804
  #shouldRenderPreview(entry: ReadEntry): boolean {
663
805
  return this.#showContentPreview && entry.contentText !== undefined;
664
806
  }