@tmustier/pi-usage-extension 0.8.1 → 0.9.1

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,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.1] - 2026-07-17
4
+
5
+ ### Changed
6
+ - Exports now save to `/tmp` (or the OS temp dir where `/tmp` doesn't exist) instead of the pi session's cwd, so they never litter a repo or home directory. The `✓ Saved` note shows the full path.
7
+ - The export directory is configurable: `{ "usage-extension": { "exportDir": "~/Downloads" } }` in `~/.pi/agent/settings.json` (`~` expands; the directory is created if missing).
8
+
9
+ ## [0.9.0] - 2026-07-17
10
+
11
+ ### Added
12
+ - **Export (`e`) in every view** (#67): table → per-model CSV with TOTAL row; graphs → CSV of exactly the plotted slice (visible series, current metric/grouping/cumulative, ISO buckets); insights → structured JSON. Files land in the cwd with self-describing names; a ✓/✗ note confirms the write.
13
+ - **Table cut/filter** (#68): `/` live type-to-filter over provider and model names — when only models match, the provider row is recomputed from just those models; `x` hides the selected row; `a` resets. The Total row recomputes over the visible slice, with an explicit status line, and table exports honor the slice.
14
+
15
+ ### Changed
16
+ - **Graphs is now the default view** (#66); `v` cycles Graphs → Table → Insights, and the title renders a view tab strip so switching is discoverable.
17
+ - README refreshed with current screenshots of all three views (#69) and documentation for export and table filtering.
18
+
19
+ ### Fixed
20
+ - Table selection could desync from rendered rows (the unsliced provider map leaked aggregation insertion order instead of cost-sorted order).
21
+
3
22
  ## [0.8.1] - 2026-07-17
4
23
 
5
24
  ### Changed
package/README.md CHANGED
@@ -2,12 +2,12 @@
2
2
 
3
3
  A Pi extension that displays aggregated usage statistics across all sessions.
4
4
 
5
- ![Default table view of /usage](screenshot.png)
5
+ ![Default graphs view of /usage](graphs-screenshot.png)
6
6
 
7
7
  ## Compatibility
8
8
 
9
9
  - **Pi version:** 0.42.4+
10
- - **Last updated:** 2026-07-17 (0.6.1)
10
+ - **Last updated:** 2026-07-17 (0.9.1)
11
11
 
12
12
  ## Installation
13
13
 
@@ -57,11 +57,21 @@ In Pi, run:
57
57
 
58
58
  ### Views
59
59
 
60
- `/usage` has three view modes, cycled with `v`:
60
+ `/usage` has three view modes, shown as a tab strip in the title and cycled with `v`:
61
61
 
62
- - **Table** (default) — per-provider / per-model stats with cost and token breakdown (screenshot at the top of this page).
62
+ - **Graphs** (default) — an interactive braille line-chart explorer for usage over time (screenshot at the top of this page, details below).
63
+ - **Table** — per-provider / per-model stats with cost and token breakdown, with keyboard filtering (details below).
63
64
  - **Insights** — data-driven characteristics of your cost for the active time period (details below). Insights are **independent lenses**, not a breakdown, so they overlap and don't sum to 100%.
64
- - **Graphs** — an interactive braille line-chart explorer for usage over time (details below).
65
+
66
+ Every view can export its current slice with `e` — see [Export](#export).
67
+
68
+ ![Table view of /usage](screenshot.png)
69
+
70
+ ### Filtering the table
71
+
72
+ - `/` opens a live type-to-filter over provider **and** model names (case-insensitive substring). When only models match, the provider row is recomputed from just the matching models — filtering `sol` shows openai-codex as exactly its `gpt-5.6-sol` numbers. `Enter` keeps the filter, `Esc` clears it.
73
+ - `x` hides the selected provider row; `a` resets all hides and the filter.
74
+ - The **Total row recomputes over the visible slice**, and a status line makes the cut explicit so a filtered table can't be mistaken for the full period.
65
75
 
66
76
  ![Insights view of /usage](insights-screenshot.png)
67
77
 
@@ -95,6 +105,26 @@ pi's built-in test providers (`faux-provider`, `fake-provider`) never call a rea
95
105
 
96
106
  A note on reading them: cache-miss and context numbers are **observed cost, not promised savings** — big-context messages often do bigger work. Treat the alarms as "look here", not as an invoice for waste.
97
107
 
108
+ ### Export
109
+
110
+ Press `e` in any view to write the **current slice** to `/tmp` (or the OS temp dir where `/tmp` doesn't exist) — exports never litter your repo or home directory, and the `✓ Saved` note shows the full path so you can grab the file if you want to keep it:
111
+
112
+ | View | File | Contents |
113
+ |---|---|---|
114
+ | Table | `usage-table-<period>[-filtered]-<stamp>.csv` | per-model rows + TOTAL, full precision; honors the active filter/hides |
115
+ | Graphs | `usage-graph-<period>-<slice>-<stamp>.csv` | exactly what's plotted: visible series only, current metric/grouping/cumulative, ISO bucket starts |
116
+ | Insights | `usage-insights-<period>-<stamp>.json` | period, totals, and the structured insight list |
117
+
118
+ A `✓ Saved …` note confirms the write (or reports the error) above the help line.
119
+
120
+ To export somewhere permanent instead, set an export directory in `~/.pi/agent/settings.json` (created if missing, `~` expands):
121
+
122
+ ```json
123
+ {
124
+ "usage-extension": { "exportDir": "~/Downloads" }
125
+ }
126
+ ```
127
+
98
128
  ### Graph explorer
99
129
 
100
130
  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.
@@ -148,11 +178,14 @@ On narrow terminals, `/usage` automatically switches to a compact table instead
148
178
  | `Tab` / `←` `→` | Switch time period |
149
179
  | `↑` `↓` | Select provider *(table)* / move legend cursor *(graphs)* |
150
180
  | `Enter` / `Space` | Expand/collapse provider *(table)* / toggle series visibility *(graphs)* |
151
- | `v` | Cycle Table → Insights → Graphs view |
181
+ | `v` | Cycle Graphs → Table → Insights view |
182
+ | `e` | Export the current slice to CSV/JSON |
183
+ | `/` | Type-to-filter providers and models *(table)* |
184
+ | `x` | Hide the selected provider row *(table)* |
152
185
  | `m` | Cycle metric: cost / tokens / messages / reasoning *(graphs)* |
153
186
  | `g` | Cycle grouping: provider / model / thinking level / total *(graphs)* |
154
187
  | `c` | Toggle cumulative vs per-bucket *(graphs)* |
155
- | `a` | Show all series *(graphs)* |
188
+ | `a` | Show all series *(graphs)* / reset filter and hides *(table)* |
156
189
  | `q` / `Esc` | Close |
157
190
 
158
191
  ## Performance & Caching
package/data.ts CHANGED
@@ -198,7 +198,7 @@ export interface ParsedSessionFile {
198
198
  // Paths
199
199
  // =============================================================================
200
200
 
201
- function getAgentDir(): string {
201
+ export function getAgentDir(): string {
202
202
  // Replicate Pi's logic: respect PI_CODING_AGENT_DIR env var
203
203
  return process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
204
204
  }
package/export.ts ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Pure builders for /usage export ([e] key).
3
+ *
4
+ * Each view exports its current slice: the table as per-model CSV rows, the
5
+ * graph as one CSV column per visible series, and insights as structured JSON.
6
+ * Builders are pure string producers so they stay trivially testable; the
7
+ * component owns file naming and disk writes.
8
+ */
9
+
10
+ import { join } from "node:path";
11
+ import type { Insight, ProviderStats, TotalStats } from "./data.ts";
12
+ import type { GraphModel } from "./graph.ts";
13
+
14
+ /**
15
+ * Read the configured export directory from settings.json content, if any.
16
+ * Config shape: `{ "usage-extension": { "exportDir": "~/Downloads" } }`.
17
+ */
18
+ export function parseExportDirSetting(settingsJson: string): string | null {
19
+ try {
20
+ const parsed = JSON.parse(settingsJson) as { "usage-extension"?: { exportDir?: unknown } };
21
+ const dir = parsed["usage-extension"]?.exportDir;
22
+ return typeof dir === "string" && dir.trim() !== "" ? dir.trim() : null;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Pick the export directory. A configured dir wins (with `~` expanded);
30
+ * otherwise exports go to /tmp so they never litter a repo or home
31
+ * directory, falling back to the OS temp dir where /tmp doesn't exist.
32
+ */
33
+ export function resolveExportDir(
34
+ configured: string | null,
35
+ home: string,
36
+ slashTmpExists: boolean,
37
+ fallbackTmp: string,
38
+ ): string {
39
+ if (configured !== null) {
40
+ if (configured === "~") return home;
41
+ if (configured.startsWith("~/")) return join(home, configured.slice(2));
42
+ return configured;
43
+ }
44
+ return slashTmpExists ? "/tmp" : fallbackTmp;
45
+ }
46
+
47
+ /** Quote a CSV field only when it needs it (comma, quote, or newline). */
48
+ function csvField(value: string | number): string {
49
+ const s = String(value);
50
+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
51
+ }
52
+
53
+ function csvLine(fields: (string | number)[]): string {
54
+ return fields.map(csvField).join(",");
55
+ }
56
+
57
+ /** Per-model rows (provider repeated), then a TOTAL row. */
58
+ export function buildTableCsv(providers: ReadonlyMap<string, ProviderStats>, totals: TotalStats): string {
59
+ const lines = [
60
+ csvLine([
61
+ "provider",
62
+ "model",
63
+ "sessions",
64
+ "messages",
65
+ "cost_usd",
66
+ "fresh_tokens",
67
+ "input_tokens",
68
+ "output_tokens",
69
+ "cache_read_tokens",
70
+ "cache_write_tokens",
71
+ ]),
72
+ ];
73
+ const sorted = Array.from(providers.entries()).sort((a, b) => b[1].cost - a[1].cost);
74
+ for (const [providerName, provider] of sorted) {
75
+ const models = Array.from(provider.models.entries()).sort((a, b) => b[1].cost - a[1].cost);
76
+ for (const [modelName, model] of models) {
77
+ lines.push(
78
+ csvLine([
79
+ providerName,
80
+ modelName,
81
+ model.sessions.size,
82
+ model.messages,
83
+ model.cost,
84
+ model.tokens.total,
85
+ model.tokens.input,
86
+ model.tokens.output,
87
+ model.tokens.cacheRead,
88
+ model.tokens.cacheWrite,
89
+ ])
90
+ );
91
+ }
92
+ }
93
+ lines.push(
94
+ csvLine([
95
+ "TOTAL",
96
+ "",
97
+ totals.sessions,
98
+ totals.messages,
99
+ totals.cost,
100
+ totals.tokens.total,
101
+ totals.tokens.input,
102
+ totals.tokens.output,
103
+ totals.tokens.cacheRead,
104
+ totals.tokens.cacheWrite,
105
+ ])
106
+ );
107
+ return lines.join("\n") + "\n";
108
+ }
109
+
110
+ /**
111
+ * One row per time bucket, one column per visible series, values exactly as
112
+ * plotted (per-bucket or cumulative, current metric).
113
+ */
114
+ export function buildGraphCsv(model: GraphModel): string {
115
+ const visible = model.series.filter((s) => !s.hidden);
116
+ const lines = [csvLine(["bucket_start", ...visible.map((s) => s.label)])];
117
+ for (let i = 0; i < model.bucketStarts.length; i++) {
118
+ lines.push(csvLine([new Date(model.bucketStarts[i]!).toISOString(), ...visible.map((s) => s.points[i] ?? 0)]));
119
+ }
120
+ return lines.join("\n") + "\n";
121
+ }
122
+
123
+ /** Structured JSON of the period's insights plus headline totals. */
124
+ export function buildInsightsJson(period: string, totals: TotalStats, insights: Insight[]): string {
125
+ return (
126
+ JSON.stringify(
127
+ {
128
+ period,
129
+ generatedAt: new Date().toISOString(),
130
+ totals: { costUsd: totals.cost, messages: totals.messages, sessions: totals.sessions },
131
+ insights: insights.map((i) => ({ kind: i.kind, stat: i.stat, headline: i.headline, advice: i.advice })),
132
+ },
133
+ null,
134
+ "\t"
135
+ ) + "\n"
136
+ );
137
+ }
138
+
139
+ /** usage-<view>-<period>[-<slice>]-<stamp>.<ext> in the current directory. */
140
+ export function exportFileName(view: string, period: string, slice: string | null, ext: string, now: Date): string {
141
+ const pad = (n: number) => String(n).padStart(2, "0");
142
+ const stamp =
143
+ `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
144
+ `-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
145
+ return ["usage", view, period, ...(slice ? [slice] : []), stamp].join("-") + `.${ext}`;
146
+ }
Binary file
package/index.ts CHANGED
@@ -13,9 +13,9 @@ import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-wor
13
13
  import { DynamicBorder } from "@earendil-works/pi-coding-agent";
14
14
  import { CancellableLoader, Container, Spacer, matchesKey, visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
15
15
 
16
- import { collectUsageData, TAB_ORDER } from "./data";
16
+ import { collectUsageData, getAgentDir, TAB_ORDER } from "./data";
17
17
  import type { CollectProgress } from "./data";
18
- import type { BaseStats, TabName, UsageData } from "./data";
18
+ import type { BaseStats, ProviderStats, TabName, TotalStats, UsageData } from "./data";
19
19
  import {
20
20
  buildGraphModel,
21
21
  renderChart,
@@ -26,10 +26,27 @@ import {
26
26
  TOTAL_SERIES_KEY,
27
27
  } from "./graph";
28
28
  import type { GraphGroupBy, GraphMetric, GraphModel } from "./graph";
29
+ import {
30
+ buildGraphCsv,
31
+ buildInsightsJson,
32
+ buildTableCsv,
33
+ exportFileName,
34
+ parseExportDirSetting,
35
+ resolveExportDir,
36
+ } from "./export";
37
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
38
+ import { homedir, tmpdir } from "node:os";
39
+ import { join } from "node:path";
29
40
 
30
41
  type ViewMode = "table" | "insights" | "graph";
31
42
 
32
- const VIEW_CYCLE: ViewMode[] = ["table", "insights", "graph"];
43
+ const VIEW_CYCLE: ViewMode[] = ["graph", "table", "insights"];
44
+
45
+ const VIEW_LABELS: Record<ViewMode, string> = {
46
+ graph: "Graphs",
47
+ table: "Table",
48
+ insights: "Insights",
49
+ };
33
50
 
34
51
  // =============================================================================
35
52
  // Column Configuration
@@ -262,7 +279,7 @@ const TAB_LABELS: Record<TabName, string> = {
262
279
 
263
280
  class UsageComponent {
264
281
  private activeTab: TabName = "allTime";
265
- private viewMode: ViewMode = "table";
282
+ private viewMode: ViewMode = "graph";
266
283
  private data: UsageData;
267
284
  private selectedIndex = 0;
268
285
  private expanded = new Set<string>();
@@ -275,6 +292,10 @@ class UsageComponent {
275
292
  private graphMetric: GraphMetric = "cost";
276
293
  private graphGroupBy: GraphGroupBy = "provider";
277
294
  private graphCumulative = true;
295
+ private exportNote: { text: string; ok: boolean } | null = null;
296
+ private tableHidden = new Set<string>();
297
+ private tableFilter = "";
298
+ private tableFilterEditing = false;
278
299
  private graphHidden = new Set<string>();
279
300
  private graphLegendIndex = 0;
280
301
 
@@ -291,10 +312,94 @@ class UsageComponent {
291
312
  this.providerOrder = Array.from(stats.providers.entries())
292
313
  .sort((a, b) => b[1].cost - a[1].cost)
293
314
  .map(([name]) => name);
294
- this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.providerOrder.length - 1));
315
+ this.clampTableSelection();
316
+ }
317
+
318
+ private clampTableSelection(): void {
319
+ this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.visibleTable().providers.size - 1));
320
+ }
321
+
322
+ /**
323
+ * The table slice after hides and the text filter. A filter matches a
324
+ * provider name (whole provider stays) or individual model names, in which
325
+ * case the provider row is synthesized from just the matching models so
326
+ * the totals row and exports reflect exactly what is on screen.
327
+ */
328
+ private visibleTable(): { providers: Map<string, ProviderStats>; totals: TotalStats } {
329
+ const stats = this.data[this.activeTab];
330
+ const q = this.tableFilter.trim().toLowerCase();
331
+ // Always iterate providerOrder so the map is cost-sorted — selection
332
+ // indexes and rendered rows must agree on ordering.
333
+ const providers = new Map<string, ProviderStats>();
334
+ for (const name of this.providerOrder) {
335
+ if (this.tableHidden.has(name)) continue;
336
+ const full = stats.providers.get(name)!;
337
+ if (!q || name.toLowerCase().includes(q)) {
338
+ providers.set(name, full);
339
+ continue;
340
+ }
341
+ const models = new Map(Array.from(full.models).filter(([model]) => model.toLowerCase().includes(q)));
342
+ if (models.size === 0) continue;
343
+ const synth: ProviderStats = {
344
+ messages: 0,
345
+ cost: 0,
346
+ tokens: { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
347
+ sessions: new Set<string>(),
348
+ models,
349
+ };
350
+ for (const model of models.values()) {
351
+ synth.messages += model.messages;
352
+ synth.cost += model.cost;
353
+ synth.tokens.total += model.tokens.total;
354
+ synth.tokens.input += model.tokens.input;
355
+ synth.tokens.output += model.tokens.output;
356
+ synth.tokens.cacheRead += model.tokens.cacheRead;
357
+ synth.tokens.cacheWrite += model.tokens.cacheWrite;
358
+ for (const s of model.sessions) synth.sessions.add(s);
359
+ }
360
+ providers.set(name, synth);
361
+ }
362
+ if (!q && this.tableHidden.size === 0) return { providers, totals: stats.totals };
363
+
364
+ const totals: TotalStats = {
365
+ sessions: 0,
366
+ messages: 0,
367
+ cost: 0,
368
+ tokens: { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
369
+ };
370
+ const sessions = new Set<string>();
371
+ for (const provider of providers.values()) {
372
+ totals.messages += provider.messages;
373
+ totals.cost += provider.cost;
374
+ totals.tokens.total += provider.tokens.total;
375
+ totals.tokens.input += provider.tokens.input;
376
+ totals.tokens.output += provider.tokens.output;
377
+ totals.tokens.cacheRead += provider.tokens.cacheRead;
378
+ totals.tokens.cacheWrite += provider.tokens.cacheWrite;
379
+ for (const s of provider.sessions) sessions.add(s);
380
+ }
381
+ totals.sessions = sessions.size;
382
+ return { providers, totals };
295
383
  }
296
384
 
297
385
  handleInput(data: string): void {
386
+ // Filter typing captures printable keys, so it runs before everything.
387
+ if (this.viewMode === "table" && this.tableFilterEditing) {
388
+ if (matchesKey(data, "escape")) {
389
+ this.tableFilter = "";
390
+ this.tableFilterEditing = false;
391
+ } else if (matchesKey(data, "enter")) {
392
+ this.tableFilterEditing = false;
393
+ } else if (matchesKey(data, "backspace")) {
394
+ this.tableFilter = this.tableFilter.slice(0, -1);
395
+ } else if (data.length === 1 && data >= " " && data !== "\x7f") {
396
+ this.tableFilter += data;
397
+ }
398
+ this.clampTableSelection();
399
+ this.requestRender();
400
+ return;
401
+ }
402
+
298
403
  if (matchesKey(data, "escape") || matchesKey(data, "q")) {
299
404
  this.done();
300
405
  return;
@@ -303,6 +408,13 @@ class UsageComponent {
303
408
  if (matchesKey(data, "v")) {
304
409
  const idx = VIEW_CYCLE.indexOf(this.viewMode);
305
410
  this.viewMode = VIEW_CYCLE[(idx + 1) % VIEW_CYCLE.length]!;
411
+ this.exportNote = null;
412
+ this.requestRender();
413
+ return;
414
+ }
415
+
416
+ if (matchesKey(data, "e")) {
417
+ this.exportCurrentView();
306
418
  this.requestRender();
307
419
  return;
308
420
  }
@@ -315,26 +427,45 @@ class UsageComponent {
315
427
  const idx = TAB_ORDER.indexOf(this.activeTab);
316
428
  this.activeTab = TAB_ORDER[(idx + 1) % TAB_ORDER.length]!;
317
429
  this.updateProviderOrder();
430
+ this.exportNote = null;
318
431
  this.requestRender();
319
432
  } else if (matchesKey(data, "shift+tab") || matchesKey(data, "left")) {
320
433
  const idx = TAB_ORDER.indexOf(this.activeTab);
321
434
  this.activeTab = TAB_ORDER[(idx - 1 + TAB_ORDER.length) % TAB_ORDER.length]!;
322
435
  this.updateProviderOrder();
436
+ this.exportNote = null;
323
437
  this.requestRender();
324
438
  } else if (this.viewMode === "graph") {
325
439
  // Graph-specific keys were handled above; swallow table-only keys.
440
+ } else if (this.viewMode === "table" && data === "/") {
441
+ this.tableFilterEditing = true;
442
+ this.requestRender();
443
+ } else if (this.viewMode === "table" && data === "x") {
444
+ const visible = Array.from(this.visibleTable().providers.keys());
445
+ const provider = visible[this.selectedIndex];
446
+ if (provider) {
447
+ this.tableHidden.add(provider);
448
+ this.clampTableSelection();
449
+ this.requestRender();
450
+ }
451
+ } else if (this.viewMode === "table" && data === "a") {
452
+ this.tableHidden.clear();
453
+ this.tableFilter = "";
454
+ this.tableFilterEditing = false;
455
+ this.clampTableSelection();
456
+ this.requestRender();
326
457
  } else if (this.viewMode === "table" && matchesKey(data, "up")) {
327
458
  if (this.selectedIndex > 0) {
328
459
  this.selectedIndex--;
329
460
  this.requestRender();
330
461
  }
331
462
  } else if (this.viewMode === "table" && matchesKey(data, "down")) {
332
- if (this.selectedIndex < this.providerOrder.length - 1) {
463
+ if (this.selectedIndex < this.visibleTable().providers.size - 1) {
333
464
  this.selectedIndex++;
334
465
  this.requestRender();
335
466
  }
336
467
  } else if (this.viewMode === "table" && (matchesKey(data, "enter") || matchesKey(data, "space"))) {
337
- const provider = this.providerOrder[this.selectedIndex];
468
+ const provider = Array.from(this.visibleTable().providers.keys())[this.selectedIndex];
338
469
  if (provider) {
339
470
  if (this.expanded.has(provider)) {
340
471
  this.expanded.delete(provider);
@@ -382,6 +513,43 @@ class UsageComponent {
382
513
  return true;
383
514
  }
384
515
 
516
+ private exportCurrentView(): void {
517
+ const now = new Date();
518
+ let name: string;
519
+ let content: string;
520
+ const stats = this.data[this.activeTab];
521
+ if (this.viewMode === "graph") {
522
+ const slice = `${this.graphCumulative ? "cumulative" : "per-bucket"}-${this.graphMetric}-by-${this.graphGroupBy}`;
523
+ name = exportFileName("graph", this.activeTab, slice, "csv", now);
524
+ content = buildGraphCsv(this.buildGraphModelForView());
525
+ } else if (this.viewMode === "insights") {
526
+ name = exportFileName("insights", this.activeTab, null, "json", now);
527
+ content = buildInsightsJson(this.activeTab, stats.totals, stats.insights.insights);
528
+ } else {
529
+ const visible = this.visibleTable();
530
+ const sliced = this.tableFilter.trim() !== "" || this.tableHidden.size > 0;
531
+ name = exportFileName("table", this.activeTab, sliced ? "filtered" : null, "csv", now);
532
+ content = buildTableCsv(visible.providers, visible.totals);
533
+ }
534
+ try {
535
+ let configured: string | null = null;
536
+ try {
537
+ configured = parseExportDirSetting(readFileSync(join(getAgentDir(), "settings.json"), "utf8"));
538
+ } catch {
539
+ // No settings file or unreadable: fall through to the default dir.
540
+ }
541
+ const home = homedir();
542
+ const dir = resolveExportDir(configured, home, existsSync("/tmp"), tmpdir());
543
+ mkdirSync(dir, { recursive: true });
544
+ const path = join(dir, name);
545
+ writeFileSync(path, content);
546
+ const shown = path.startsWith(home + "/") ? "~" + path.slice(home.length) : path;
547
+ this.exportNote = { text: `Saved ${shown}`, ok: true };
548
+ } catch (err) {
549
+ this.exportNote = { text: `Export failed: ${err instanceof Error ? err.message : String(err)}`, ok: false };
550
+ }
551
+ }
552
+
385
553
  private buildGraphModelForView(): GraphModel {
386
554
  return buildGraphModel(this.data.hourly, {
387
555
  period: this.activeTab,
@@ -396,7 +564,7 @@ class UsageComponent {
396
564
  render(width: number): string[] {
397
565
  if (this.viewMode === "graph") {
398
566
  return clampLines(
399
- [...this.renderTitle(), ...this.renderTabs(width, getTableLayout(width)), ...this.renderGraph(width), ...this.renderHelp(width)],
567
+ [...this.renderTitle(width), ...this.renderTabs(width, getTableLayout(width)), ...this.renderGraph(width), ...this.renderHelp(width)],
400
568
  width
401
569
  );
402
570
  }
@@ -404,7 +572,7 @@ class UsageComponent {
404
572
  if (this.viewMode === "insights") {
405
573
  return clampLines(
406
574
  [
407
- ...this.renderTitle(),
575
+ ...this.renderTitle(width),
408
576
  ...this.renderTabs(width, getTableLayout(width)),
409
577
  ...this.renderInsights(width),
410
578
  ...this.renderHelp(width),
@@ -416,7 +584,7 @@ class UsageComponent {
416
584
  const layout = getTableLayout(width);
417
585
  return clampLines(
418
586
  [
419
- ...this.renderTitle(),
587
+ ...this.renderTitle(width),
420
588
  ...this.renderTabs(width, layout),
421
589
  ...this.renderHeader(layout),
422
590
  ...this.renderRows(layout),
@@ -428,11 +596,21 @@ class UsageComponent {
428
596
  );
429
597
  }
430
598
 
431
- private renderTitle(): string[] {
599
+ private renderTitle(width: number): string[] {
432
600
  const th = this.theme;
433
- const label =
434
- this.viewMode === "insights" ? "Usage Insights" : this.viewMode === "graph" ? "Usage Graphs" : "Usage Statistics";
435
- return [th.fg("accent", th.bold(label)), ""];
601
+ const title = th.fg("accent", th.bold("Usage"));
602
+ // Render the views as a tab strip (like the period tabs) so it is
603
+ // obvious there are multiple views and [v] switches between them.
604
+ const fullStrip = VIEW_CYCLE.map((view) =>
605
+ view === this.viewMode ? th.fg("accent", `[${VIEW_LABELS[view]}]`) : th.fg("dim", ` ${VIEW_LABELS[view]} `)
606
+ ).join(" ");
607
+ const activeOnly = th.fg("accent", `[${VIEW_LABELS[this.viewMode]}]`);
608
+ const line = pickFittingText(width, [
609
+ `${title} ${fullStrip} ${th.fg("dim", "[v]")}`,
610
+ `${title} ${activeOnly} ${th.fg("dim", "[v]")}`,
611
+ `${title} ${activeOnly}`,
612
+ ]);
613
+ return [line, ""];
436
614
  }
437
615
 
438
616
  private renderGraph(width: number): string[] {
@@ -589,6 +767,17 @@ class UsageComponent {
589
767
  ? wrapTextWithAnsi(th.fg("dim", "Compact view. Widen the terminal for more columns."), Math.max(width, 1))
590
768
  : [];
591
769
 
770
+ if (this.viewMode === "table") {
771
+ if (this.tableFilterEditing) {
772
+ infoLines.push(`${th.fg("accent", `/ ${this.tableFilter}▌`)} ${th.fg("dim", "[Enter] keep · [Esc] clear")}`);
773
+ } else if (this.tableFilter.trim() !== "" || this.tableHidden.size > 0) {
774
+ const parts: string[] = [];
775
+ if (this.tableFilter.trim() !== "") parts.push(`filter: “${this.tableFilter.trim()}”`);
776
+ if (this.tableHidden.size > 0) parts.push(`${this.tableHidden.size} hidden`);
777
+ infoLines.push(th.fg("warning", `${parts.join(" · ")} · totals reflect this slice · [a] reset`));
778
+ }
779
+ }
780
+
592
781
  return [tabLine, ...infoLines, ""];
593
782
  }
594
783
 
@@ -633,7 +822,6 @@ class UsageComponent {
633
822
 
634
823
  private renderRows(layout: TableLayout): string[] {
635
824
  const th = this.theme;
636
- const stats = this.data[this.activeTab];
637
825
  const lines: string[] = [];
638
826
 
639
827
  if (this.providerOrder.length === 0) {
@@ -641,9 +829,14 @@ class UsageComponent {
641
829
  return lines;
642
830
  }
643
831
 
644
- for (let i = 0; i < this.providerOrder.length; i++) {
645
- const providerName = this.providerOrder[i]!;
646
- const providerStats = stats.providers.get(providerName)!;
832
+ const visible = Array.from(this.visibleTable().providers.entries());
833
+ if (visible.length === 0) {
834
+ lines.push(th.fg("dim", " Nothing matches the current filter — [a] resets"));
835
+ return lines;
836
+ }
837
+
838
+ for (let i = 0; i < visible.length; i++) {
839
+ const [providerName, providerStats] = visible[i]!;
647
840
  const isSelected = i === this.selectedIndex;
648
841
  const isExpanded = this.expanded.has(providerName);
649
842
  const arrow = isExpanded ? "▾" : "▸";
@@ -670,11 +863,11 @@ class UsageComponent {
670
863
 
671
864
  private renderTotals(layout: TableLayout): string[] {
672
865
  const th = this.theme;
673
- const stats = this.data[this.activeTab];
866
+ const { totals } = this.visibleTable();
674
867
 
675
868
  let totalRow = fitCell(th.bold("Total"), layout.nameWidth);
676
869
  for (const col of layout.columns) {
677
- const value = fitCell(col.getValue(stats.totals), col.width, "right");
870
+ const value = fitCell(col.getValue(totals), col.width, "right");
678
871
  totalRow += col.dimmed ? th.fg("dim", value) : value;
679
872
  }
680
873
 
@@ -692,32 +885,35 @@ class UsageComponent {
692
885
  }
693
886
 
694
887
  private renderHelp(width: number): string[] {
888
+ const noteLines = this.exportNote
889
+ ? [this.theme.fg(this.exportNote.ok ? "success" : "error", `${this.exportNote.ok ? "✓" : "✗"} ${this.exportNote.text}`), ""]
890
+ : [];
695
891
  const variants =
696
892
  this.viewMode === "graph"
697
893
  ? [
698
- "[Tab/←→] period [m] metric [g] group [c] cumulative [↑↓/Enter] filter [a] all [v] view [q] close",
699
- "[Tab] period [m] metric [g] group [c] cumul [↑↓/Enter] filter [v] view [q] close",
894
+ "[Tab/←→] period [m] metric [g] group [c] cumulative [↑↓/Enter] filter [a] all [e] export [v] view [q] close",
895
+ "[Tab] period [m] metric [g] group [c] cumul [↑↓/Enter] filter [e] export [v] view [q] close",
700
896
  "[m] metric [g] group [c] cumul [↑↓] filter [q] close",
701
897
  "[m] [g] [c] [↑↓] [q]",
702
898
  "[q] close",
703
899
  ]
704
900
  : this.viewMode === "insights"
705
901
  ? [
706
- "[Tab/←→] period [v] graph view [q] close",
707
- "[Tab] period [v] graphs [q] close",
708
- "[v] graphs [q] close",
902
+ "[Tab/←→] period [e] export [v] view [q] close",
903
+ "[Tab] period [e] export [v] view [q] close",
904
+ "[v] view [q] close",
709
905
  "[q] close",
710
906
  ]
711
907
  : [
712
- "[Tab/←→] period [↑↓] select [Enter] expand [v] insights [q] close",
713
- "[Tab] period [↑↓] select [Enter] expand [v] insights [q] close",
714
- "[↑↓] select [Enter] expand [v] insights [q] close",
715
- "[↑↓] select [v] insights [q] close",
908
+ "[Tab/←→] period [↑↓] select [Enter] expand [/] filter [x] hide [a] all [e] export [v] view [q] close",
909
+ "[Tab] period [↑↓] select [Enter] expand [/] filter [x] hide [e] export [v] view [q] close",
910
+ "[↑↓] select [Enter] expand [/] filter [x] hide [v] view [q] close",
911
+ "[↑↓] select [/] [x] [v] [q]",
716
912
  "[↑↓] select [q] close",
717
913
  "[q] close",
718
914
  ];
719
915
  const line = pickFittingText(width, variants);
720
- return [this.theme.fg("dim", line)];
916
+ return [...noteLines, this.theme.fg("dim", line)];
721
917
  }
722
918
 
723
919
  invalidate(): void {}
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmustier/pi-usage-extension",
3
- "version": "0.8.1",
3
+ "version": "0.9.1",
4
4
  "description": "Usage statistics dashboard for Pi sessions.",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Mustier",
package/screenshot.png CHANGED
Binary file