@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 +19 -0
- package/README.md +40 -7
- package/data.ts +1 -1
- package/export.ts +146 -0
- package/graphs-screenshot.png +0 -0
- package/index.ts +226 -30
- package/insights-screenshot.png +0 -0
- package/package.json +1 -1
- package/screenshot.png +0 -0
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
|
-

|
|
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.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
|
-
- **
|
|
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,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
|
|
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[] = ["
|
|
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 = "
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
|
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(
|
|
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]
|
|
707
|
-
"[Tab] period [v]
|
|
708
|
-
"[v]
|
|
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]
|
|
713
|
-
"[Tab] period [↑↓] select [Enter] expand [v]
|
|
714
|
-
"[↑↓] select [Enter] expand [v]
|
|
715
|
-
"[↑↓] select [v]
|
|
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 {}
|
package/insights-screenshot.png
CHANGED
|
Binary file
|
package/package.json
CHANGED
package/screenshot.png
CHANGED
|
Binary file
|