@tmustier/pi-usage-extension 0.3.2 → 0.6.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.
package/index.ts CHANGED
@@ -5,90 +5,30 @@
5
5
  * - Tab cycles: Today → This Week → Last Week → All Time
6
6
  * - Arrow keys navigate providers
7
7
  * - Enter expands/collapses to show models
8
+ *
9
+ * Data collection and caching live in ./data.ts.
8
10
  */
9
11
 
10
12
  import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
11
13
  import { DynamicBorder } from "@earendil-works/pi-coding-agent";
12
14
  import { CancellableLoader, Container, Spacer, matchesKey, visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/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
15
 
21
- interface TokenStats {
22
- total: number;
23
- input: number;
24
- output: number;
25
- cacheRead: number;
26
- cacheWrite: number;
27
- }
28
-
29
- interface BaseStats {
30
- messages: number;
31
- cost: number;
32
- tokens: TokenStats;
33
- }
34
-
35
- interface ModelStats extends BaseStats {
36
- sessions: Set<string>;
37
- }
38
-
39
- interface ProviderStats extends BaseStats {
40
- sessions: Set<string>;
41
- models: Map<string, ModelStats>;
42
- }
43
-
44
- interface TotalStats extends BaseStats {
45
- sessions: number;
46
- }
47
-
48
- interface Insight {
49
- percent: number; // 0-100
50
- headline: string;
51
- advice: string;
52
- }
53
-
54
- interface PeriodInsights {
55
- insights: Insight[];
56
- }
57
-
58
- interface RawMessage {
59
- sessionId: string;
60
- timestamp: number;
61
- cost: number;
62
- input: number;
63
- cacheRead: number;
64
- cacheWrite: number;
65
- }
66
-
67
- interface PeriodRawData {
68
- messages: RawMessage[];
69
- sessionCosts: Map<string, number>;
70
- }
71
-
72
- interface GlobalSessionSpan {
73
- startMs: number;
74
- endMs: number;
75
- }
76
-
77
- interface TimeFilteredStats {
78
- providers: Map<string, ProviderStats>;
79
- totals: TotalStats;
80
- insights: PeriodInsights;
81
- }
16
+ import { collectUsageData, TAB_ORDER } from "./data";
17
+ import type { BaseStats, TabName, UsageData } from "./data";
18
+ import {
19
+ buildGraphModel,
20
+ renderChart,
21
+ GROUP_LABELS,
22
+ GROUP_ORDER,
23
+ METRIC_LABELS,
24
+ METRIC_ORDER,
25
+ TOTAL_SERIES_KEY,
26
+ } from "./graph";
27
+ import type { GraphGroupBy, GraphMetric, GraphModel } from "./graph";
82
28
 
83
- interface UsageData {
84
- today: TimeFilteredStats;
85
- thisWeek: TimeFilteredStats;
86
- lastWeek: TimeFilteredStats;
87
- allTime: TimeFilteredStats;
88
- }
29
+ type ViewMode = "table" | "insights" | "graph";
89
30
 
90
- type TabName = "today" | "thisWeek" | "lastWeek" | "allTime";
91
- type ViewMode = "table" | "insights";
31
+ const VIEW_CYCLE: ViewMode[] = ["table", "insights", "graph"];
92
32
 
93
33
  // =============================================================================
94
34
  // Column Configuration
@@ -182,482 +122,6 @@ const TABLE_LAYOUTS: TableLayoutCandidate[] = [
182
122
  { columns: [COST_COLUMN], minNameWidth: 8, compact: true },
183
123
  ];
184
124
 
185
- // =============================================================================
186
- // Data Collection
187
- // =============================================================================
188
-
189
- function getSessionsDir(): string {
190
- // Replicate Pi's logic: respect PI_CODING_AGENT_DIR env var
191
- const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
192
- return join(agentDir, "sessions");
193
- }
194
-
195
- async function collectSessionFilesRecursively(dir: string, files: string[], signal?: AbortSignal): Promise<void> {
196
- try {
197
- const entries = await readdir(dir, { withFileTypes: true });
198
- for (const entry of entries) {
199
- if (signal?.aborted) return;
200
- const entryPath = join(dir, entry.name);
201
- if (entry.isDirectory()) {
202
- await collectSessionFilesRecursively(entryPath, files, signal);
203
- } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
204
- files.push(entryPath);
205
- }
206
- }
207
- } catch {
208
- // Skip directories we can't read
209
- }
210
- }
211
-
212
- async function getAllSessionFiles(signal?: AbortSignal): Promise<string[]> {
213
- const files: string[] = [];
214
- await collectSessionFilesRecursively(getSessionsDir(), files, signal);
215
- files.sort();
216
- return files;
217
- }
218
-
219
- interface SessionMessage {
220
- provider: string;
221
- model: string;
222
- cost: number;
223
- input: number;
224
- output: number;
225
- cacheRead: number;
226
- cacheWrite: number;
227
- timestamp: number;
228
- }
229
-
230
- interface ParsedSessionFile {
231
- sessionId: string;
232
- messages: SessionMessage[];
233
- }
234
-
235
- async function parseSessionFile(
236
- filePath: string,
237
- seenHashes: Set<string>,
238
- signal?: AbortSignal
239
- ): Promise<ParsedSessionFile | null> {
240
- try {
241
- const content = await readFile(filePath, "utf8");
242
- if (signal?.aborted) return null;
243
- const lines = content.trim().split("\n");
244
- const messages: SessionMessage[] = [];
245
- let sessionId = "";
246
-
247
- for (let i = 0; i < lines.length; i++) {
248
- if (signal?.aborted) return null;
249
- if (i % 500 === 0) {
250
- await new Promise<void>((resolve) => setImmediate(resolve));
251
- }
252
- const line = lines[i]!;
253
- if (!line.trim()) continue;
254
- try {
255
- const entry = JSON.parse(line);
256
-
257
- if (entry.type === "session") {
258
- sessionId = entry.id;
259
- } else if (entry.type === "message" && entry.message?.role === "assistant") {
260
- const msg = entry.message;
261
- if (msg.usage && msg.provider && msg.model) {
262
- const input = msg.usage.input || 0;
263
- const output = msg.usage.output || 0;
264
- const cacheRead = msg.usage.cacheRead || 0;
265
- const cacheWrite = msg.usage.cacheWrite || 0;
266
- const fallbackTs = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
267
- const timestamp = msg.timestamp || (Number.isNaN(fallbackTs) ? 0 : fallbackTs);
268
-
269
- // Deduplicate copied history across branched session files.
270
- // Keep the existing ccusage-style hash so current totals remain comparable.
271
- const totalTokens = input + output + cacheRead + cacheWrite;
272
- const hash = `${timestamp}:${totalTokens}`;
273
- if (seenHashes.has(hash)) continue;
274
- seenHashes.add(hash);
275
-
276
- messages.push({
277
- provider: msg.provider,
278
- model: msg.model,
279
- cost: msg.usage.cost?.total || 0,
280
- input,
281
- output,
282
- cacheRead,
283
- cacheWrite,
284
- timestamp,
285
- });
286
- }
287
- }
288
- } catch {
289
- // Skip malformed lines
290
- }
291
- }
292
-
293
- return sessionId ? { sessionId, messages } : null;
294
- } catch {
295
- return null;
296
- }
297
- }
298
-
299
- // Helper to accumulate stats into a target
300
- function accumulateStats(
301
- target: BaseStats,
302
- cost: number,
303
- tokens: { total: number; input: number; output: number; cacheRead: number; cacheWrite: number }
304
- ): void {
305
- target.messages++;
306
- target.cost += cost;
307
- target.tokens.total += tokens.total;
308
- target.tokens.input += tokens.input;
309
- target.tokens.output += tokens.output;
310
- target.tokens.cacheRead += tokens.cacheRead;
311
- target.tokens.cacheWrite += tokens.cacheWrite;
312
- }
313
-
314
- function emptyTokens(): TokenStats {
315
- return { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
316
- }
317
-
318
- function emptyModelStats(): ModelStats {
319
- return { sessions: new Set(), messages: 0, cost: 0, tokens: emptyTokens() };
320
- }
321
-
322
- function emptyProviderStats(): ProviderStats {
323
- return { sessions: new Set(), messages: 0, cost: 0, tokens: emptyTokens(), models: new Map() };
324
- }
325
-
326
- function emptyTimeFilteredStats(): TimeFilteredStats {
327
- return {
328
- providers: new Map(),
329
- totals: { sessions: 0, messages: 0, cost: 0, tokens: emptyTokens() },
330
- insights: { insights: [] },
331
- };
332
- }
333
-
334
- function emptyPeriodRawData(): PeriodRawData {
335
- return { messages: [], sessionCosts: new Map() };
336
- }
337
-
338
- function emptyUsageData(): UsageData {
339
- return {
340
- today: emptyTimeFilteredStats(),
341
- thisWeek: emptyTimeFilteredStats(),
342
- lastWeek: emptyTimeFilteredStats(),
343
- allTime: emptyTimeFilteredStats(),
344
- };
345
- }
346
-
347
- function getPeriodsForTimestamp(timestamp: number, todayMs: number, weekStartMs: number, lastWeekStartMs: number): TabName[] {
348
- const periods: TabName[] = ["allTime"];
349
- if (timestamp >= todayMs) periods.push("today");
350
- if (timestamp >= weekStartMs) {
351
- periods.push("thisWeek");
352
- } else if (timestamp >= lastWeekStartMs) {
353
- periods.push("lastWeek");
354
- }
355
- return periods;
356
- }
357
-
358
- function addMessagesToUsageData(
359
- data: UsageData,
360
- sessionId: string,
361
- messages: SessionMessage[],
362
- todayMs: number,
363
- weekStartMs: number,
364
- lastWeekStartMs: number,
365
- rawByPeriod: Record<TabName, PeriodRawData>,
366
- globalSessionSpans: Map<string, GlobalSessionSpan>
367
- ): void {
368
- const sessionContributed = { today: false, thisWeek: false, lastWeek: false, allTime: false };
369
-
370
- for (const msg of messages) {
371
- // Track real per-session lifetime across every message we see, regardless of
372
- // which period the message falls into. Used later for the "8h+ session" insight.
373
- if (msg.timestamp > 0) {
374
- const span = globalSessionSpans.get(sessionId);
375
- if (!span) {
376
- globalSessionSpans.set(sessionId, { startMs: msg.timestamp, endMs: msg.timestamp });
377
- } else {
378
- if (msg.timestamp < span.startMs) span.startMs = msg.timestamp;
379
- if (msg.timestamp > span.endMs) span.endMs = msg.timestamp;
380
- }
381
- }
382
-
383
- const periods = getPeriodsForTimestamp(msg.timestamp, todayMs, weekStartMs, lastWeekStartMs);
384
- const tokens = {
385
- // Count fresh tokens processed this turn.
386
- // Include cacheWrite because those prompt tokens were newly written and billed.
387
- // Exclude cacheRead because repeated cache hits would otherwise dominate totals.
388
- total: msg.input + msg.output + msg.cacheWrite,
389
- input: msg.input,
390
- output: msg.output,
391
- cacheRead: msg.cacheRead,
392
- cacheWrite: msg.cacheWrite,
393
- };
394
-
395
- for (const period of periods) {
396
- const stats = data[period];
397
-
398
- let providerStats = stats.providers.get(msg.provider);
399
- if (!providerStats) {
400
- providerStats = emptyProviderStats();
401
- stats.providers.set(msg.provider, providerStats);
402
- }
403
-
404
- let modelStats = providerStats.models.get(msg.model);
405
- if (!modelStats) {
406
- modelStats = emptyModelStats();
407
- providerStats.models.set(msg.model, modelStats);
408
- }
409
-
410
- modelStats.sessions.add(sessionId);
411
- accumulateStats(modelStats, msg.cost, tokens);
412
-
413
- providerStats.sessions.add(sessionId);
414
- accumulateStats(providerStats, msg.cost, tokens);
415
-
416
- accumulateStats(stats.totals, msg.cost, tokens);
417
- sessionContributed[period] = true;
418
-
419
- const raw = rawByPeriod[period];
420
- raw.messages.push({
421
- sessionId,
422
- timestamp: msg.timestamp,
423
- cost: msg.cost,
424
- input: msg.input,
425
- cacheRead: msg.cacheRead,
426
- cacheWrite: msg.cacheWrite,
427
- });
428
- raw.sessionCosts.set(sessionId, (raw.sessionCosts.get(sessionId) ?? 0) + msg.cost);
429
- }
430
- }
431
-
432
- if (sessionContributed.today) data.today.totals.sessions++;
433
- if (sessionContributed.thisWeek) data.thisWeek.totals.sessions++;
434
- if (sessionContributed.lastWeek) data.lastWeek.totals.sessions++;
435
- if (sessionContributed.allTime) data.allTime.totals.sessions++;
436
- }
437
-
438
- async function collectUsageData(signal?: AbortSignal): Promise<UsageData | null> {
439
- const startOfToday = new Date();
440
- startOfToday.setHours(0, 0, 0, 0);
441
- const todayMs = startOfToday.getTime();
442
-
443
- // Start of current week (Monday 00:00)
444
- const startOfWeek = new Date();
445
- const dayOfWeek = startOfWeek.getDay(); // 0 = Sunday, 1 = Monday, ...
446
- const daysSinceMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
447
- startOfWeek.setDate(startOfWeek.getDate() - daysSinceMonday);
448
- startOfWeek.setHours(0, 0, 0, 0);
449
- const weekStartMs = startOfWeek.getTime();
450
-
451
- // Start of last week (previous Monday 00:00)
452
- const startOfLastWeek = new Date(startOfWeek);
453
- startOfLastWeek.setDate(startOfLastWeek.getDate() - 7);
454
- const lastWeekStartMs = startOfLastWeek.getTime();
455
-
456
- const data = emptyUsageData();
457
- const rawByPeriod: Record<TabName, PeriodRawData> = {
458
- today: emptyPeriodRawData(),
459
- thisWeek: emptyPeriodRawData(),
460
- lastWeek: emptyPeriodRawData(),
461
- allTime: emptyPeriodRawData(),
462
- };
463
- const globalSessionSpans = new Map<string, GlobalSessionSpan>();
464
-
465
- const sessionFiles = await getAllSessionFiles(signal);
466
- if (signal?.aborted) return null;
467
- const seenHashes = new Set<string>();
468
-
469
- for (const filePath of sessionFiles) {
470
- if (signal?.aborted) return null;
471
- const parsed = await parseSessionFile(filePath, seenHashes, signal);
472
- if (signal?.aborted) return null;
473
- if (!parsed) continue;
474
-
475
- addMessagesToUsageData(
476
- data,
477
- parsed.sessionId,
478
- parsed.messages,
479
- todayMs,
480
- weekStartMs,
481
- lastWeekStartMs,
482
- rawByPeriod,
483
- globalSessionSpans
484
- );
485
-
486
- await new Promise<void>((resolve) => setImmediate(resolve));
487
- }
488
-
489
- // Classify sessions that are globally long-running once, then reuse across periods.
490
- const longSessionIds = new Set<string>();
491
- for (const [id, span] of globalSessionSpans) {
492
- if (span.endMs - span.startMs >= LONG_SESSION_MS) longSessionIds.add(id);
493
- }
494
-
495
- for (const period of TAB_ORDER) {
496
- data[period].insights = computeInsights(rawByPeriod[period], longSessionIds);
497
- }
498
-
499
- return data;
500
- }
501
-
502
- // =============================================================================
503
- // Insights
504
- // =============================================================================
505
-
506
- const PARALLEL_WINDOW_MS = 2 * 60_000; // exact ±N milliseconds around each message
507
- const PARALLEL_SESSION_THRESHOLD = 4;
508
- const LARGE_CONTEXT_THRESHOLD = 150_000;
509
- const LARGE_CACHE_MISS_THRESHOLD = 100_000;
510
- const LONG_SESSION_MS = 8 * 60 * 60 * 1000;
511
- const TOP_SESSION_COUNT = 5;
512
- const MIN_MESSAGES_FOR_PARALLEL_INSIGHT = 10;
513
- const MIN_PERCENT_TO_SHOW = 1;
514
-
515
- /**
516
- * Insights are weighted by recorded API cost. Periods with zero total cost produce
517
- * an empty `insights` list — the UI renders a distinct empty-state for that case.
518
- * Long-running-session classification is passed in from a global pass so that a
519
- * session's real lifetime is used rather than the slice visible inside this period.
520
- */
521
- function computeInsights(raw: PeriodRawData, longSessionIds: Set<string>): PeriodInsights {
522
- if (raw.messages.length === 0) {
523
- return { insights: [] };
524
- }
525
-
526
- const total = raw.messages.reduce((sum, m) => sum + m.cost, 0);
527
- if (total <= 0) {
528
- return { insights: [] };
529
- }
530
-
531
- const candidates: Insight[] = [];
532
-
533
- // 1. Parallel sessions — ≥ N unique sessions active within an exact ±W ms window.
534
- const parallelWeight = computeParallelCostWeight(raw.messages);
535
- if (parallelWeight !== null) {
536
- candidates.push({
537
- percent: (parallelWeight / total) * 100,
538
- headline: `of your cost was while ${PARALLEL_SESSION_THRESHOLD}+ sessions ran in parallel`,
539
- advice:
540
- "All sessions share one rate limit. If you don't need them all at once, queueing uses capacity more evenly.",
541
- });
542
- }
543
-
544
- // 2. Large context — input + cacheRead + cacheWrite > threshold.
545
- const largeContextWeight = raw.messages
546
- .filter((m) => m.input + m.cacheRead + m.cacheWrite > LARGE_CONTEXT_THRESHOLD)
547
- .reduce((sum, m) => sum + m.cost, 0);
548
- candidates.push({
549
- percent: (largeContextWeight / total) * 100,
550
- headline: `of your cost was at >${formatThresholdTokens(LARGE_CONTEXT_THRESHOLD)} context`,
551
- advice:
552
- "Longer sessions are more expensive even when cached. /compact mid-task, /clear when switching to new tasks.",
553
- });
554
-
555
- // 3. Large uncached prompt — fresh (non-cached) input > threshold, per the v0.2.0 formula.
556
- const uncachedWeight = raw.messages
557
- .filter((m) => m.input + m.cacheWrite > LARGE_CACHE_MISS_THRESHOLD)
558
- .reduce((sum, m) => sum + m.cost, 0);
559
- candidates.push({
560
- percent: (uncachedWeight / total) * 100,
561
- headline: `of your cost came from >${formatThresholdTokens(LARGE_CACHE_MISS_THRESHOLD)}-token uncached prompts`,
562
- advice:
563
- "Uncached input is expensive, and often happens when sending a message to a session that has gone idle. /compact before stepping away keeps the cold-start small.",
564
- });
565
-
566
- // 4. Long-running sessions — classification comes from the global pass so we use
567
- // true session lifetime, not just the span visible inside this period slice.
568
- const longWeight = raw.messages
569
- .filter((m) => longSessionIds.has(m.sessionId))
570
- .reduce((sum, m) => sum + m.cost, 0);
571
- if (longWeight > 0) {
572
- candidates.push({
573
- percent: (longWeight / total) * 100,
574
- headline: `of your cost came from sessions active for ${LONG_SESSION_MS / 3_600_000}+ hours`,
575
- advice:
576
- "These are often background/loop sessions. Continuous usage can add up quickly so make sure it is intentional.",
577
- });
578
- }
579
-
580
- // 5. Top-N session concentration.
581
- if (raw.sessionCosts.size > TOP_SESSION_COUNT) {
582
- const sortedSessions = Array.from(raw.sessionCosts.values()).sort((a, b) => b - a);
583
- const topN = Math.min(TOP_SESSION_COUNT, sortedSessions.length);
584
- const topWeight = sortedSessions.slice(0, topN).reduce((sum, c) => sum + c, 0);
585
- candidates.push({
586
- percent: (topWeight / total) * 100,
587
- headline: `of your cost came from your top ${topN} session${topN === 1 ? "" : "s"}`,
588
- advice:
589
- "A small number of sessions drives most of your spend. The table view can help pinpoint which ones.",
590
- });
591
- }
592
-
593
- const insights = candidates.filter((i) => i.percent >= MIN_PERCENT_TO_SHOW).sort((a, b) => b.percent - a.percent);
594
- return { insights };
595
- }
596
-
597
- /**
598
- * Two-pointer sweep of messages sorted by timestamp. For each message, count the
599
- * number of distinct session IDs whose messages fall within an exact ± window.
600
- * Returns the total cost attributable to moments when ≥ threshold sessions were
601
- * active, or null if the period has too few sessions/messages to call it.
602
- *
603
- * Messages with missing/invalid timestamps (parsed as 0) are filtered out first —
604
- * otherwise they would collapse into a single synthetic instant and inflate the
605
- * parallel count on older or incomplete logs.
606
- */
607
- function computeParallelCostWeight(messages: RawMessage[]): number | null {
608
- const timed = messages.filter((m) => m.timestamp > 0);
609
- if (timed.length < MIN_MESSAGES_FOR_PARALLEL_INSIGHT) return null;
610
- const distinctSessions = new Set(timed.map((m) => m.sessionId));
611
- if (distinctSessions.size < PARALLEL_SESSION_THRESHOLD) return null;
612
-
613
- const sorted = timed.slice().sort((a, b) => a.timestamp - b.timestamp);
614
- const sidCount = new Map<string, number>();
615
- let uniqueCount = 0;
616
- let left = 0;
617
- let right = 0;
618
- let parallelCost = 0;
619
-
620
- for (let i = 0; i < sorted.length; i++) {
621
- const current = sorted[i]!;
622
- const high = current.timestamp + PARALLEL_WINDOW_MS;
623
- const low = current.timestamp - PARALLEL_WINDOW_MS;
624
-
625
- while (right < sorted.length && sorted[right]!.timestamp <= high) {
626
- const sid = sorted[right]!.sessionId;
627
- const next = (sidCount.get(sid) ?? 0) + 1;
628
- sidCount.set(sid, next);
629
- if (next === 1) uniqueCount++;
630
- right++;
631
- }
632
- while (left < right && sorted[left]!.timestamp < low) {
633
- const sid = sorted[left]!.sessionId;
634
- const next = (sidCount.get(sid) ?? 0) - 1;
635
- if (next === 0) {
636
- sidCount.delete(sid);
637
- uniqueCount--;
638
- } else {
639
- sidCount.set(sid, next);
640
- }
641
- left++;
642
- }
643
-
644
- if (uniqueCount >= PARALLEL_SESSION_THRESHOLD) parallelCost += current.cost;
645
- }
646
-
647
- return parallelCost;
648
- }
649
-
650
- function formatThresholdTokens(n: number): string {
651
- if (n >= 1_000_000) return `${n / 1_000_000}M`;
652
- if (n >= 1_000) return `${n / 1_000}k`;
653
- return String(n);
654
- }
655
-
656
- function formatInsightPercent(p: number): string {
657
- if (p >= 10) return `${Math.round(p)}%`;
658
- return `${Math.round(p * 10) / 10}%`;
659
- }
660
-
661
125
  // =============================================================================
662
126
  // Formatting Helpers
663
127
  // =============================================================================
@@ -685,6 +149,37 @@ function formatNumber(n: number): string {
685
149
  return n.toLocaleString();
686
150
  }
687
151
 
152
+ // Compact axis/legend formatters for the graph view.
153
+ function formatAxisCost(v: number): string {
154
+ if (v === 0) return "$0";
155
+ if (v < 1) return `$${v.toFixed(2)}`;
156
+ if (v < 100) return `$${v.toFixed(1)}`;
157
+ if (v < 10_000) return `$${Math.round(v)}`;
158
+ if (v < 1_000_000) return `$${(v / 1000).toFixed(1)}k`;
159
+ return `$${(v / 1_000_000).toFixed(2)}M`;
160
+ }
161
+
162
+ function formatAxisCount(v: number): string {
163
+ if (v === 0) return "0";
164
+ if (v < 1000) return String(Math.round(v));
165
+ if (v < 1_000_000) return `${(v / 1000).toFixed(v < 10_000 ? 1 : 0)}k`;
166
+ if (v < 1_000_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
167
+ return `${(v / 1_000_000_000).toFixed(1)}B`;
168
+ }
169
+
170
+ // Bright ANSI palette for graph series (Total uses index 0).
171
+ const SERIES_COLORS = ["\x1b[97m", "\x1b[96m", "\x1b[95m", "\x1b[93m", "\x1b[92m", "\x1b[94m", "\x1b[91m", "\x1b[90m"];
172
+ const COLOR_RESET = "\x1b[39m";
173
+
174
+ function seriesColor(index: number): string {
175
+ return SERIES_COLORS[index % SERIES_COLORS.length]!;
176
+ }
177
+
178
+ function formatInsightPercent(p: number): string {
179
+ if (p >= 10) return `${Math.round(p)}%`;
180
+ return `${Math.round(p * 10) / 10}%`;
181
+ }
182
+
688
183
  function padLeft(s: string, len: number): string {
689
184
  const vis = visibleWidth(s);
690
185
  if (vis >= len) return s;
@@ -753,11 +248,10 @@ const TAB_LABELS: Record<TabName, string> = {
753
248
  today: "Today",
754
249
  thisWeek: "This Week",
755
250
  lastWeek: "Last Week",
251
+ last30Days: "Last 30 Days",
756
252
  allTime: "All Time",
757
253
  };
758
254
 
759
- const TAB_ORDER: TabName[] = ["today", "thisWeek", "lastWeek", "allTime"];
760
-
761
255
  class UsageComponent {
762
256
  private activeTab: TabName = "allTime";
763
257
  private viewMode: ViewMode = "table";
@@ -769,6 +263,13 @@ class UsageComponent {
769
263
  private requestRender: () => void;
770
264
  private done: () => void;
771
265
 
266
+ // Graph explorer state.
267
+ private graphMetric: GraphMetric = "cost";
268
+ private graphGroupBy: GraphGroupBy = "provider";
269
+ private graphCumulative = true;
270
+ private graphHidden = new Set<string>();
271
+ private graphLegendIndex = 0;
272
+
772
273
  constructor(theme: Theme, data: UsageData, requestRender: () => void, done: () => void) {
773
274
  this.theme = theme;
774
275
  this.requestRender = requestRender;
@@ -792,11 +293,16 @@ class UsageComponent {
792
293
  }
793
294
 
794
295
  if (matchesKey(data, "v")) {
795
- this.viewMode = this.viewMode === "table" ? "insights" : "table";
296
+ const idx = VIEW_CYCLE.indexOf(this.viewMode);
297
+ this.viewMode = VIEW_CYCLE[(idx + 1) % VIEW_CYCLE.length]!;
796
298
  this.requestRender();
797
299
  return;
798
300
  }
799
301
 
302
+ if (this.viewMode === "graph" && this.handleGraphInput(data)) {
303
+ return;
304
+ }
305
+
800
306
  if (matchesKey(data, "tab") || matchesKey(data, "right")) {
801
307
  const idx = TAB_ORDER.indexOf(this.activeTab);
802
308
  this.activeTab = TAB_ORDER[(idx + 1) % TAB_ORDER.length]!;
@@ -807,6 +313,8 @@ class UsageComponent {
807
313
  this.activeTab = TAB_ORDER[(idx - 1 + TAB_ORDER.length) % TAB_ORDER.length]!;
808
314
  this.updateProviderOrder();
809
315
  this.requestRender();
316
+ } else if (this.viewMode === "graph") {
317
+ // Graph-specific keys were handled above; swallow table-only keys.
810
318
  } else if (this.viewMode === "table" && matchesKey(data, "up")) {
811
319
  if (this.selectedIndex > 0) {
812
320
  this.selectedIndex--;
@@ -834,7 +342,57 @@ class UsageComponent {
834
342
  // Render Methods
835
343
  // -------------------------------------------------------------------------
836
344
 
345
+ private handleGraphInput(data: string): boolean {
346
+ if (matchesKey(data, "m")) {
347
+ const idx = METRIC_ORDER.indexOf(this.graphMetric);
348
+ this.graphMetric = METRIC_ORDER[(idx + 1) % METRIC_ORDER.length]!;
349
+ } else if (matchesKey(data, "g")) {
350
+ const idx = GROUP_ORDER.indexOf(this.graphGroupBy);
351
+ this.graphGroupBy = GROUP_ORDER[(idx + 1) % GROUP_ORDER.length]!;
352
+ this.graphHidden.clear();
353
+ this.graphLegendIndex = 0;
354
+ } else if (matchesKey(data, "c")) {
355
+ this.graphCumulative = !this.graphCumulative;
356
+ } else if (matchesKey(data, "a")) {
357
+ this.graphHidden.clear();
358
+ } else if (matchesKey(data, "up")) {
359
+ this.graphLegendIndex = Math.max(0, this.graphLegendIndex - 1);
360
+ } else if (matchesKey(data, "down")) {
361
+ const count = this.buildGraphModelForView().series.length;
362
+ this.graphLegendIndex = Math.min(Math.max(count - 1, 0), this.graphLegendIndex + 1);
363
+ } else if (matchesKey(data, "enter") || matchesKey(data, "space")) {
364
+ const model = this.buildGraphModelForView();
365
+ const target = model.series[this.graphLegendIndex];
366
+ if (target) {
367
+ if (this.graphHidden.has(target.key)) this.graphHidden.delete(target.key);
368
+ else this.graphHidden.add(target.key);
369
+ }
370
+ } else {
371
+ return false;
372
+ }
373
+ this.requestRender();
374
+ return true;
375
+ }
376
+
377
+ private buildGraphModelForView(): GraphModel {
378
+ return buildGraphModel(this.data.hourly, {
379
+ period: this.activeTab,
380
+ metric: this.graphMetric,
381
+ groupBy: this.graphGroupBy,
382
+ cumulative: this.graphCumulative,
383
+ hidden: this.graphHidden,
384
+ bounds: this.data.bounds,
385
+ });
386
+ }
387
+
837
388
  render(width: number): string[] {
389
+ if (this.viewMode === "graph") {
390
+ return clampLines(
391
+ [...this.renderTitle(), ...this.renderTabs(width, getTableLayout(width)), ...this.renderGraph(width), ...this.renderHelp(width)],
392
+ width
393
+ );
394
+ }
395
+
838
396
  if (this.viewMode === "insights") {
839
397
  return clampLines(
840
398
  [
@@ -864,10 +422,67 @@ class UsageComponent {
864
422
 
865
423
  private renderTitle(): string[] {
866
424
  const th = this.theme;
867
- const label = this.viewMode === "insights" ? "Usage Insights" : "Usage Statistics";
425
+ const label =
426
+ this.viewMode === "insights" ? "Usage Insights" : this.viewMode === "graph" ? "Usage Graphs" : "Usage Statistics";
868
427
  return [th.fg("accent", th.bold(label)), ""];
869
428
  }
870
429
 
430
+ private renderGraph(width: number): string[] {
431
+ const th = this.theme;
432
+ const model = this.buildGraphModelForView();
433
+ const lines: string[] = [];
434
+
435
+ const modeLabel = `${this.graphCumulative ? "Cumulative" : "Per bucket"} ${METRIC_LABELS[this.graphMetric]} · ${GROUP_LABELS[this.graphGroupBy]}`;
436
+ lines.push(th.fg("muted", modeLabel));
437
+ lines.push("");
438
+
439
+ if (model.groupedTotal === 0 && model.series.every((s) => s.total === 0)) {
440
+ lines.push(th.fg("dim", " No usage data for this period"));
441
+ lines.push("");
442
+ return lines;
443
+ }
444
+
445
+ const formatValue = this.graphMetric === "cost" ? formatAxisCost : formatAxisCount;
446
+ const spanMs = model.domainEndMs - model.domainStartMs;
447
+ const formatTime = (ms: number): string => {
448
+ const d = new Date(ms);
449
+ if (spanMs <= 26 * 3_600_000) {
450
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
451
+ }
452
+ return d.toLocaleDateString(undefined, { day: "numeric", month: "short" });
453
+ };
454
+
455
+ const chartHeight = 12;
456
+ const chart = renderChart(model, {
457
+ width: Math.max(Math.min(width, 110), 30),
458
+ height: chartHeight,
459
+ formatValue,
460
+ formatTime,
461
+ colorize: (seriesIndex, text) => {
462
+ if (seriesIndex < 0) return th.fg("dim", text);
463
+ return seriesColor(seriesIndex) + text + COLOR_RESET;
464
+ },
465
+ });
466
+ lines.push(...chart);
467
+ lines.push("");
468
+
469
+ // Legend with selection cursor and hide/show state.
470
+ for (let i = 0; i < model.series.length; i++) {
471
+ const s = model.series[i]!;
472
+ const cursor = i === this.graphLegendIndex ? th.fg("accent", "▸ ") : " ";
473
+ const marker = s.hidden ? th.fg("dim", "○") : seriesColor(i) + "●" + COLOR_RESET;
474
+ const value = this.graphMetric === "cost" ? formatAxisCost(s.total) : formatAxisCount(s.total);
475
+ const pct =
476
+ s.key !== TOTAL_SERIES_KEY && model.groupedTotal > 0
477
+ ? ` ${th.fg("dim", `${Math.round((s.total / model.groupedTotal) * 100)}%`)}`
478
+ : "";
479
+ const label = s.hidden ? th.fg("dim", s.label) : s.key === TOTAL_SERIES_KEY ? th.bold(s.label) : s.label;
480
+ lines.push(`${cursor}${marker} ${padRight(label, 24)} ${padLeft(value, 8)}${pct}`);
481
+ }
482
+ lines.push("");
483
+ return lines;
484
+ }
485
+
871
486
  private renderInsights(width: number): string[] {
872
487
  const th = this.theme;
873
488
  const stats = this.data[this.activeTab];
@@ -1038,11 +653,19 @@ class UsageComponent {
1038
653
 
1039
654
  private renderHelp(width: number): string[] {
1040
655
  const variants =
1041
- this.viewMode === "insights"
656
+ this.viewMode === "graph"
657
+ ? [
658
+ "[Tab/←→] period [m] metric [g] group [c] cumulative [↑↓/Enter] filter [a] all [v] view [q] close",
659
+ "[Tab] period [m] metric [g] group [c] cumul [↑↓/Enter] filter [v] view [q] close",
660
+ "[m] metric [g] group [c] cumul [↑↓] filter [q] close",
661
+ "[m] [g] [c] [↑↓] [q]",
662
+ "[q] close",
663
+ ]
664
+ : this.viewMode === "insights"
1042
665
  ? [
1043
- "[Tab/←→] period [v] table view [q] close",
1044
- "[Tab] period [v] table [q] close",
1045
- "[v] table [q] close",
666
+ "[Tab/←→] period [v] graph view [q] close",
667
+ "[Tab] period [v] graphs [q] close",
668
+ "[v] graphs [q] close",
1046
669
  "[q] close",
1047
670
  ]
1048
671
  : [
@@ -1090,7 +713,7 @@ export default function (pi: ExtensionAPI) {
1090
713
 
1091
714
  loader.onAbort = () => finish(null);
1092
715
 
1093
- collectUsageData(loader.signal)
716
+ collectUsageData({ signal: loader.signal })
1094
717
  .then(finish)
1095
718
  .catch(() => finish(null));
1096
719