@tmustier/pi-usage-extension 0.8.0 → 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 +18 -0
- package/README.md +32 -7
- package/data.ts +4 -4
- package/export.ts +112 -0
- package/graphs-screenshot.png +0 -0
- package/index.ts +207 -29
- package/insights-screenshot.png +0 -0
- package/package.json +1 -1
- package/screenshot.png +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
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
|
+
|
|
16
|
+
## [0.8.1] - 2026-07-17
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- **Consistent units.** Every alarm in “Worth attention” now leads with dollars (the act-or-ignore number), with the period share in the dimmed parenthetical; “Where it went” keeps percentages (shares of the pie). Session concentration and upfront tax previously led with %.
|
|
20
|
+
|
|
3
21
|
## [0.8.0] - 2026-07-17
|
|
4
22
|
|
|
5
23
|
### 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
|
-

|
|
6
6
|
|
|
7
7
|
## Compatibility
|
|
8
8
|
|
|
9
9
|
- **Pi version:** 0.42.4+
|
|
10
|
-
- **Last updated:** 2026-07-17 (0.
|
|
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
|
-
- **
|
|
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
|
-
|
|
65
|
+
|
|
66
|
+
Every view can export its current slice with `e` — see [Export](#export).
|
|
67
|
+
|
|
68
|
+

|
|
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
|

|
|
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
|
|
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/data.ts
CHANGED
|
@@ -1093,8 +1093,8 @@ function computeInsights(raw: PeriodRawData, trend: TrendInfo | null): PeriodIns
|
|
|
1093
1093
|
if (topPct >= CONCENTRATION_ALARM_PERCENT) {
|
|
1094
1094
|
insights.push({
|
|
1095
1095
|
kind: "alarm",
|
|
1096
|
-
stat:
|
|
1097
|
-
headline: `
|
|
1096
|
+
stat: fmtMoney(topWeight),
|
|
1097
|
+
headline: `came from just ${TOP_SESSION_COUNT} of your ${raw.sessionCosts.size} sessions (${fmtPercent(topPct)} of this period)`,
|
|
1098
1098
|
advice: "A handful of sessions drove most of the spend. The graph view can show what they were doing.",
|
|
1099
1099
|
});
|
|
1100
1100
|
}
|
|
@@ -1104,8 +1104,8 @@ function computeInsights(raw: PeriodRawData, trend: TrendInfo | null): PeriodIns
|
|
|
1104
1104
|
if (upfrontPct >= UPFRONT_ALARM_PERCENT) {
|
|
1105
1105
|
insights.push({
|
|
1106
1106
|
kind: "alarm",
|
|
1107
|
-
stat:
|
|
1108
|
-
headline:
|
|
1107
|
+
stat: fmtMoney(raw.upfrontCost),
|
|
1108
|
+
headline: `spent on the opening message of new sessions (${fmtPercent(upfrontPct)} of this period)`,
|
|
1109
1109
|
advice: "A session's first message sends everything from scratch. Fewer, longer sessions cut this overhead.",
|
|
1110
1110
|
});
|
|
1111
1111
|
}
|
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[] = ["
|
|
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 = "
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
|
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(
|
|
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]
|
|
707
|
-
"[Tab] period [v]
|
|
708
|
-
"[v]
|
|
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]
|
|
713
|
-
"[Tab] period [↑↓] select [Enter] expand [v]
|
|
714
|
-
"[↑↓] select [Enter] expand [v]
|
|
715
|
-
"[↑↓] select [v]
|
|
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 {}
|
package/insights-screenshot.png
CHANGED
|
Binary file
|
package/package.json
CHANGED
package/screenshot.png
CHANGED
|
Binary file
|