@tmustier/pi-usage-extension 0.5.0 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.0] - 2026-07-17
4
+
5
+ ### Added
6
+ - **Interactive graph explorer.** New third view mode (cycle with `v`): a braille line chart of usage over time for the active period, with a legend showing per-series totals and shares.
7
+ - Metrics (`m`): cost, tokens, messages, reasoning tokens.
8
+ - Grouping (`g`): by provider, by model, by thinking level, or total only — top 6 series plus an `other` rollup, with a bold Total line always drawn.
9
+ - Cumulative running totals or per-bucket rates (`c`), hourly buckets for day/week periods and daily buckets for Last 30 Days / All Time.
10
+ - Legend filtering: `↑`/`↓` moves the cursor, `Enter`/`Space` hides or shows a series, `a` shows all. The y-axis rescales to the visible series so small series can be inspected by hiding large ones.
11
+ - **Thinking level and reasoning tokens.** Session parsing now replays `thinking_level_change` entries (compact and spaced JSON styles) to attribute a thinking level to each assistant message, and records `usage.reasoning` token counts. Messages before the first recorded change appear as `unknown`.
12
+
13
+ ### Changed
14
+ - On-disk cache format bumped to v2 (per-message thinking level and reasoning tokens). The first open after upgrading does a one-off full rebuild; older caches are ignored safely.
15
+
3
16
  ## [0.5.0] - 2026-07-17
4
17
 
5
18
  ### Added
package/README.md CHANGED
@@ -7,7 +7,7 @@ A Pi extension that displays aggregated usage statistics across all sessions.
7
7
  ## Compatibility
8
8
 
9
9
  - **Pi version:** 0.42.4+
10
- - **Last updated:** 2026-07-17 (0.5.0)
10
+ - **Last updated:** 2026-07-17 (0.6.0)
11
11
 
12
12
  ## Installation
13
13
 
@@ -57,15 +57,28 @@ In Pi, run:
57
57
 
58
58
  ### Views
59
59
 
60
- `/usage` has two view modes, toggled with `v`:
60
+ `/usage` has three view modes, cycled with `v`:
61
61
 
62
62
  - **Table** (default) — per-provider / per-model stats with cost and token breakdown (screenshot at the top of this page).
63
63
  - **Insights** — narrative characteristics of your cost for the active time period, e.g. *"X% of your cost was at >150k context"*. Insights are **independent characteristics**, not a breakdown, so they overlap and can sum to more than 100%.
64
+ - **Graphs** — an interactive braille line-chart explorer for usage over time (details below).
64
65
 
65
66
  ![Insights view of /usage](insights-screenshot.png)
66
67
 
67
68
  **Unit:** insights are always weighted by recorded API cost (USD). Periods with no recorded cost show an explicit empty state rather than silently switching to a different unit.
68
69
 
70
+ ### Graph explorer
71
+
72
+ The **Graphs** view plots usage over time for the active period as a braille line chart, with a legend showing per-series totals and shares.
73
+
74
+ - **Metric** (`m` to cycle): cost, tokens (input + output + cache write), messages, reasoning tokens.
75
+ - **Grouping** (`g` to cycle): by provider, by model, by thinking level, or total only. The top 6 series are shown individually; the rest merge into an `other` series. A bold **Total** line is always drawn.
76
+ - **Cumulative vs per-bucket** (`c` to toggle): running total across the period (default), or the raw per-bucket rate.
77
+ - **Filtering**: move the legend cursor with `↑`/`↓` and toggle series visibility with `Enter`/`Space` (`a` shows all again). The y-axis rescales to the visible series — hide the big lines to zoom into the small ones.
78
+ - **Buckets**: hourly for Today / This Week / Last Week, daily for Last 30 Days / All Time.
79
+
80
+ Thinking levels are replayed from `thinking_level_change` entries in each session file; messages before the first recorded change appear as `unknown`. Reasoning token counts come from `usage.reasoning` where providers report them.
81
+
69
82
  The insights currently shown:
70
83
 
71
84
  | Insight | Threshold |
@@ -114,9 +127,13 @@ On narrow terminals, `/usage` automatically switches to a compact table instead
114
127
  | Key | Action |
115
128
  |-----|--------|
116
129
  | `Tab` / `←` `→` | Switch time period |
117
- | `↑` `↓` | Select provider *(table view)* |
118
- | `Enter` / `Space` | Expand/collapse provider to show models *(table view)* |
119
- | `v` | Toggle between Table and Insights view |
130
+ | `↑` `↓` | Select provider *(table)* / move legend cursor *(graphs)* |
131
+ | `Enter` / `Space` | Expand/collapse provider *(table)* / toggle series visibility *(graphs)* |
132
+ | `v` | Cycle Table → Insights → Graphs view |
133
+ | `m` | Cycle metric: cost / tokens / messages / reasoning *(graphs)* |
134
+ | `g` | Cycle grouping: provider / model / thinking level / total *(graphs)* |
135
+ | `c` | Toggle cumulative vs per-bucket *(graphs)* |
136
+ | `a` | Show all series *(graphs)* |
120
137
  | `q` / `Esc` | Close |
121
138
 
122
139
  ## Performance & Caching
@@ -126,6 +143,7 @@ On narrow terminals, `/usage` automatically switches to a compact table instead
126
143
  - **On-disk cache.** Per-file extraction results are cached in `<agentDir>/usage-extension-cache.json` (respects `PI_CODING_AGENT_DIR`), keyed by file size + mtime. Warm opens only re-parse session files that changed since the last run — on a 5.2 GB / 3,310-file corpus that takes the open from ~17 s to ~0.3 s.
127
144
  - **First open** after install (or after deleting the cache) does a one-off full build, showing the usual cancellable loader. Cancelling saves partial progress, so the next open resumes where it left off.
128
145
  - The cache is safe to delete at any time; it is rebuilt automatically. Corrupt or version-mismatched caches are ignored and rebuilt rather than trusted.
146
+ - **0.6.0 bumps the cache format to v2** (adds thinking level and reasoning tokens per message). The first open after upgrading does a one-off full rebuild, then warm opens are fast again.
129
147
 
130
148
  ## Provider Notes
131
149
 
package/data.ts CHANGED
@@ -81,12 +81,51 @@ export interface TimeFilteredStats {
81
81
  insights: PeriodInsights;
82
82
  }
83
83
 
84
+ /**
85
+ * One (provider, model, thinkingLevel) cell inside an hourly bucket.
86
+ * Powers the graph explorer; built post-dedupe so it matches table totals.
87
+ */
88
+ export interface HourlyCell {
89
+ messages: number;
90
+ cost: number;
91
+ input: number;
92
+ output: number;
93
+ cacheRead: number;
94
+ cacheWrite: number;
95
+ reasoning: number;
96
+ }
97
+
98
+ /** Composite key: `${provider}\u0000${model}\u0000${thinkingLevel}` */
99
+ export type HourlyKey = string;
100
+
101
+ export const HOURLY_KEY_SEP = "\u0000";
102
+
103
+ export function makeHourlyKey(provider: string, model: string, thinkingLevel: string): HourlyKey {
104
+ return provider + HOURLY_KEY_SEP + model + HOURLY_KEY_SEP + thinkingLevel;
105
+ }
106
+
107
+ export function splitHourlyKey(key: HourlyKey): { provider: string; model: string; thinkingLevel: string } {
108
+ const [provider = "", model = "", thinkingLevel = ""] = key.split(HOURLY_KEY_SEP);
109
+ return { provider, model, thinkingLevel };
110
+ }
111
+
112
+ export interface PeriodBounds {
113
+ todayMs: number;
114
+ weekStartMs: number;
115
+ lastWeekStartMs: number;
116
+ last30DaysStartMs: number;
117
+ nowMs: number;
118
+ }
119
+
84
120
  export interface UsageData {
85
121
  today: TimeFilteredStats;
86
122
  thisWeek: TimeFilteredStats;
87
123
  lastWeek: TimeFilteredStats;
88
124
  last30Days: TimeFilteredStats;
89
125
  allTime: TimeFilteredStats;
126
+ /** Deduped usage bucketed by hour start (ms) → series key → metrics. */
127
+ hourly: Map<number, Map<HourlyKey, HourlyCell>>;
128
+ bounds: PeriodBounds;
90
129
  }
91
130
 
92
131
  export type TabName = "today" | "thisWeek" | "lastWeek" | "last30Days" | "allTime";
@@ -96,11 +135,15 @@ export const TAB_ORDER: TabName[] = ["today", "thisWeek", "lastWeek", "last30Day
96
135
  export interface SessionMessage {
97
136
  provider: string;
98
137
  model: string;
138
+ /** Thinking level active when the message was produced; "" when unknown. */
139
+ thinkingLevel: string;
99
140
  cost: number;
100
141
  input: number;
101
142
  output: number;
102
143
  cacheRead: number;
103
144
  cacheWrite: number;
145
+ /** Reasoning output tokens reported by the provider (0 when absent). */
146
+ reasoning: number;
104
147
  timestamp: number;
105
148
  }
106
149
 
@@ -171,6 +214,8 @@ const PATTERN_ASSISTANT_COMPACT = Buffer.from('"role":"assistant"');
171
214
  const PATTERN_ASSISTANT_SPACED = Buffer.from('"role": "assistant"');
172
215
  const PATTERN_SESSION_COMPACT = Buffer.from('"type":"session"');
173
216
  const PATTERN_SESSION_SPACED = Buffer.from('"type": "session"');
217
+ const PATTERN_THINKING_COMPACT = Buffer.from('"type":"thinking_level_change"');
218
+ const PATTERN_THINKING_SPACED = Buffer.from('"type": "thinking_level_change"');
174
219
 
175
220
  const PARSE_YIELD_EVERY_LINES = 2000;
176
221
 
@@ -178,8 +223,10 @@ function lineMightBeRelevant(line: Buffer): boolean {
178
223
  return (
179
224
  line.includes(PATTERN_ASSISTANT_COMPACT) ||
180
225
  line.includes(PATTERN_SESSION_COMPACT) ||
226
+ line.includes(PATTERN_THINKING_COMPACT) ||
181
227
  line.includes(PATTERN_ASSISTANT_SPACED) ||
182
- line.includes(PATTERN_SESSION_SPACED)
228
+ line.includes(PATTERN_SESSION_SPACED) ||
229
+ line.includes(PATTERN_THINKING_SPACED)
183
230
  );
184
231
  }
185
232
 
@@ -191,6 +238,11 @@ function lineMightBeRelevant(line: Buffer): boolean {
191
238
  export async function parseSessionBuffer(buffer: Buffer, signal?: AbortSignal): Promise<ParsedSessionFile> {
192
239
  const messages: SessionMessage[] = [];
193
240
  let sessionId = "";
241
+ // Assistant messages don't carry the thinking level; pi records it as separate
242
+ // thinking_level_change entries, always written before the first assistant
243
+ // message of a session. Replaying them in append order attributes each message
244
+ // to the level active when it was produced.
245
+ let thinkingLevel = "";
194
246
  let start = 0;
195
247
  let lineNumber = 0;
196
248
 
@@ -210,6 +262,8 @@ export async function parseSessionBuffer(buffer: Buffer, signal?: AbortSignal):
210
262
 
211
263
  if (entry.type === "session") {
212
264
  sessionId = entry.id;
265
+ } else if (entry.type === "thinking_level_change") {
266
+ if (typeof entry.thinkingLevel === "string") thinkingLevel = entry.thinkingLevel;
213
267
  } else if (entry.type === "message" && entry.message?.role === "assistant") {
214
268
  const msg = entry.message;
215
269
  if (msg.usage && msg.provider && msg.model) {
@@ -217,11 +271,13 @@ export async function parseSessionBuffer(buffer: Buffer, signal?: AbortSignal):
217
271
  messages.push({
218
272
  provider: msg.provider,
219
273
  model: msg.model,
274
+ thinkingLevel,
220
275
  cost: msg.usage.cost?.total || 0,
221
276
  input: msg.usage.input || 0,
222
277
  output: msg.usage.output || 0,
223
278
  cacheRead: msg.usage.cacheRead || 0,
224
279
  cacheWrite: msg.usage.cacheWrite || 0,
280
+ reasoning: msg.usage.reasoning || 0,
225
281
  timestamp: msg.timestamp || (Number.isNaN(fallbackTs) ? 0 : fallbackTs),
226
282
  });
227
283
  }
@@ -241,7 +297,7 @@ export async function parseSessionBuffer(buffer: Buffer, signal?: AbortSignal):
241
297
  // On-disk cache
242
298
  // =============================================================================
243
299
 
244
- const CACHE_VERSION = 1;
300
+ const CACHE_VERSION = 2;
245
301
 
246
302
  type CachedMessageTuple = [
247
303
  providerIdx: number,
@@ -252,6 +308,8 @@ type CachedMessageTuple = [
252
308
  cacheRead: number,
253
309
  cacheWrite: number,
254
310
  timestamp: number,
311
+ thinkingLevelIdx: number,
312
+ reasoning: number,
255
313
  ];
256
314
 
257
315
  interface CacheFileEntry {
@@ -293,25 +351,28 @@ export async function loadUsageCache(cachePath: string): Promise<Map<string, Cac
293
351
  const messages: SessionMessage[] = [];
294
352
  let valid = true;
295
353
  for (const tuple of entry.messages) {
296
- if (!Array.isArray(tuple) || tuple.length !== 8) {
354
+ if (!Array.isArray(tuple) || tuple.length !== 10) {
297
355
  valid = false;
298
356
  break;
299
357
  }
300
358
  const provider = names[tuple[0]];
301
359
  const model = names[tuple[1]];
302
- if (typeof provider !== "string" || typeof model !== "string") {
360
+ const thinkingLevel = names[tuple[8]];
361
+ if (typeof provider !== "string" || typeof model !== "string" || typeof thinkingLevel !== "string") {
303
362
  valid = false;
304
363
  break;
305
364
  }
306
365
  messages.push({
307
366
  provider,
308
367
  model,
368
+ thinkingLevel,
309
369
  cost: Number(tuple[2]) || 0,
310
370
  input: Number(tuple[3]) || 0,
311
371
  output: Number(tuple[4]) || 0,
312
372
  cacheRead: Number(tuple[5]) || 0,
313
373
  cacheWrite: Number(tuple[6]) || 0,
314
374
  timestamp: Number(tuple[7]) || 0,
375
+ reasoning: Number(tuple[9]) || 0,
315
376
  });
316
377
  }
317
378
  if (!valid) continue;
@@ -352,6 +413,8 @@ export async function saveUsageCache(cachePath: string, states: Map<string, Cach
352
413
  m.cacheRead,
353
414
  m.cacheWrite,
354
415
  m.timestamp,
416
+ intern(m.thinkingLevel),
417
+ m.reasoning,
355
418
  ]),
356
419
  };
357
420
  }
@@ -392,16 +455,43 @@ function emptyPeriodRawData(): PeriodRawData {
392
455
  return { messages: [], sessionCosts: new Map() };
393
456
  }
394
457
 
395
- function emptyUsageData(): UsageData {
458
+ function emptyUsageData(bounds: PeriodBounds): UsageData {
396
459
  return {
397
460
  today: emptyTimeFilteredStats(),
398
461
  thisWeek: emptyTimeFilteredStats(),
399
462
  lastWeek: emptyTimeFilteredStats(),
400
463
  last30Days: emptyTimeFilteredStats(),
401
464
  allTime: emptyTimeFilteredStats(),
465
+ hourly: new Map(),
466
+ bounds,
402
467
  };
403
468
  }
404
469
 
470
+ const HOUR_MS = 3_600_000;
471
+
472
+ function addToHourlyBuckets(hourly: Map<number, Map<HourlyKey, HourlyCell>>, msg: SessionMessage): void {
473
+ if (msg.timestamp <= 0) return; // Unknown time can't be placed on a time axis.
474
+ const hour = Math.floor(msg.timestamp / HOUR_MS) * HOUR_MS;
475
+ let bucket = hourly.get(hour);
476
+ if (!bucket) {
477
+ bucket = new Map();
478
+ hourly.set(hour, bucket);
479
+ }
480
+ const key = makeHourlyKey(msg.provider, msg.model, msg.thinkingLevel);
481
+ let cell = bucket.get(key);
482
+ if (!cell) {
483
+ cell = { messages: 0, cost: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 };
484
+ bucket.set(key, cell);
485
+ }
486
+ cell.messages++;
487
+ cell.cost += msg.cost;
488
+ cell.input += msg.input;
489
+ cell.output += msg.output;
490
+ cell.cacheRead += msg.cacheRead;
491
+ cell.cacheWrite += msg.cacheWrite;
492
+ cell.reasoning += msg.reasoning;
493
+ }
494
+
405
495
  // Helper to accumulate stats into a target
406
496
  function accumulateStats(
407
497
  target: BaseStats,
@@ -461,6 +551,8 @@ function addMessagesToUsageData(
461
551
  }
462
552
  }
463
553
 
554
+ addToHourlyBuckets(data.hourly, msg);
555
+
464
556
  const periods = getPeriodsForTimestamp(msg.timestamp, todayMs, weekStartMs, lastWeekStartMs, last30DaysStartMs);
465
557
  const tokens = {
466
558
  // Count fresh tokens processed this turn.
@@ -659,7 +751,7 @@ export async function collectUsageData(options: CollectUsageOptions = {}): Promi
659
751
  }
660
752
 
661
753
  // 6. Aggregate in sorted path order with cross-file dedupe.
662
- const data = emptyUsageData();
754
+ const data = emptyUsageData({ todayMs, weekStartMs, lastWeekStartMs, last30DaysStartMs, nowMs: now.getTime() });
663
755
  const rawByPeriod: Record<TabName, PeriodRawData> = {
664
756
  today: emptyPeriodRawData(),
665
757
  thisWeek: emptyPeriodRawData(),
package/graph.ts ADDED
@@ -0,0 +1,386 @@
1
+ /**
2
+ * Graph explorer model + braille chart rendering for /usage.
3
+ *
4
+ * Everything here is pure and theme-free: series building works off the hourly
5
+ * buckets produced by data.ts, and the renderer emits plain text plus a
6
+ * colorize callback so the UI layer owns all styling. This keeps the module
7
+ * fully unit-testable.
8
+ */
9
+
10
+ // Explicit .ts extension so plain `node --test` (type stripping) can resolve
11
+ // this module too; pi's extension loader accepts it as well.
12
+ import type { HourlyCell, HourlyKey, PeriodBounds, TabName } from "./data.ts";
13
+ import { splitHourlyKey } from "./data.ts";
14
+
15
+ // =============================================================================
16
+ // Options and model types
17
+ // =============================================================================
18
+
19
+ export type GraphMetric = "cost" | "tokens" | "messages" | "reasoning";
20
+ export type GraphGroupBy = "provider" | "model" | "thinking" | "total";
21
+
22
+ export const METRIC_ORDER: GraphMetric[] = ["cost", "tokens", "messages", "reasoning"];
23
+ export const GROUP_ORDER: GraphGroupBy[] = ["provider", "model", "thinking", "total"];
24
+
25
+ export const METRIC_LABELS: Record<GraphMetric, string> = {
26
+ cost: "cost",
27
+ tokens: "tokens",
28
+ messages: "messages",
29
+ reasoning: "reasoning tokens",
30
+ };
31
+
32
+ export const GROUP_LABELS: Record<GraphGroupBy, string> = {
33
+ provider: "by provider",
34
+ model: "by model",
35
+ thinking: "by thinking level",
36
+ total: "total only",
37
+ };
38
+
39
+ /** Series beyond this cap are merged into a single "other" series. */
40
+ export const MAX_GROUP_SERIES = 6;
41
+
42
+ export const TOTAL_SERIES_KEY = "\u0000total";
43
+ export const OTHER_SERIES_KEY = "\u0000other";
44
+
45
+ export interface GraphOptions {
46
+ period: TabName;
47
+ metric: GraphMetric;
48
+ groupBy: GraphGroupBy;
49
+ cumulative: boolean;
50
+ /** Series keys hidden via the legend. */
51
+ hidden?: ReadonlySet<string>;
52
+ bounds: PeriodBounds;
53
+ }
54
+
55
+ export interface GraphSeries {
56
+ key: string;
57
+ label: string;
58
+ /** One value per bucket. Cumulative when options.cumulative. */
59
+ points: number[];
60
+ /** Period total for this series (not affected by cumulative). */
61
+ total: number;
62
+ hidden: boolean;
63
+ }
64
+
65
+ export interface GraphModel {
66
+ series: GraphSeries[];
67
+ /** Bucket start timestamps (ms), ascending. */
68
+ bucketStarts: number[];
69
+ bucketMs: number;
70
+ domainStartMs: number;
71
+ domainEndMs: number;
72
+ /** Max point value across visible series (y-axis scale). */
73
+ yMax: number;
74
+ /** Sum of totals across grouped (non-total) series, for legend percentages. */
75
+ groupedTotal: number;
76
+ }
77
+
78
+ // =============================================================================
79
+ // Series building
80
+ // =============================================================================
81
+
82
+ const HOUR_MS = 3_600_000;
83
+ const DAY_MS = 24 * HOUR_MS;
84
+ /** Periods spanning at most this many hours use hourly buckets; otherwise daily. */
85
+ const MAX_HOURLY_BUCKETS = 8 * 24;
86
+
87
+ function metricOf(cell: HourlyCell, metric: GraphMetric): number {
88
+ switch (metric) {
89
+ case "cost":
90
+ return cell.cost;
91
+ case "tokens":
92
+ // Matches the dashboard formula: fresh tokens = input + output + cacheWrite.
93
+ return cell.input + cell.output + cell.cacheWrite;
94
+ case "messages":
95
+ return cell.messages;
96
+ case "reasoning":
97
+ return cell.reasoning;
98
+ }
99
+ }
100
+
101
+ function groupKeyOf(key: HourlyKey, groupBy: GraphGroupBy): string {
102
+ if (groupBy === "total") return TOTAL_SERIES_KEY;
103
+ const { provider, model, thinkingLevel } = splitHourlyKey(key);
104
+ if (groupBy === "provider") return provider;
105
+ if (groupBy === "model") return model;
106
+ return thinkingLevel === "" ? "unknown" : thinkingLevel;
107
+ }
108
+
109
+ function domainFor(period: TabName, bounds: PeriodBounds, hourly: Map<number, Map<HourlyKey, HourlyCell>>): { startMs: number; endMs: number } {
110
+ switch (period) {
111
+ case "today":
112
+ return { startMs: bounds.todayMs, endMs: bounds.nowMs };
113
+ case "thisWeek":
114
+ return { startMs: bounds.weekStartMs, endMs: bounds.nowMs };
115
+ case "lastWeek":
116
+ return { startMs: bounds.lastWeekStartMs, endMs: bounds.weekStartMs };
117
+ case "last30Days":
118
+ return { startMs: bounds.last30DaysStartMs, endMs: bounds.nowMs };
119
+ case "allTime": {
120
+ let first = Number.POSITIVE_INFINITY;
121
+ for (const hour of hourly.keys()) if (hour < first) first = hour;
122
+ if (!Number.isFinite(first)) first = bounds.todayMs;
123
+ return { startMs: Math.min(first, bounds.nowMs), endMs: bounds.nowMs };
124
+ }
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Build the graph model for one (period, metric, groupBy) view.
130
+ *
131
+ * Buckets are hourly for short periods and daily for long ones. Group series
132
+ * are capped at MAX_GROUP_SERIES by period total; the rest merge into "other".
133
+ * A Total series is always present (first). Hidden series keep their points
134
+ * but are excluded from the y-axis scale.
135
+ */
136
+ export function buildGraphModel(
137
+ hourly: Map<number, Map<HourlyKey, HourlyCell>>,
138
+ options: GraphOptions
139
+ ): GraphModel {
140
+ const { startMs, endMs } = domainFor(options.period, options.bounds, hourly);
141
+ const spanMs = Math.max(endMs - startMs, 1);
142
+ const bucketMs = spanMs / HOUR_MS <= MAX_HOURLY_BUCKETS ? HOUR_MS : DAY_MS;
143
+
144
+ // Bucket starts aligned to the domain start so "day" buckets follow the
145
+ // local-midnight period boundaries computed by data.ts (DST shifts move a
146
+ // boundary by an hour, which is invisible at graph resolution).
147
+ const bucketCount = Math.max(1, Math.ceil(spanMs / bucketMs));
148
+ const bucketStarts: number[] = [];
149
+ for (let i = 0; i < bucketCount; i++) bucketStarts.push(startMs + i * bucketMs);
150
+
151
+ // Accumulate per-group bucket values.
152
+ const groupValues = new Map<string, number[]>();
153
+ const groupTotals = new Map<string, number>();
154
+ const totalPoints = new Array<number>(bucketCount).fill(0);
155
+ let totalSum = 0;
156
+
157
+ for (const [hour, bucket] of hourly) {
158
+ if (hour < startMs || hour >= endMs) continue;
159
+ const idx = Math.min(bucketCount - 1, Math.floor((hour - startMs) / bucketMs));
160
+ for (const [key, cell] of bucket) {
161
+ const value = metricOf(cell, options.metric);
162
+ if (value === 0) continue;
163
+ totalPoints[idx] += value;
164
+ totalSum += value;
165
+ if (options.groupBy !== "total") {
166
+ const groupKey = groupKeyOf(key, options.groupBy);
167
+ let points = groupValues.get(groupKey);
168
+ if (!points) {
169
+ points = new Array<number>(bucketCount).fill(0);
170
+ groupValues.set(groupKey, points);
171
+ }
172
+ points[idx] += value;
173
+ groupTotals.set(groupKey, (groupTotals.get(groupKey) ?? 0) + value);
174
+ }
175
+ }
176
+ }
177
+
178
+ // Rank groups and cap at MAX_GROUP_SERIES; merge the tail into "other".
179
+ const ranked = Array.from(groupTotals.entries()).sort((a, b) => b[1] - a[1]);
180
+ const kept = ranked.slice(0, MAX_GROUP_SERIES);
181
+ const merged = ranked.slice(MAX_GROUP_SERIES);
182
+
183
+ const hidden = options.hidden ?? new Set<string>();
184
+ const series: GraphSeries[] = [];
185
+
186
+ series.push({
187
+ key: TOTAL_SERIES_KEY,
188
+ label: "Total",
189
+ points: totalPoints.slice(),
190
+ total: totalSum,
191
+ hidden: hidden.has(TOTAL_SERIES_KEY),
192
+ });
193
+
194
+ for (const [groupKey, total] of kept) {
195
+ series.push({
196
+ key: groupKey,
197
+ label: groupKey,
198
+ points: groupValues.get(groupKey)!,
199
+ total,
200
+ hidden: hidden.has(groupKey),
201
+ });
202
+ }
203
+
204
+ if (merged.length > 0) {
205
+ const points = new Array<number>(bucketCount).fill(0);
206
+ let total = 0;
207
+ for (const [groupKey, groupTotal] of merged) {
208
+ const groupPoints = groupValues.get(groupKey)!;
209
+ for (let i = 0; i < bucketCount; i++) points[i] += groupPoints[i]!;
210
+ total += groupTotal;
211
+ }
212
+ series.push({
213
+ key: OTHER_SERIES_KEY,
214
+ label: `other (${merged.length})`,
215
+ points,
216
+ total,
217
+ hidden: hidden.has(OTHER_SERIES_KEY),
218
+ });
219
+ }
220
+
221
+ if (options.cumulative) {
222
+ for (const s of series) {
223
+ let running = 0;
224
+ s.points = s.points.map((v) => (running += v));
225
+ }
226
+ }
227
+
228
+ let yMax = 0;
229
+ for (const s of series) {
230
+ if (s.hidden) continue;
231
+ for (const v of s.points) if (v > yMax) yMax = v;
232
+ }
233
+
234
+ return {
235
+ series,
236
+ bucketStarts,
237
+ bucketMs,
238
+ domainStartMs: startMs,
239
+ domainEndMs: endMs,
240
+ yMax,
241
+ groupedTotal: totalSum,
242
+ };
243
+ }
244
+
245
+ // =============================================================================
246
+ // Braille chart rendering
247
+ // =============================================================================
248
+
249
+ /**
250
+ * Style callback: seriesIndex is the index into model.series, or -1 for
251
+ * chart furniture (axes). Return the text styled for the terminal.
252
+ */
253
+ export type ChartColorize = (seriesIndex: number, text: string) => string;
254
+
255
+ export interface ChartRenderOptions {
256
+ width: number;
257
+ /** Text rows for the plot area (each row is 4 braille dots tall). */
258
+ height: number;
259
+ formatValue: (value: number) => string;
260
+ formatTime: (ms: number) => string;
261
+ colorize?: ChartColorize;
262
+ }
263
+
264
+ const BRAILLE_BASE = 0x2800;
265
+ // Dot bit masks by (x: 0=left, 1=right) and (y: 0=top .. 3=bottom).
266
+ const DOT_BITS = [
267
+ [0x01, 0x02, 0x04, 0x40],
268
+ [0x08, 0x10, 0x20, 0x80],
269
+ ] as const;
270
+
271
+ /**
272
+ * Render the model as a braille line chart with a y-axis and an x-axis line.
273
+ * Returns exactly height + 1 lines (plot rows + x-axis labels).
274
+ */
275
+ export function renderChart(model: GraphModel, options: ChartRenderOptions): string[] {
276
+ const colorize: ChartColorize = options.colorize ?? ((_i, text) => text);
277
+ const plotHeightForLabels = Math.max(options.height, 4);
278
+ const midRowForLabels = Math.floor((plotHeightForLabels - 1) / 2);
279
+ const midValue = (model.yMax * (plotHeightForLabels - 1 - midRowForLabels)) / (plotHeightForLabels - 1);
280
+ const yLabelWidth = Math.max(
281
+ options.formatValue(model.yMax).length,
282
+ options.formatValue(midValue).length,
283
+ options.formatValue(0).length
284
+ );
285
+ const axisWidth = yLabelWidth + 2; // label + " ┤" / " │"
286
+ const plotWidth = Math.max(options.width - axisWidth, 10);
287
+ const plotHeight = Math.max(options.height, 4);
288
+ const dotW = plotWidth * 2;
289
+ const dotH = plotHeight * 4;
290
+
291
+ // masks[row][col] per series index — draw order decides which color wins a cell.
292
+ const cellMasks: number[][] = Array.from({ length: plotHeight }, () => new Array<number>(plotWidth).fill(0));
293
+ const cellOwner: number[][] = Array.from({ length: plotHeight }, () => new Array<number>(plotWidth).fill(-1));
294
+
295
+ const yMax = model.yMax > 0 ? model.yMax : 1;
296
+ const bucketCount = model.bucketStarts.length;
297
+
298
+ const plot = (seriesIndex: number, points: number[]) => {
299
+ let prevX = -1;
300
+ let prevY = -1;
301
+ for (let i = 0; i < bucketCount; i++) {
302
+ const x = bucketCount === 1 ? dotW - 1 : Math.round((i / (bucketCount - 1)) * (dotW - 1));
303
+ const y = Math.round((1 - (points[i]! / yMax)) * (dotH - 1));
304
+ if (prevX >= 0) {
305
+ // Connect with a vertical-stepped segment for continuity.
306
+ const steps = Math.max(Math.abs(x - prevX), Math.abs(y - prevY), 1);
307
+ for (let s = 1; s <= steps; s++) {
308
+ const ix = Math.round(prevX + ((x - prevX) * s) / steps);
309
+ const iy = Math.round(prevY + ((y - prevY) * s) / steps);
310
+ setDot(ix, iy, seriesIndex);
311
+ }
312
+ } else {
313
+ setDot(x, y, seriesIndex);
314
+ }
315
+ prevX = x;
316
+ prevY = y;
317
+ }
318
+ };
319
+
320
+ const setDot = (x: number, y: number, seriesIndex: number) => {
321
+ if (x < 0 || y < 0 || x >= dotW || y >= dotH) return;
322
+ const col = Math.floor(x / 2);
323
+ const row = Math.floor(y / 4);
324
+ cellMasks[row]![col]! |= DOT_BITS[x % 2]![y % 4]!;
325
+ cellOwner[row]![col] = seriesIndex;
326
+ };
327
+
328
+ // Draw least-important first so important series own contested cells:
329
+ // other → smallest groups → largest group → total.
330
+ const drawOrder = model.series
331
+ .map((s, i) => ({ s, i }))
332
+ .filter(({ s }) => !s.hidden)
333
+ .sort((a, b) => {
334
+ const rank = (entry: { s: GraphSeries; i: number }) =>
335
+ entry.s.key === TOTAL_SERIES_KEY ? Number.POSITIVE_INFINITY : entry.s.key === OTHER_SERIES_KEY ? -1 : entry.s.total;
336
+ return rank(a) - rank(b);
337
+ });
338
+ for (const { s, i } of drawOrder) plot(i, s.points);
339
+
340
+ // Compose text rows.
341
+ const lines: string[] = [];
342
+ const midRow = Math.floor((plotHeight - 1) / 2);
343
+ for (let row = 0; row < plotHeight; row++) {
344
+ let label = "";
345
+ if (row === 0) label = options.formatValue(model.yMax);
346
+ else if (row === midRow && plotHeight > 2) label = options.formatValue(midValue);
347
+ else if (row === plotHeight - 1) label = options.formatValue(0);
348
+ const axisChar = label ? "┤" : "│";
349
+ let line = colorize(-1, label.padStart(yLabelWidth) + " " + axisChar);
350
+ // Batch consecutive cells with the same owning series into one colorize
351
+ // call to keep ANSI overhead proportional to color changes, not cells.
352
+ let runOwner = -2;
353
+ let runText = "";
354
+ const flush = () => {
355
+ if (!runText) return;
356
+ line += runOwner === -2 ? runText : colorize(runOwner, runText);
357
+ runText = "";
358
+ };
359
+ for (let col = 0; col < plotWidth; col++) {
360
+ const mask = cellMasks[row]![col]!;
361
+ const owner = mask === 0 ? -2 : cellOwner[row]![col]!;
362
+ if (owner !== runOwner) {
363
+ flush();
364
+ runOwner = owner;
365
+ }
366
+ runText += mask === 0 ? " " : String.fromCharCode(BRAILLE_BASE + mask);
367
+ }
368
+ flush();
369
+ lines.push(line);
370
+ }
371
+
372
+ // X-axis labels: start, optional middle, end.
373
+ const startLabel = options.formatTime(model.domainStartMs);
374
+ const endLabel = options.formatTime(model.domainEndMs);
375
+ const midLabel = plotWidth >= startLabel.length + endLabel.length + 14 ? options.formatTime(model.domainStartMs + (model.domainEndMs - model.domainStartMs) / 2) : "";
376
+ let axis = " ".repeat(yLabelWidth + 2) + startLabel;
377
+ if (midLabel) {
378
+ const midPos = yLabelWidth + 2 + Math.floor(plotWidth / 2 - midLabel.length / 2);
379
+ axis = axis.padEnd(midPos) + midLabel;
380
+ }
381
+ const endPos = yLabelWidth + 2 + plotWidth - endLabel.length;
382
+ axis = axis.padEnd(Math.max(endPos, axis.length + 1)) + endLabel;
383
+ lines.push(colorize(-1, axis));
384
+
385
+ return lines;
386
+ }
package/index.ts CHANGED
@@ -15,8 +15,20 @@ import { CancellableLoader, Container, Spacer, matchesKey, visibleWidth, truncat
15
15
 
16
16
  import { collectUsageData, TAB_ORDER } from "./data";
17
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";
18
28
 
19
- type ViewMode = "table" | "insights";
29
+ type ViewMode = "table" | "insights" | "graph";
30
+
31
+ const VIEW_CYCLE: ViewMode[] = ["table", "insights", "graph"];
20
32
 
21
33
  // =============================================================================
22
34
  // Column Configuration
@@ -137,6 +149,32 @@ function formatNumber(n: number): string {
137
149
  return n.toLocaleString();
138
150
  }
139
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
+
140
178
  function formatInsightPercent(p: number): string {
141
179
  if (p >= 10) return `${Math.round(p)}%`;
142
180
  return `${Math.round(p * 10) / 10}%`;
@@ -225,6 +263,13 @@ class UsageComponent {
225
263
  private requestRender: () => void;
226
264
  private done: () => void;
227
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
+
228
273
  constructor(theme: Theme, data: UsageData, requestRender: () => void, done: () => void) {
229
274
  this.theme = theme;
230
275
  this.requestRender = requestRender;
@@ -248,11 +293,16 @@ class UsageComponent {
248
293
  }
249
294
 
250
295
  if (matchesKey(data, "v")) {
251
- 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]!;
252
298
  this.requestRender();
253
299
  return;
254
300
  }
255
301
 
302
+ if (this.viewMode === "graph" && this.handleGraphInput(data)) {
303
+ return;
304
+ }
305
+
256
306
  if (matchesKey(data, "tab") || matchesKey(data, "right")) {
257
307
  const idx = TAB_ORDER.indexOf(this.activeTab);
258
308
  this.activeTab = TAB_ORDER[(idx + 1) % TAB_ORDER.length]!;
@@ -263,6 +313,8 @@ class UsageComponent {
263
313
  this.activeTab = TAB_ORDER[(idx - 1 + TAB_ORDER.length) % TAB_ORDER.length]!;
264
314
  this.updateProviderOrder();
265
315
  this.requestRender();
316
+ } else if (this.viewMode === "graph") {
317
+ // Graph-specific keys were handled above; swallow table-only keys.
266
318
  } else if (this.viewMode === "table" && matchesKey(data, "up")) {
267
319
  if (this.selectedIndex > 0) {
268
320
  this.selectedIndex--;
@@ -290,7 +342,57 @@ class UsageComponent {
290
342
  // Render Methods
291
343
  // -------------------------------------------------------------------------
292
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
+
293
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
+
294
396
  if (this.viewMode === "insights") {
295
397
  return clampLines(
296
398
  [
@@ -320,10 +422,67 @@ class UsageComponent {
320
422
 
321
423
  private renderTitle(): string[] {
322
424
  const th = this.theme;
323
- 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";
324
427
  return [th.fg("accent", th.bold(label)), ""];
325
428
  }
326
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
+
327
486
  private renderInsights(width: number): string[] {
328
487
  const th = this.theme;
329
488
  const stats = this.data[this.activeTab];
@@ -494,11 +653,19 @@ class UsageComponent {
494
653
 
495
654
  private renderHelp(width: number): string[] {
496
655
  const variants =
497
- 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"
498
665
  ? [
499
- "[Tab/←→] period [v] table view [q] close",
500
- "[Tab] period [v] table [q] close",
501
- "[v] table [q] close",
666
+ "[Tab/←→] period [v] graph view [q] close",
667
+ "[Tab] period [v] graphs [q] close",
668
+ "[v] graphs [q] close",
502
669
  "[q] close",
503
670
  ]
504
671
  : [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmustier/pi-usage-extension",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Usage statistics dashboard for Pi sessions.",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Mustier",