@tmustier/pi-usage-extension 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +23 -5
- package/data.ts +98 -6
- package/graph.ts +386 -0
- package/index.ts +174 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.6.0] - 2026-07-17
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **Interactive graph explorer.** New third view mode (cycle with `v`): a braille line chart of usage over time for the active period, with a legend showing per-series totals and shares.
|
|
7
|
+
- Metrics (`m`): cost, tokens, messages, reasoning tokens.
|
|
8
|
+
- Grouping (`g`): by provider, by model, by thinking level, or total only — top 6 series plus an `other` rollup, with a bold Total line always drawn.
|
|
9
|
+
- Cumulative running totals or per-bucket rates (`c`), hourly buckets for day/week periods and daily buckets for Last 30 Days / All Time.
|
|
10
|
+
- Legend filtering: `↑`/`↓` moves the cursor, `Enter`/`Space` hides or shows a series, `a` shows all. The y-axis rescales to the visible series so small series can be inspected by hiding large ones.
|
|
11
|
+
- **Thinking level and reasoning tokens.** Session parsing now replays `thinking_level_change` entries (compact and spaced JSON styles) to attribute a thinking level to each assistant message, and records `usage.reasoning` token counts. Messages before the first recorded change appear as `unknown`.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- On-disk cache format bumped to v2 (per-message thinking level and reasoning tokens). The first open after upgrading does a one-off full rebuild; older caches are ignored safely.
|
|
15
|
+
|
|
3
16
|
## [0.5.0] - 2026-07-17
|
|
4
17
|
|
|
5
18
|
### Added
|
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ A Pi extension that displays aggregated usage statistics across all sessions.
|
|
|
7
7
|
## Compatibility
|
|
8
8
|
|
|
9
9
|
- **Pi version:** 0.42.4+
|
|
10
|
-
- **Last updated:** 2026-07-17 (0.
|
|
10
|
+
- **Last updated:** 2026-07-17 (0.6.0)
|
|
11
11
|
|
|
12
12
|
## Installation
|
|
13
13
|
|
|
@@ -57,15 +57,28 @@ In Pi, run:
|
|
|
57
57
|
|
|
58
58
|
### Views
|
|
59
59
|
|
|
60
|
-
`/usage` has
|
|
60
|
+
`/usage` has three view modes, cycled with `v`:
|
|
61
61
|
|
|
62
62
|
- **Table** (default) — per-provider / per-model stats with cost and token breakdown (screenshot at the top of this page).
|
|
63
63
|
- **Insights** — narrative characteristics of your cost for the active time period, e.g. *"X% of your cost was at >150k context"*. Insights are **independent characteristics**, not a breakdown, so they overlap and can sum to more than 100%.
|
|
64
|
+
- **Graphs** — an interactive braille line-chart explorer for usage over time (details below).
|
|
64
65
|
|
|
65
66
|

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