@tmustier/pi-usage-extension 0.8.1 → 0.9.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.9.0] - 2026-07-17
4
+
5
+ ### Added
6
+ - **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.
7
+ - **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.
8
+
9
+ ### Changed
10
+ - **Graphs is now the default view** (#66); `v` cycles Graphs → Table → Insights, and the title renders a view tab strip so switching is discoverable.
11
+ - README refreshed with current screenshots of all three views (#69) and documentation for export and table filtering.
12
+
13
+ ### Fixed
14
+ - Table selection could desync from rendered rows (the unsliced provider map leaked aggregation insertion order instead of cost-sorted order).
15
+
3
16
  ## [0.8.1] - 2026-07-17
4
17
 
5
18
  ### 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.0)
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,18 @@ 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 the current working directory:
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
+
98
120
  ### Graph explorer
99
121
 
100
122
  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 +170,14 @@ On narrow terminals, `/usage` automatically switches to a compact table instead
148
170
  | `Tab` / `←` `→` | Switch time period |
149
171
  | `↑` `↓` | Select provider *(table)* / move legend cursor *(graphs)* |
150
172
  | `Enter` / `Space` | Expand/collapse provider *(table)* / toggle series visibility *(graphs)* |
151
- | `v` | Cycle Table → Insights → Graphs view |
173
+ | `v` | Cycle Graphs → Table → Insights view |
174
+ | `e` | Export the current slice to CSV/JSON |
175
+ | `/` | Type-to-filter providers and models *(table)* |
176
+ | `x` | Hide the selected provider row *(table)* |
152
177
  | `m` | Cycle metric: cost / tokens / messages / reasoning *(graphs)* |
153
178
  | `g` | Cycle grouping: provider / model / thinking level / total *(graphs)* |
154
179
  | `c` | Toggle cumulative vs per-bucket *(graphs)* |
155
- | `a` | Show all series *(graphs)* |
180
+ | `a` | Show all series *(graphs)* / reset filter and hides *(table)* |
156
181
  | `q` / `Esc` | Close |
157
182
 
158
183
  ## Performance & Caching
package/export.ts ADDED
@@ -0,0 +1,112 @@
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 type { Insight, ProviderStats, TotalStats } from "./data.ts";
11
+ import type { GraphModel } from "./graph.ts";
12
+
13
+ /** Quote a CSV field only when it needs it (comma, quote, or newline). */
14
+ function csvField(value: string | number): string {
15
+ const s = String(value);
16
+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
17
+ }
18
+
19
+ function csvLine(fields: (string | number)[]): string {
20
+ return fields.map(csvField).join(",");
21
+ }
22
+
23
+ /** Per-model rows (provider repeated), then a TOTAL row. */
24
+ export function buildTableCsv(providers: ReadonlyMap<string, ProviderStats>, totals: TotalStats): string {
25
+ const lines = [
26
+ csvLine([
27
+ "provider",
28
+ "model",
29
+ "sessions",
30
+ "messages",
31
+ "cost_usd",
32
+ "fresh_tokens",
33
+ "input_tokens",
34
+ "output_tokens",
35
+ "cache_read_tokens",
36
+ "cache_write_tokens",
37
+ ]),
38
+ ];
39
+ const sorted = Array.from(providers.entries()).sort((a, b) => b[1].cost - a[1].cost);
40
+ for (const [providerName, provider] of sorted) {
41
+ const models = Array.from(provider.models.entries()).sort((a, b) => b[1].cost - a[1].cost);
42
+ for (const [modelName, model] of models) {
43
+ lines.push(
44
+ csvLine([
45
+ providerName,
46
+ modelName,
47
+ model.sessions.size,
48
+ model.messages,
49
+ model.cost,
50
+ model.tokens.total,
51
+ model.tokens.input,
52
+ model.tokens.output,
53
+ model.tokens.cacheRead,
54
+ model.tokens.cacheWrite,
55
+ ])
56
+ );
57
+ }
58
+ }
59
+ lines.push(
60
+ csvLine([
61
+ "TOTAL",
62
+ "",
63
+ totals.sessions,
64
+ totals.messages,
65
+ totals.cost,
66
+ totals.tokens.total,
67
+ totals.tokens.input,
68
+ totals.tokens.output,
69
+ totals.tokens.cacheRead,
70
+ totals.tokens.cacheWrite,
71
+ ])
72
+ );
73
+ return lines.join("\n") + "\n";
74
+ }
75
+
76
+ /**
77
+ * One row per time bucket, one column per visible series, values exactly as
78
+ * plotted (per-bucket or cumulative, current metric).
79
+ */
80
+ export function buildGraphCsv(model: GraphModel): string {
81
+ const visible = model.series.filter((s) => !s.hidden);
82
+ const lines = [csvLine(["bucket_start", ...visible.map((s) => s.label)])];
83
+ for (let i = 0; i < model.bucketStarts.length; i++) {
84
+ lines.push(csvLine([new Date(model.bucketStarts[i]!).toISOString(), ...visible.map((s) => s.points[i] ?? 0)]));
85
+ }
86
+ return lines.join("\n") + "\n";
87
+ }
88
+
89
+ /** Structured JSON of the period's insights plus headline totals. */
90
+ export function buildInsightsJson(period: string, totals: TotalStats, insights: Insight[]): string {
91
+ return (
92
+ JSON.stringify(
93
+ {
94
+ period,
95
+ generatedAt: new Date().toISOString(),
96
+ totals: { costUsd: totals.cost, messages: totals.messages, sessions: totals.sessions },
97
+ insights: insights.map((i) => ({ kind: i.kind, stat: i.stat, headline: i.headline, advice: i.advice })),
98
+ },
99
+ null,
100
+ "\t"
101
+ ) + "\n"
102
+ );
103
+ }
104
+
105
+ /** usage-<view>-<period>[-<slice>]-<stamp>.<ext> in the current directory. */
106
+ export function exportFileName(view: string, period: string, slice: string | null, ext: string, now: Date): string {
107
+ const pad = (n: number) => String(n).padStart(2, "0");
108
+ const stamp =
109
+ `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
110
+ `-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
111
+ return ["usage", view, period, ...(slice ? [slice] : []), stamp].join("-") + `.${ext}`;
112
+ }
Binary file
package/index.ts CHANGED
@@ -15,7 +15,7 @@ import { CancellableLoader, Container, Spacer, matchesKey, visibleWidth, truncat
15
15
 
16
16
  import { collectUsageData, 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,19 @@ import {
26
26
  TOTAL_SERIES_KEY,
27
27
  } from "./graph";
28
28
  import type { GraphGroupBy, GraphMetric, GraphModel } from "./graph";
29
+ import { buildGraphCsv, buildInsightsJson, buildTableCsv, exportFileName } from "./export";
30
+ import { writeFileSync } from "node:fs";
31
+ import { join } from "node:path";
29
32
 
30
33
  type ViewMode = "table" | "insights" | "graph";
31
34
 
32
- const VIEW_CYCLE: ViewMode[] = ["table", "insights", "graph"];
35
+ const VIEW_CYCLE: ViewMode[] = ["graph", "table", "insights"];
36
+
37
+ const VIEW_LABELS: Record<ViewMode, string> = {
38
+ graph: "Graphs",
39
+ table: "Table",
40
+ insights: "Insights",
41
+ };
33
42
 
34
43
  // =============================================================================
35
44
  // Column Configuration
@@ -262,7 +271,7 @@ const TAB_LABELS: Record<TabName, string> = {
262
271
 
263
272
  class UsageComponent {
264
273
  private activeTab: TabName = "allTime";
265
- private viewMode: ViewMode = "table";
274
+ private viewMode: ViewMode = "graph";
266
275
  private data: UsageData;
267
276
  private selectedIndex = 0;
268
277
  private expanded = new Set<string>();
@@ -275,6 +284,10 @@ class UsageComponent {
275
284
  private graphMetric: GraphMetric = "cost";
276
285
  private graphGroupBy: GraphGroupBy = "provider";
277
286
  private graphCumulative = true;
287
+ private exportNote: { text: string; ok: boolean } | null = null;
288
+ private tableHidden = new Set<string>();
289
+ private tableFilter = "";
290
+ private tableFilterEditing = false;
278
291
  private graphHidden = new Set<string>();
279
292
  private graphLegendIndex = 0;
280
293
 
@@ -291,10 +304,94 @@ class UsageComponent {
291
304
  this.providerOrder = Array.from(stats.providers.entries())
292
305
  .sort((a, b) => b[1].cost - a[1].cost)
293
306
  .map(([name]) => name);
294
- this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.providerOrder.length - 1));
307
+ this.clampTableSelection();
308
+ }
309
+
310
+ private clampTableSelection(): void {
311
+ this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.visibleTable().providers.size - 1));
312
+ }
313
+
314
+ /**
315
+ * The table slice after hides and the text filter. A filter matches a
316
+ * provider name (whole provider stays) or individual model names, in which
317
+ * case the provider row is synthesized from just the matching models so
318
+ * the totals row and exports reflect exactly what is on screen.
319
+ */
320
+ private visibleTable(): { providers: Map<string, ProviderStats>; totals: TotalStats } {
321
+ const stats = this.data[this.activeTab];
322
+ const q = this.tableFilter.trim().toLowerCase();
323
+ // Always iterate providerOrder so the map is cost-sorted — selection
324
+ // indexes and rendered rows must agree on ordering.
325
+ const providers = new Map<string, ProviderStats>();
326
+ for (const name of this.providerOrder) {
327
+ if (this.tableHidden.has(name)) continue;
328
+ const full = stats.providers.get(name)!;
329
+ if (!q || name.toLowerCase().includes(q)) {
330
+ providers.set(name, full);
331
+ continue;
332
+ }
333
+ const models = new Map(Array.from(full.models).filter(([model]) => model.toLowerCase().includes(q)));
334
+ if (models.size === 0) continue;
335
+ const synth: ProviderStats = {
336
+ messages: 0,
337
+ cost: 0,
338
+ tokens: { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
339
+ sessions: new Set<string>(),
340
+ models,
341
+ };
342
+ for (const model of models.values()) {
343
+ synth.messages += model.messages;
344
+ synth.cost += model.cost;
345
+ synth.tokens.total += model.tokens.total;
346
+ synth.tokens.input += model.tokens.input;
347
+ synth.tokens.output += model.tokens.output;
348
+ synth.tokens.cacheRead += model.tokens.cacheRead;
349
+ synth.tokens.cacheWrite += model.tokens.cacheWrite;
350
+ for (const s of model.sessions) synth.sessions.add(s);
351
+ }
352
+ providers.set(name, synth);
353
+ }
354
+ if (!q && this.tableHidden.size === 0) return { providers, totals: stats.totals };
355
+
356
+ const totals: TotalStats = {
357
+ sessions: 0,
358
+ messages: 0,
359
+ cost: 0,
360
+ tokens: { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
361
+ };
362
+ const sessions = new Set<string>();
363
+ for (const provider of providers.values()) {
364
+ totals.messages += provider.messages;
365
+ totals.cost += provider.cost;
366
+ totals.tokens.total += provider.tokens.total;
367
+ totals.tokens.input += provider.tokens.input;
368
+ totals.tokens.output += provider.tokens.output;
369
+ totals.tokens.cacheRead += provider.tokens.cacheRead;
370
+ totals.tokens.cacheWrite += provider.tokens.cacheWrite;
371
+ for (const s of provider.sessions) sessions.add(s);
372
+ }
373
+ totals.sessions = sessions.size;
374
+ return { providers, totals };
295
375
  }
296
376
 
297
377
  handleInput(data: string): void {
378
+ // Filter typing captures printable keys, so it runs before everything.
379
+ if (this.viewMode === "table" && this.tableFilterEditing) {
380
+ if (matchesKey(data, "escape")) {
381
+ this.tableFilter = "";
382
+ this.tableFilterEditing = false;
383
+ } else if (matchesKey(data, "enter")) {
384
+ this.tableFilterEditing = false;
385
+ } else if (matchesKey(data, "backspace")) {
386
+ this.tableFilter = this.tableFilter.slice(0, -1);
387
+ } else if (data.length === 1 && data >= " " && data !== "\x7f") {
388
+ this.tableFilter += data;
389
+ }
390
+ this.clampTableSelection();
391
+ this.requestRender();
392
+ return;
393
+ }
394
+
298
395
  if (matchesKey(data, "escape") || matchesKey(data, "q")) {
299
396
  this.done();
300
397
  return;
@@ -303,6 +400,13 @@ class UsageComponent {
303
400
  if (matchesKey(data, "v")) {
304
401
  const idx = VIEW_CYCLE.indexOf(this.viewMode);
305
402
  this.viewMode = VIEW_CYCLE[(idx + 1) % VIEW_CYCLE.length]!;
403
+ this.exportNote = null;
404
+ this.requestRender();
405
+ return;
406
+ }
407
+
408
+ if (matchesKey(data, "e")) {
409
+ this.exportCurrentView();
306
410
  this.requestRender();
307
411
  return;
308
412
  }
@@ -315,26 +419,45 @@ class UsageComponent {
315
419
  const idx = TAB_ORDER.indexOf(this.activeTab);
316
420
  this.activeTab = TAB_ORDER[(idx + 1) % TAB_ORDER.length]!;
317
421
  this.updateProviderOrder();
422
+ this.exportNote = null;
318
423
  this.requestRender();
319
424
  } else if (matchesKey(data, "shift+tab") || matchesKey(data, "left")) {
320
425
  const idx = TAB_ORDER.indexOf(this.activeTab);
321
426
  this.activeTab = TAB_ORDER[(idx - 1 + TAB_ORDER.length) % TAB_ORDER.length]!;
322
427
  this.updateProviderOrder();
428
+ this.exportNote = null;
323
429
  this.requestRender();
324
430
  } else if (this.viewMode === "graph") {
325
431
  // Graph-specific keys were handled above; swallow table-only keys.
432
+ } else if (this.viewMode === "table" && data === "/") {
433
+ this.tableFilterEditing = true;
434
+ this.requestRender();
435
+ } else if (this.viewMode === "table" && data === "x") {
436
+ const visible = Array.from(this.visibleTable().providers.keys());
437
+ const provider = visible[this.selectedIndex];
438
+ if (provider) {
439
+ this.tableHidden.add(provider);
440
+ this.clampTableSelection();
441
+ this.requestRender();
442
+ }
443
+ } else if (this.viewMode === "table" && data === "a") {
444
+ this.tableHidden.clear();
445
+ this.tableFilter = "";
446
+ this.tableFilterEditing = false;
447
+ this.clampTableSelection();
448
+ this.requestRender();
326
449
  } else if (this.viewMode === "table" && matchesKey(data, "up")) {
327
450
  if (this.selectedIndex > 0) {
328
451
  this.selectedIndex--;
329
452
  this.requestRender();
330
453
  }
331
454
  } else if (this.viewMode === "table" && matchesKey(data, "down")) {
332
- if (this.selectedIndex < this.providerOrder.length - 1) {
455
+ if (this.selectedIndex < this.visibleTable().providers.size - 1) {
333
456
  this.selectedIndex++;
334
457
  this.requestRender();
335
458
  }
336
459
  } else if (this.viewMode === "table" && (matchesKey(data, "enter") || matchesKey(data, "space"))) {
337
- const provider = this.providerOrder[this.selectedIndex];
460
+ const provider = Array.from(this.visibleTable().providers.keys())[this.selectedIndex];
338
461
  if (provider) {
339
462
  if (this.expanded.has(provider)) {
340
463
  this.expanded.delete(provider);
@@ -382,6 +505,33 @@ class UsageComponent {
382
505
  return true;
383
506
  }
384
507
 
508
+ private exportCurrentView(): void {
509
+ const now = new Date();
510
+ let name: string;
511
+ let content: string;
512
+ const stats = this.data[this.activeTab];
513
+ if (this.viewMode === "graph") {
514
+ const slice = `${this.graphCumulative ? "cumulative" : "per-bucket"}-${this.graphMetric}-by-${this.graphGroupBy}`;
515
+ name = exportFileName("graph", this.activeTab, slice, "csv", now);
516
+ content = buildGraphCsv(this.buildGraphModelForView());
517
+ } else if (this.viewMode === "insights") {
518
+ name = exportFileName("insights", this.activeTab, null, "json", now);
519
+ content = buildInsightsJson(this.activeTab, stats.totals, stats.insights.insights);
520
+ } else {
521
+ const visible = this.visibleTable();
522
+ const sliced = this.tableFilter.trim() !== "" || this.tableHidden.size > 0;
523
+ name = exportFileName("table", this.activeTab, sliced ? "filtered" : null, "csv", now);
524
+ content = buildTableCsv(visible.providers, visible.totals);
525
+ }
526
+ try {
527
+ const path = join(process.cwd(), name);
528
+ writeFileSync(path, content);
529
+ this.exportNote = { text: `Saved ${name}`, ok: true };
530
+ } catch (err) {
531
+ this.exportNote = { text: `Export failed: ${err instanceof Error ? err.message : String(err)}`, ok: false };
532
+ }
533
+ }
534
+
385
535
  private buildGraphModelForView(): GraphModel {
386
536
  return buildGraphModel(this.data.hourly, {
387
537
  period: this.activeTab,
@@ -396,7 +546,7 @@ class UsageComponent {
396
546
  render(width: number): string[] {
397
547
  if (this.viewMode === "graph") {
398
548
  return clampLines(
399
- [...this.renderTitle(), ...this.renderTabs(width, getTableLayout(width)), ...this.renderGraph(width), ...this.renderHelp(width)],
549
+ [...this.renderTitle(width), ...this.renderTabs(width, getTableLayout(width)), ...this.renderGraph(width), ...this.renderHelp(width)],
400
550
  width
401
551
  );
402
552
  }
@@ -404,7 +554,7 @@ class UsageComponent {
404
554
  if (this.viewMode === "insights") {
405
555
  return clampLines(
406
556
  [
407
- ...this.renderTitle(),
557
+ ...this.renderTitle(width),
408
558
  ...this.renderTabs(width, getTableLayout(width)),
409
559
  ...this.renderInsights(width),
410
560
  ...this.renderHelp(width),
@@ -416,7 +566,7 @@ class UsageComponent {
416
566
  const layout = getTableLayout(width);
417
567
  return clampLines(
418
568
  [
419
- ...this.renderTitle(),
569
+ ...this.renderTitle(width),
420
570
  ...this.renderTabs(width, layout),
421
571
  ...this.renderHeader(layout),
422
572
  ...this.renderRows(layout),
@@ -428,11 +578,21 @@ class UsageComponent {
428
578
  );
429
579
  }
430
580
 
431
- private renderTitle(): string[] {
581
+ private renderTitle(width: number): string[] {
432
582
  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)), ""];
583
+ const title = th.fg("accent", th.bold("Usage"));
584
+ // Render the views as a tab strip (like the period tabs) so it is
585
+ // obvious there are multiple views and [v] switches between them.
586
+ const fullStrip = VIEW_CYCLE.map((view) =>
587
+ view === this.viewMode ? th.fg("accent", `[${VIEW_LABELS[view]}]`) : th.fg("dim", ` ${VIEW_LABELS[view]} `)
588
+ ).join(" ");
589
+ const activeOnly = th.fg("accent", `[${VIEW_LABELS[this.viewMode]}]`);
590
+ const line = pickFittingText(width, [
591
+ `${title} ${fullStrip} ${th.fg("dim", "[v]")}`,
592
+ `${title} ${activeOnly} ${th.fg("dim", "[v]")}`,
593
+ `${title} ${activeOnly}`,
594
+ ]);
595
+ return [line, ""];
436
596
  }
437
597
 
438
598
  private renderGraph(width: number): string[] {
@@ -589,6 +749,17 @@ class UsageComponent {
589
749
  ? wrapTextWithAnsi(th.fg("dim", "Compact view. Widen the terminal for more columns."), Math.max(width, 1))
590
750
  : [];
591
751
 
752
+ if (this.viewMode === "table") {
753
+ if (this.tableFilterEditing) {
754
+ infoLines.push(`${th.fg("accent", `/ ${this.tableFilter}▌`)} ${th.fg("dim", "[Enter] keep · [Esc] clear")}`);
755
+ } else if (this.tableFilter.trim() !== "" || this.tableHidden.size > 0) {
756
+ const parts: string[] = [];
757
+ if (this.tableFilter.trim() !== "") parts.push(`filter: “${this.tableFilter.trim()}”`);
758
+ if (this.tableHidden.size > 0) parts.push(`${this.tableHidden.size} hidden`);
759
+ infoLines.push(th.fg("warning", `${parts.join(" · ")} · totals reflect this slice · [a] reset`));
760
+ }
761
+ }
762
+
592
763
  return [tabLine, ...infoLines, ""];
593
764
  }
594
765
 
@@ -633,7 +804,6 @@ class UsageComponent {
633
804
 
634
805
  private renderRows(layout: TableLayout): string[] {
635
806
  const th = this.theme;
636
- const stats = this.data[this.activeTab];
637
807
  const lines: string[] = [];
638
808
 
639
809
  if (this.providerOrder.length === 0) {
@@ -641,9 +811,14 @@ class UsageComponent {
641
811
  return lines;
642
812
  }
643
813
 
644
- for (let i = 0; i < this.providerOrder.length; i++) {
645
- const providerName = this.providerOrder[i]!;
646
- const providerStats = stats.providers.get(providerName)!;
814
+ const visible = Array.from(this.visibleTable().providers.entries());
815
+ if (visible.length === 0) {
816
+ lines.push(th.fg("dim", " Nothing matches the current filter — [a] resets"));
817
+ return lines;
818
+ }
819
+
820
+ for (let i = 0; i < visible.length; i++) {
821
+ const [providerName, providerStats] = visible[i]!;
647
822
  const isSelected = i === this.selectedIndex;
648
823
  const isExpanded = this.expanded.has(providerName);
649
824
  const arrow = isExpanded ? "▾" : "▸";
@@ -670,11 +845,11 @@ class UsageComponent {
670
845
 
671
846
  private renderTotals(layout: TableLayout): string[] {
672
847
  const th = this.theme;
673
- const stats = this.data[this.activeTab];
848
+ const { totals } = this.visibleTable();
674
849
 
675
850
  let totalRow = fitCell(th.bold("Total"), layout.nameWidth);
676
851
  for (const col of layout.columns) {
677
- const value = fitCell(col.getValue(stats.totals), col.width, "right");
852
+ const value = fitCell(col.getValue(totals), col.width, "right");
678
853
  totalRow += col.dimmed ? th.fg("dim", value) : value;
679
854
  }
680
855
 
@@ -692,32 +867,35 @@ class UsageComponent {
692
867
  }
693
868
 
694
869
  private renderHelp(width: number): string[] {
870
+ const noteLines = this.exportNote
871
+ ? [this.theme.fg(this.exportNote.ok ? "success" : "error", `${this.exportNote.ok ? "✓" : "✗"} ${this.exportNote.text}`), ""]
872
+ : [];
695
873
  const variants =
696
874
  this.viewMode === "graph"
697
875
  ? [
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",
876
+ "[Tab/←→] period [m] metric [g] group [c] cumulative [↑↓/Enter] filter [a] all [e] export [v] view [q] close",
877
+ "[Tab] period [m] metric [g] group [c] cumul [↑↓/Enter] filter [e] export [v] view [q] close",
700
878
  "[m] metric [g] group [c] cumul [↑↓] filter [q] close",
701
879
  "[m] [g] [c] [↑↓] [q]",
702
880
  "[q] close",
703
881
  ]
704
882
  : this.viewMode === "insights"
705
883
  ? [
706
- "[Tab/←→] period [v] graph view [q] close",
707
- "[Tab] period [v] graphs [q] close",
708
- "[v] graphs [q] close",
884
+ "[Tab/←→] period [e] export [v] view [q] close",
885
+ "[Tab] period [e] export [v] view [q] close",
886
+ "[v] view [q] close",
709
887
  "[q] close",
710
888
  ]
711
889
  : [
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",
890
+ "[Tab/←→] period [↑↓] select [Enter] expand [/] filter [x] hide [a] all [e] export [v] view [q] close",
891
+ "[Tab] period [↑↓] select [Enter] expand [/] filter [x] hide [e] export [v] view [q] close",
892
+ "[↑↓] select [Enter] expand [/] filter [x] hide [v] view [q] close",
893
+ "[↑↓] select [/] [x] [v] [q]",
716
894
  "[↑↓] select [q] close",
717
895
  "[q] close",
718
896
  ];
719
897
  const line = pickFittingText(width, variants);
720
- return [this.theme.fg("dim", line)];
898
+ return [...noteLines, this.theme.fg("dim", line)];
721
899
  }
722
900
 
723
901
  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.0",
4
4
  "description": "Usage statistics dashboard for Pi sessions.",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Mustier",
package/screenshot.png CHANGED
Binary file