pi-extensions 0.1.9

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 (135) hide show
  1. package/.ralph/import-cc-codex.md +31 -0
  2. package/.ralph/import-cc-codex.state.json +14 -0
  3. package/.ralph/mario-not-impl.md +69 -0
  4. package/.ralph/mario-not-impl.state.json +14 -0
  5. package/.ralph/mario-not-spec.md +163 -0
  6. package/.ralph/mario-not-spec.state.json +14 -0
  7. package/LICENSE +21 -0
  8. package/README.md +65 -0
  9. package/RELEASING.md +34 -0
  10. package/agent-guidance/CHANGELOG.md +4 -0
  11. package/agent-guidance/README.md +102 -0
  12. package/agent-guidance/agent-guidance.ts +147 -0
  13. package/agent-guidance/package.json +22 -0
  14. package/agent-guidance/setup.sh +75 -0
  15. package/agent-guidance/templates/CLAUDE.md +5 -0
  16. package/agent-guidance/templates/CODEX.md +92 -0
  17. package/agent-guidance/templates/GEMINI.md +5 -0
  18. package/arcade/CHANGELOG.md +4 -0
  19. package/arcade/README.md +85 -0
  20. package/arcade/assets/picman.png +0 -0
  21. package/arcade/assets/ping.png +0 -0
  22. package/arcade/assets/spice-invaders.png +0 -0
  23. package/arcade/assets/tetris.png +0 -0
  24. package/arcade/mario-not/README.md +30 -0
  25. package/arcade/mario-not/boss.js +103 -0
  26. package/arcade/mario-not/camera.js +59 -0
  27. package/arcade/mario-not/collision.js +91 -0
  28. package/arcade/mario-not/colors.js +36 -0
  29. package/arcade/mario-not/constants.js +97 -0
  30. package/arcade/mario-not/core.js +39 -0
  31. package/arcade/mario-not/death.js +77 -0
  32. package/arcade/mario-not/effects.js +84 -0
  33. package/arcade/mario-not/enemies.js +31 -0
  34. package/arcade/mario-not/engine.js +171 -0
  35. package/arcade/mario-not/fireballs.js +98 -0
  36. package/arcade/mario-not/items.js +24 -0
  37. package/arcade/mario-not/levels.js +403 -0
  38. package/arcade/mario-not/logic.js +104 -0
  39. package/arcade/mario-not/mario-not.ts +297 -0
  40. package/arcade/mario-not/player.js +244 -0
  41. package/arcade/mario-not/render.js +257 -0
  42. package/arcade/mario-not/spec.md +548 -0
  43. package/arcade/mario-not/state.js +246 -0
  44. package/arcade/mario-not/tests/e2e.test.js +855 -0
  45. package/arcade/mario-not/tests/engine.test.js +888 -0
  46. package/arcade/mario-not/tests/fixtures/story0-frame.txt +4 -0
  47. package/arcade/mario-not/tests/fixtures/story1-camera.txt +4 -0
  48. package/arcade/mario-not/tests/fixtures/story1-glyphs.txt +4 -0
  49. package/arcade/mario-not/tests/fixtures/story10-item.txt +4 -0
  50. package/arcade/mario-not/tests/fixtures/story11-hazards.txt +4 -0
  51. package/arcade/mario-not/tests/fixtures/story12-used-block.txt +4 -0
  52. package/arcade/mario-not/tests/fixtures/story13-pipes.txt +4 -0
  53. package/arcade/mario-not/tests/fixtures/story14-goal.txt +4 -0
  54. package/arcade/mario-not/tests/fixtures/story15-hud-narrow.txt +2 -0
  55. package/arcade/mario-not/tests/fixtures/story16-unknown-tile.txt +4 -0
  56. package/arcade/mario-not/tests/fixtures/story17-mix.txt +4 -0
  57. package/arcade/mario-not/tests/fixtures/story18-hud-score.txt +2 -0
  58. package/arcade/mario-not/tests/fixtures/story19-cue.txt +4 -0
  59. package/arcade/mario-not/tests/fixtures/story2-enemy.txt +4 -0
  60. package/arcade/mario-not/tests/fixtures/story20-camera-offset.txt +4 -0
  61. package/arcade/mario-not/tests/fixtures/story21-hud-zero.txt +2 -0
  62. package/arcade/mario-not/tests/fixtures/story22-big-viewport.txt +4 -0
  63. package/arcade/mario-not/tests/fixtures/story23-camera-negative.txt +4 -0
  64. package/arcade/mario-not/tests/fixtures/story24-camera-width.txt +4 -0
  65. package/arcade/mario-not/tests/fixtures/story25-camera-positive.txt +4 -0
  66. package/arcade/mario-not/tests/fixtures/story26-hud-lives.txt +2 -0
  67. package/arcade/mario-not/tests/fixtures/story27-hud-coins.txt +2 -0
  68. package/arcade/mario-not/tests/fixtures/story28-item-viewport.txt +4 -0
  69. package/arcade/mario-not/tests/fixtures/story29-enemy-viewport.txt +4 -0
  70. package/arcade/mario-not/tests/fixtures/story3-hud.txt +2 -0
  71. package/arcade/mario-not/tests/fixtures/story30-hud-score.txt +2 -0
  72. package/arcade/mario-not/tests/fixtures/story31-particles-viewport.txt +4 -0
  73. package/arcade/mario-not/tests/fixtures/story32-paused-frame.txt +4 -0
  74. package/arcade/mario-not/tests/fixtures/story4-big.txt +4 -0
  75. package/arcade/mario-not/tests/fixtures/story5-resume-hud.txt +2 -0
  76. package/arcade/mario-not/tests/fixtures/story6-particles.txt +4 -0
  77. package/arcade/mario-not/tests/fixtures/story6-paused.txt +4 -0
  78. package/arcade/mario-not/tests/fixtures/story7-powerup.txt +4 -0
  79. package/arcade/mario-not/tests/fixtures/story8-hud-time.txt +2 -0
  80. package/arcade/mario-not/tests/fixtures/story9-hud-level.txt +2 -0
  81. package/arcade/mario-not/tiles.js +79 -0
  82. package/arcade/mario-not/tsconfig.json +14 -0
  83. package/arcade/mario-not/types.js +225 -0
  84. package/arcade/package.json +26 -0
  85. package/arcade/picman.ts +328 -0
  86. package/arcade/ping.ts +594 -0
  87. package/arcade/spice-invaders.ts +1104 -0
  88. package/arcade/tetris.ts +662 -0
  89. package/code-actions/CHANGELOG.md +4 -0
  90. package/code-actions/README.md +65 -0
  91. package/code-actions/actions.ts +107 -0
  92. package/code-actions/index.ts +148 -0
  93. package/code-actions/package.json +22 -0
  94. package/code-actions/search.ts +79 -0
  95. package/code-actions/snippets.ts +179 -0
  96. package/code-actions/ui.ts +120 -0
  97. package/files-widget/CHANGELOG.md +90 -0
  98. package/files-widget/DESIGN.md +452 -0
  99. package/files-widget/README.md +122 -0
  100. package/files-widget/TODO.md +141 -0
  101. package/files-widget/browser.ts +922 -0
  102. package/files-widget/comment.ts +5 -0
  103. package/files-widget/constants.ts +18 -0
  104. package/files-widget/demo.svg +1 -0
  105. package/files-widget/file-tree.ts +224 -0
  106. package/files-widget/file-viewer.ts +93 -0
  107. package/files-widget/git.ts +107 -0
  108. package/files-widget/index.ts +140 -0
  109. package/files-widget/input-utils.ts +3 -0
  110. package/files-widget/package.json +22 -0
  111. package/files-widget/types.ts +28 -0
  112. package/files-widget/utils.ts +26 -0
  113. package/files-widget/viewer.ts +424 -0
  114. package/import-cc-codex/research/import-chats-from-other-agents.md +135 -0
  115. package/import-cc-codex/spec.md +79 -0
  116. package/package.json +29 -0
  117. package/ralph-wiggum/CHANGELOG.md +7 -0
  118. package/ralph-wiggum/README.md +96 -0
  119. package/ralph-wiggum/SKILL.md +73 -0
  120. package/ralph-wiggum/index.ts +792 -0
  121. package/ralph-wiggum/package.json +25 -0
  122. package/raw-paste/CHANGELOG.md +7 -0
  123. package/raw-paste/README.md +52 -0
  124. package/raw-paste/index.ts +112 -0
  125. package/raw-paste/package.json +22 -0
  126. package/tab-status/CHANGELOG.md +4 -0
  127. package/tab-status/README.md +61 -0
  128. package/tab-status/assets/tab-status.png +0 -0
  129. package/tab-status/package.json +22 -0
  130. package/tab-status/tab-status.ts +179 -0
  131. package/usage-extension/CHANGELOG.md +17 -0
  132. package/usage-extension/README.md +120 -0
  133. package/usage-extension/index.ts +628 -0
  134. package/usage-extension/package.json +22 -0
  135. package/usage-extension/screenshot.png +0 -0
@@ -0,0 +1,628 @@
1
+ /**
2
+ * /usage - Usage statistics dashboard
3
+ *
4
+ * Shows an inline view with usage stats grouped by provider.
5
+ * - Tab cycles: Today → This Week → All Time
6
+ * - Arrow keys navigate providers
7
+ * - Enter expands/collapses to show models
8
+ */
9
+
10
+ import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@mariozechner/pi-coding-agent";
11
+ import { DynamicBorder } from "@mariozechner/pi-coding-agent";
12
+ import { CancellableLoader, Container, Spacer, matchesKey, visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
13
+ import { readdir, readFile } from "node:fs/promises";
14
+ import { join } from "node:path";
15
+ import { homedir } from "node:os";
16
+
17
+ // =============================================================================
18
+ // Types
19
+ // =============================================================================
20
+
21
+ interface TokenStats {
22
+ total: number;
23
+ input: number;
24
+ output: number;
25
+ cache: number;
26
+ }
27
+
28
+ interface BaseStats {
29
+ messages: number;
30
+ cost: number;
31
+ tokens: TokenStats;
32
+ }
33
+
34
+ interface ModelStats extends BaseStats {
35
+ sessions: Set<string>;
36
+ }
37
+
38
+ interface ProviderStats extends BaseStats {
39
+ sessions: Set<string>;
40
+ models: Map<string, ModelStats>;
41
+ }
42
+
43
+ interface TotalStats extends BaseStats {
44
+ sessions: number;
45
+ }
46
+
47
+ interface TimeFilteredStats {
48
+ providers: Map<string, ProviderStats>;
49
+ totals: TotalStats;
50
+ }
51
+
52
+ interface UsageData {
53
+ today: TimeFilteredStats;
54
+ thisWeek: TimeFilteredStats;
55
+ allTime: TimeFilteredStats;
56
+ }
57
+
58
+ type TabName = "today" | "thisWeek" | "allTime";
59
+
60
+ // =============================================================================
61
+ // Column Configuration
62
+ // =============================================================================
63
+
64
+ interface DataColumn {
65
+ label: string;
66
+ width: number;
67
+ dimmed?: boolean;
68
+ getValue: (stats: BaseStats & { sessions: Set<string> | number }) => string;
69
+ }
70
+
71
+ const NAME_COL_WIDTH = 26;
72
+
73
+ const DATA_COLUMNS: DataColumn[] = [
74
+ {
75
+ label: "Sessions",
76
+ width: 9,
77
+ getValue: (s) => formatNumber(typeof s.sessions === "number" ? s.sessions : s.sessions.size),
78
+ },
79
+ { label: "Msgs", width: 9, getValue: (s) => formatNumber(s.messages) },
80
+ { label: "Cost", width: 9, getValue: (s) => formatCost(s.cost) },
81
+ { label: "Tokens", width: 9, getValue: (s) => formatTokens(s.tokens.total) },
82
+ { label: "↑In", width: 8, dimmed: true, getValue: (s) => formatTokens(s.tokens.input) },
83
+ { label: "↓Out", width: 8, dimmed: true, getValue: (s) => formatTokens(s.tokens.output) },
84
+ { label: "Cache", width: 8, dimmed: true, getValue: (s) => formatTokens(s.tokens.cache) },
85
+ ];
86
+
87
+ const TABLE_WIDTH = NAME_COL_WIDTH + DATA_COLUMNS.reduce((sum, col) => sum + col.width, 0);
88
+
89
+ // =============================================================================
90
+ // Data Collection
91
+ // =============================================================================
92
+
93
+ function getSessionsDir(): string {
94
+ // Replicate Pi's logic: respect PI_CODING_AGENT_DIR env var
95
+ const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
96
+ return join(agentDir, "sessions");
97
+ }
98
+
99
+ async function getAllSessionFiles(signal?: AbortSignal): Promise<string[]> {
100
+ const sessionsDir = getSessionsDir();
101
+ const files: string[] = [];
102
+
103
+ try {
104
+ const cwdDirs = await readdir(sessionsDir, { withFileTypes: true });
105
+ for (const dir of cwdDirs) {
106
+ if (signal?.aborted) return files;
107
+ if (!dir.isDirectory()) continue;
108
+ const cwdPath = join(sessionsDir, dir.name);
109
+ try {
110
+ const sessionFiles = await readdir(cwdPath);
111
+ for (const file of sessionFiles) {
112
+ if (file.endsWith(".jsonl")) {
113
+ files.push(join(cwdPath, file));
114
+ }
115
+ }
116
+ } catch {
117
+ // Skip directories we can't read
118
+ }
119
+ }
120
+ } catch {
121
+ // Return empty if we can't read sessions dir
122
+ }
123
+
124
+ return files;
125
+ }
126
+
127
+ interface SessionMessage {
128
+ provider: string;
129
+ model: string;
130
+ cost: number;
131
+ input: number;
132
+ output: number;
133
+ cacheRead: number;
134
+ cacheWrite: number;
135
+ timestamp: number;
136
+ }
137
+
138
+ async function parseSessionFile(
139
+ filePath: string,
140
+ seenHashes: Set<string>,
141
+ signal?: AbortSignal
142
+ ): Promise<{ sessionId: string; messages: SessionMessage[] } | null> {
143
+ try {
144
+ const content = await readFile(filePath, "utf8");
145
+ if (signal?.aborted) return null;
146
+ const lines = content.trim().split("\n");
147
+ const messages: SessionMessage[] = [];
148
+ let sessionId = "";
149
+
150
+ for (let i = 0; i < lines.length; i++) {
151
+ if (signal?.aborted) return null;
152
+ if (i % 500 === 0) {
153
+ await new Promise<void>((resolve) => setImmediate(resolve));
154
+ }
155
+ const line = lines[i]!;
156
+ if (!line.trim()) continue;
157
+ try {
158
+ const entry = JSON.parse(line);
159
+
160
+ if (entry.type === "session") {
161
+ sessionId = entry.id;
162
+ } else if (entry.type === "message" && entry.message?.role === "assistant") {
163
+ const msg = entry.message;
164
+ if (msg.usage && msg.provider && msg.model) {
165
+ const input = msg.usage.input || 0;
166
+ const output = msg.usage.output || 0;
167
+ const cacheRead = msg.usage.cacheRead || 0;
168
+ const cacheWrite = msg.usage.cacheWrite || 0;
169
+ const fallbackTs = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
170
+ const timestamp = msg.timestamp || (Number.isNaN(fallbackTs) ? 0 : fallbackTs);
171
+
172
+ // Deduplicate by timestamp + total tokens (same as ccusage)
173
+ // Session files contain many duplicate entries
174
+ const totalTokens = input + output + cacheRead + cacheWrite;
175
+ const hash = `${timestamp}:${totalTokens}`;
176
+ if (seenHashes.has(hash)) continue;
177
+ seenHashes.add(hash);
178
+
179
+ messages.push({
180
+ provider: msg.provider,
181
+ model: msg.model,
182
+ cost: msg.usage.cost?.total || 0,
183
+ input,
184
+ output,
185
+ cacheRead,
186
+ cacheWrite,
187
+ timestamp,
188
+ });
189
+ }
190
+ }
191
+ } catch {
192
+ // Skip malformed lines
193
+ }
194
+ }
195
+
196
+ return sessionId ? { sessionId, messages } : null;
197
+ } catch {
198
+ return null;
199
+ }
200
+ }
201
+
202
+ // Helper to accumulate stats into a target
203
+ function accumulateStats(
204
+ target: BaseStats,
205
+ cost: number,
206
+ tokens: { total: number; input: number; output: number; cache: number }
207
+ ): void {
208
+ target.messages++;
209
+ target.cost += cost;
210
+ target.tokens.total += tokens.total;
211
+ target.tokens.input += tokens.input;
212
+ target.tokens.output += tokens.output;
213
+ target.tokens.cache += tokens.cache;
214
+ }
215
+
216
+ function emptyTokens(): TokenStats {
217
+ return { total: 0, input: 0, output: 0, cache: 0 };
218
+ }
219
+
220
+ function emptyModelStats(): ModelStats {
221
+ return { sessions: new Set(), messages: 0, cost: 0, tokens: emptyTokens() };
222
+ }
223
+
224
+ function emptyProviderStats(): ProviderStats {
225
+ return { sessions: new Set(), messages: 0, cost: 0, tokens: emptyTokens(), models: new Map() };
226
+ }
227
+
228
+ function emptyTimeFilteredStats(): TimeFilteredStats {
229
+ return {
230
+ providers: new Map(),
231
+ totals: { sessions: 0, messages: 0, cost: 0, tokens: emptyTokens() },
232
+ };
233
+ }
234
+
235
+ async function collectUsageData(signal?: AbortSignal): Promise<UsageData | null> {
236
+ const startOfToday = new Date();
237
+ startOfToday.setHours(0, 0, 0, 0);
238
+ const todayMs = startOfToday.getTime();
239
+
240
+ // Start of current week (Monday 00:00)
241
+ const startOfWeek = new Date();
242
+ const dayOfWeek = startOfWeek.getDay(); // 0 = Sunday, 1 = Monday, ...
243
+ const daysSinceMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
244
+ startOfWeek.setDate(startOfWeek.getDate() - daysSinceMonday);
245
+ startOfWeek.setHours(0, 0, 0, 0);
246
+ const weekStartMs = startOfWeek.getTime();
247
+
248
+ const data: UsageData = {
249
+ today: emptyTimeFilteredStats(),
250
+ thisWeek: emptyTimeFilteredStats(),
251
+ allTime: emptyTimeFilteredStats(),
252
+ };
253
+
254
+ const sessionFiles = await getAllSessionFiles(signal);
255
+ if (signal?.aborted) return null;
256
+ const seenHashes = new Set<string>(); // Deduplicate across all files
257
+
258
+ for (const filePath of sessionFiles) {
259
+ if (signal?.aborted) return null;
260
+ const parsed = await parseSessionFile(filePath, seenHashes, signal);
261
+ if (signal?.aborted) return null;
262
+ if (!parsed) continue;
263
+
264
+ const { sessionId, messages } = parsed;
265
+ const sessionContributed = { today: false, thisWeek: false, allTime: false };
266
+
267
+ for (const msg of messages) {
268
+ if (signal?.aborted) return null;
269
+ const periods: TabName[] = ["allTime"];
270
+ if (msg.timestamp >= todayMs) periods.push("today");
271
+ if (msg.timestamp >= weekStartMs) periods.push("thisWeek");
272
+
273
+ const tokens = {
274
+ // Total = input + output only. cacheRead/cacheWrite are tracked separately.
275
+ // cacheRead tokens were already counted when first sent, so including them
276
+ // would double-count and massively inflate totals (cache hits repeat every message).
277
+ total: msg.input + msg.output,
278
+ input: msg.input,
279
+ output: msg.output,
280
+ cache: msg.cacheRead + msg.cacheWrite,
281
+ };
282
+
283
+ for (const period of periods) {
284
+ const stats = data[period];
285
+
286
+ // Get or create provider stats
287
+ let providerStats = stats.providers.get(msg.provider);
288
+ if (!providerStats) {
289
+ providerStats = emptyProviderStats();
290
+ stats.providers.set(msg.provider, providerStats);
291
+ }
292
+
293
+ // Get or create model stats
294
+ let modelStats = providerStats.models.get(msg.model);
295
+ if (!modelStats) {
296
+ modelStats = emptyModelStats();
297
+ providerStats.models.set(msg.model, modelStats);
298
+ }
299
+
300
+ // Accumulate stats at all levels
301
+ modelStats.sessions.add(sessionId);
302
+ accumulateStats(modelStats, msg.cost, tokens);
303
+
304
+ providerStats.sessions.add(sessionId);
305
+ accumulateStats(providerStats, msg.cost, tokens);
306
+
307
+ accumulateStats(stats.totals, msg.cost, tokens);
308
+
309
+ sessionContributed[period] = true;
310
+ }
311
+ }
312
+
313
+ // Count unique sessions per period
314
+ if (sessionContributed.today) data.today.totals.sessions++;
315
+ if (sessionContributed.thisWeek) data.thisWeek.totals.sessions++;
316
+ if (sessionContributed.allTime) data.allTime.totals.sessions++;
317
+
318
+ await new Promise<void>((resolve) => setImmediate(resolve));
319
+ }
320
+
321
+ return data;
322
+ }
323
+
324
+ // =============================================================================
325
+ // Formatting Helpers
326
+ // =============================================================================
327
+
328
+ function formatCost(cost: number): string {
329
+ if (cost === 0) return "-";
330
+ if (cost < 0.01) return `$${cost.toFixed(4)}`;
331
+ if (cost < 1) return `$${cost.toFixed(2)}`;
332
+ if (cost < 10) return `$${cost.toFixed(2)}`;
333
+ if (cost < 100) return `$${cost.toFixed(1)}`;
334
+ return `$${Math.round(cost)}`;
335
+ }
336
+
337
+ function formatTokens(count: number): string {
338
+ if (count === 0) return "-";
339
+ if (count < 1000) return count.toString();
340
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
341
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
342
+ if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
343
+ return `${Math.round(count / 1000000)}M`;
344
+ }
345
+
346
+ function formatNumber(n: number): string {
347
+ if (n === 0) return "-";
348
+ return n.toLocaleString();
349
+ }
350
+
351
+ function padLeft(s: string, len: number): string {
352
+ const vis = visibleWidth(s);
353
+ if (vis >= len) return s;
354
+ return " ".repeat(len - vis) + s;
355
+ }
356
+
357
+ function padRight(s: string, len: number): string {
358
+ const vis = visibleWidth(s);
359
+ if (vis >= len) return s;
360
+ return s + " ".repeat(len - vis);
361
+ }
362
+
363
+ // =============================================================================
364
+ // Component
365
+ // =============================================================================
366
+
367
+ const TAB_LABELS: Record<TabName, string> = {
368
+ today: "Today",
369
+ thisWeek: "This Week",
370
+ allTime: "All Time",
371
+ };
372
+
373
+ const TAB_ORDER: TabName[] = ["today", "thisWeek", "allTime"];
374
+
375
+ class UsageComponent {
376
+ private activeTab: TabName = "allTime";
377
+ private data: UsageData;
378
+ private selectedIndex = 0;
379
+ private expanded = new Set<string>();
380
+ private providerOrder: string[] = [];
381
+ private theme: Theme;
382
+ private requestRender: () => void;
383
+ private done: () => void;
384
+
385
+ constructor(theme: Theme, data: UsageData, requestRender: () => void, done: () => void) {
386
+ this.theme = theme;
387
+ this.requestRender = requestRender;
388
+ this.done = done;
389
+ this.data = data;
390
+ this.updateProviderOrder();
391
+ }
392
+
393
+ private updateProviderOrder(): void {
394
+ const stats = this.data[this.activeTab];
395
+ this.providerOrder = Array.from(stats.providers.entries())
396
+ .sort((a, b) => b[1].cost - a[1].cost)
397
+ .map(([name]) => name);
398
+ this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.providerOrder.length - 1));
399
+ }
400
+
401
+ handleInput(data: string): void {
402
+ if (matchesKey(data, "escape") || matchesKey(data, "q")) {
403
+ this.done();
404
+ return;
405
+ }
406
+
407
+ if (matchesKey(data, "tab") || matchesKey(data, "right")) {
408
+ const idx = TAB_ORDER.indexOf(this.activeTab);
409
+ this.activeTab = TAB_ORDER[(idx + 1) % TAB_ORDER.length]!;
410
+ this.updateProviderOrder();
411
+ this.requestRender();
412
+ } else if (matchesKey(data, "shift+tab") || matchesKey(data, "left")) {
413
+ const idx = TAB_ORDER.indexOf(this.activeTab);
414
+ this.activeTab = TAB_ORDER[(idx - 1 + TAB_ORDER.length) % TAB_ORDER.length]!;
415
+ this.updateProviderOrder();
416
+ this.requestRender();
417
+ } else if (matchesKey(data, "up")) {
418
+ if (this.selectedIndex > 0) {
419
+ this.selectedIndex--;
420
+ this.requestRender();
421
+ }
422
+ } else if (matchesKey(data, "down")) {
423
+ if (this.selectedIndex < this.providerOrder.length - 1) {
424
+ this.selectedIndex++;
425
+ this.requestRender();
426
+ }
427
+ } else if (matchesKey(data, "enter") || matchesKey(data, "space")) {
428
+ const provider = this.providerOrder[this.selectedIndex];
429
+ if (provider) {
430
+ if (this.expanded.has(provider)) {
431
+ this.expanded.delete(provider);
432
+ } else {
433
+ this.expanded.add(provider);
434
+ }
435
+ this.requestRender();
436
+ }
437
+ }
438
+ }
439
+
440
+ // -------------------------------------------------------------------------
441
+ // Render Methods
442
+ // -------------------------------------------------------------------------
443
+
444
+ render(_width: number): string[] {
445
+ return [
446
+ ...this.renderTitle(),
447
+ ...this.renderTabs(),
448
+ ...this.renderHeader(),
449
+ ...this.renderRows(),
450
+ ...this.renderTotals(),
451
+ ...this.renderHelp(),
452
+ ];
453
+ }
454
+
455
+ private renderTitle(): string[] {
456
+ const th = this.theme;
457
+ return [th.fg("accent", th.bold("Usage Statistics")), ""];
458
+ }
459
+
460
+ private renderTabs(): string[] {
461
+ const th = this.theme;
462
+ const tabs = TAB_ORDER.map((tab) => {
463
+ const label = TAB_LABELS[tab];
464
+ return tab === this.activeTab ? th.fg("accent", `[${label}]`) : th.fg("dim", ` ${label} `);
465
+ }).join(" ");
466
+ return [tabs, ""];
467
+ }
468
+
469
+ private renderHeader(): string[] {
470
+ const th = this.theme;
471
+
472
+ let headerLine = padRight("Provider / Model", NAME_COL_WIDTH);
473
+ for (const col of DATA_COLUMNS) {
474
+ const label = padLeft(col.label, col.width);
475
+ headerLine += col.dimmed ? th.fg("dim", label) : label;
476
+ }
477
+
478
+ return [th.fg("muted", headerLine), th.fg("border", "─".repeat(TABLE_WIDTH))];
479
+ }
480
+
481
+ private renderDataRow(
482
+ name: string,
483
+ stats: BaseStats & { sessions: Set<string> | number },
484
+ options: { indent?: number; selected?: boolean; dimAll?: boolean } = {}
485
+ ): string {
486
+ const th = this.theme;
487
+ const { indent = 0, selected = false, dimAll = false } = options;
488
+
489
+ const indentStr = " ".repeat(indent);
490
+ const nameWidth = NAME_COL_WIDTH - indent;
491
+ const truncName = truncateToWidth(name, nameWidth - 1);
492
+ const styledName = selected ? th.fg("accent", truncName) : dimAll ? th.fg("dim", truncName) : truncName;
493
+
494
+ let row = indentStr + padRight(styledName, nameWidth);
495
+
496
+ for (const col of DATA_COLUMNS) {
497
+ const value = col.getValue(stats);
498
+ const shouldDim = col.dimmed || dimAll;
499
+ row += shouldDim ? th.fg("dim", padLeft(value, col.width)) : padLeft(value, col.width);
500
+ }
501
+
502
+ return row;
503
+ }
504
+
505
+ private renderRows(): string[] {
506
+ const th = this.theme;
507
+ const stats = this.data[this.activeTab];
508
+ const lines: string[] = [];
509
+
510
+ if (this.providerOrder.length === 0) {
511
+ lines.push(th.fg("dim", " No usage data for this period"));
512
+ return lines;
513
+ }
514
+
515
+ for (let i = 0; i < this.providerOrder.length; i++) {
516
+ const providerName = this.providerOrder[i]!;
517
+ const providerStats = stats.providers.get(providerName)!;
518
+ const isSelected = i === this.selectedIndex;
519
+ const isExpanded = this.expanded.has(providerName);
520
+
521
+ // Provider row with expand/collapse arrow
522
+ const arrow = isExpanded ? "▾" : "▸";
523
+ const prefix = isSelected ? th.fg("accent", arrow + " ") : th.fg("dim", arrow + " ");
524
+ const dataRow = this.renderDataRow(providerName, providerStats, {
525
+ indent: 2,
526
+ selected: isSelected,
527
+ });
528
+ lines.push(prefix + dataRow.slice(2)); // Replace indent with arrow prefix
529
+
530
+ // Model rows (if expanded)
531
+ if (isExpanded) {
532
+ const models = Array.from(providerStats.models.entries()).sort((a, b) => b[1].cost - a[1].cost);
533
+
534
+ for (const [modelName, modelStats] of models) {
535
+ lines.push(this.renderDataRow(modelName, modelStats, { indent: 4, dimAll: true }));
536
+ }
537
+ }
538
+ }
539
+
540
+ return lines;
541
+ }
542
+
543
+ private renderTotals(): string[] {
544
+ const th = this.theme;
545
+ const stats = this.data[this.activeTab];
546
+
547
+ let totalRow = padRight(th.bold("Total"), NAME_COL_WIDTH);
548
+ for (const col of DATA_COLUMNS) {
549
+ const value = col.getValue(stats.totals);
550
+ totalRow += col.dimmed ? th.fg("dim", padLeft(value, col.width)) : padLeft(value, col.width);
551
+ }
552
+
553
+ return [th.fg("border", "─".repeat(TABLE_WIDTH)), totalRow, ""];
554
+ }
555
+
556
+ private renderHelp(): string[] {
557
+ return [this.theme.fg("dim", "[Tab/←→] period [↑↓] select [Enter] expand [q] close")];
558
+ }
559
+
560
+ invalidate(): void {}
561
+ dispose(): void {}
562
+ }
563
+
564
+ // =============================================================================
565
+ // Extension Entry Point
566
+ // =============================================================================
567
+
568
+ export default function (pi: ExtensionAPI) {
569
+ pi.registerCommand("usage", {
570
+ description: "Show usage statistics dashboard",
571
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
572
+ if (!ctx.hasUI) {
573
+ return;
574
+ }
575
+
576
+ const data = await ctx.ui.custom<UsageData | null>((tui, theme, _kb, done) => {
577
+ const loader = new CancellableLoader(
578
+ tui,
579
+ (s: string) => theme.fg("accent", s),
580
+ (s: string) => theme.fg("muted", s),
581
+ "Loading Usage..."
582
+ );
583
+ let finished = false;
584
+ const finish = (value: UsageData | null) => {
585
+ if (finished) return;
586
+ finished = true;
587
+ loader.dispose();
588
+ done(value);
589
+ };
590
+
591
+ loader.onAbort = () => finish(null);
592
+
593
+ collectUsageData(loader.signal)
594
+ .then(finish)
595
+ .catch(() => finish(null));
596
+
597
+ return loader;
598
+ });
599
+
600
+ if (!data) {
601
+ return;
602
+ }
603
+
604
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => {
605
+ const container = new Container();
606
+
607
+ // Top border
608
+ container.addChild(new Spacer(1));
609
+ container.addChild(new DynamicBorder((s: string) => theme.fg("border", s)));
610
+ container.addChild(new Spacer(1));
611
+
612
+ const usage = new UsageComponent(theme, data, () => tui.requestRender(), () => done());
613
+
614
+ return {
615
+ render: (w: number) => {
616
+ const borderLines = container.render(w);
617
+ const usageLines = usage.render(w);
618
+ const bottomBorder = theme.fg("border", "─".repeat(w));
619
+ return [...borderLines, ...usageLines, "", bottomBorder];
620
+ },
621
+ invalidate: () => container.invalidate(),
622
+ handleInput: (input: string) => usage.handleInput(input),
623
+ dispose: () => {},
624
+ };
625
+ });
626
+ },
627
+ });
628
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@tmustier/pi-usage-extension",
3
+ "version": "0.1.0",
4
+ "description": "Usage statistics dashboard for Pi sessions.",
5
+ "license": "MIT",
6
+ "author": "Thomas Mustier",
7
+ "keywords": [
8
+ "pi-package"
9
+ ],
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/tmustier/pi-extensions.git",
13
+ "directory": "usage-extension"
14
+ },
15
+ "bugs": "https://github.com/tmustier/pi-extensions/issues",
16
+ "homepage": "https://github.com/tmustier/pi-extensions/tree/main/usage-extension",
17
+ "pi": {
18
+ "extensions": [
19
+ "index.ts"
20
+ ]
21
+ }
22
+ }
Binary file