@ohgodtamit/pi-usage 0.1.0-alpha.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 +8 -0
- package/LICENSE +22 -0
- package/README.md +403 -0
- package/THIRD_PARTY_NOTICES.md +54 -0
- package/dist/aggregate.d.ts +392 -0
- package/dist/aggregate.d.ts.map +1 -0
- package/dist/cache.d.ts +27 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/config.d.ts +38 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/format.d.ts +46 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/freshness.d.ts +42 -0
- package/dist/freshness.d.ts.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/mascot.d.ts +35 -0
- package/dist/mascot.d.ts.map +1 -0
- package/dist/prices.d.ts +24 -0
- package/dist/prices.d.ts.map +1 -0
- package/dist/provider.d.ts +147 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/view.d.ts +207 -0
- package/dist/view.d.ts.map +1 -0
- package/dist/zai.d.ts +57 -0
- package/dist/zai.d.ts.map +1 -0
- package/package.json +60 -0
- package/src/aggregate.ts +1838 -0
- package/src/cache.ts +100 -0
- package/src/config.ts +93 -0
- package/src/format.ts +166 -0
- package/src/freshness.ts +55 -0
- package/src/index.ts +642 -0
- package/src/mascot.ts +227 -0
- package/src/prices.ts +63 -0
- package/src/provider.ts +704 -0
- package/src/view.ts +2585 -0
- package/src/zai.ts +101 -0
package/src/view.ts
ADDED
|
@@ -0,0 +1,2585 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive usage panel TUI component.
|
|
3
|
+
*
|
|
4
|
+
* Rendered via ctx.ui.custom(). Mirrors Claude Code's `/usage` screen:
|
|
5
|
+
* always-visible 5-hour and weekly quota bars, a selectable time window, and
|
|
6
|
+
* independent-characteristic breakdowns by model / skill / plugin / tool /
|
|
7
|
+
* project. Supports vertical scrolling for small terminals.
|
|
8
|
+
*/
|
|
9
|
+
import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import type { TUI } from "@earendil-works/pi-tui";
|
|
11
|
+
import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
12
|
+
import {
|
|
13
|
+
type AttributionMaps,
|
|
14
|
+
agentStats,
|
|
15
|
+
agentTopModel,
|
|
16
|
+
availableYears,
|
|
17
|
+
type Bucket,
|
|
18
|
+
bucketTokens,
|
|
19
|
+
type ContribGraph,
|
|
20
|
+
computeStats,
|
|
21
|
+
contributionGraph,
|
|
22
|
+
dailyStats,
|
|
23
|
+
dayTopModel,
|
|
24
|
+
dayUptimeMs,
|
|
25
|
+
defaultWrappedYear,
|
|
26
|
+
hourlyStats,
|
|
27
|
+
hourTopModel,
|
|
28
|
+
metricValue,
|
|
29
|
+
naturalMetric,
|
|
30
|
+
type Report,
|
|
31
|
+
rangeLabel,
|
|
32
|
+
rangeSince,
|
|
33
|
+
ranked,
|
|
34
|
+
type StatsRange,
|
|
35
|
+
tokensPerSecond,
|
|
36
|
+
type WindowedReport,
|
|
37
|
+
type WindowKey,
|
|
38
|
+
type WrappedStats,
|
|
39
|
+
windowize,
|
|
40
|
+
wrappedStats,
|
|
41
|
+
} from "./aggregate.ts";
|
|
42
|
+
import {
|
|
43
|
+
formatCost,
|
|
44
|
+
formatDayLabel,
|
|
45
|
+
formatDuration,
|
|
46
|
+
formatHour,
|
|
47
|
+
formatInt,
|
|
48
|
+
formatTokens,
|
|
49
|
+
monthLabel,
|
|
50
|
+
percent,
|
|
51
|
+
shortenPath,
|
|
52
|
+
sparkline,
|
|
53
|
+
} from "./format.ts";
|
|
54
|
+
import {
|
|
55
|
+
mascotPose,
|
|
56
|
+
mascotQuip,
|
|
57
|
+
RAINBOW,
|
|
58
|
+
renderMascot,
|
|
59
|
+
renderWrappedMascot,
|
|
60
|
+
toolGlyph,
|
|
61
|
+
VIEW_ORDER,
|
|
62
|
+
VIEW_TABS,
|
|
63
|
+
type ViewKey,
|
|
64
|
+
wrappedMascotCaption,
|
|
65
|
+
} from "./mascot.ts";
|
|
66
|
+
import type { ProviderQuota } from "./provider.ts";
|
|
67
|
+
|
|
68
|
+
export type { ViewKey } from "./mascot.ts";
|
|
69
|
+
|
|
70
|
+
/** Sort field for the Models table. */
|
|
71
|
+
type SortKey = "value" | "name";
|
|
72
|
+
|
|
73
|
+
/** Sort field + direction for the Daily table. */
|
|
74
|
+
type DailySortField = "tokens" | "cost" | "date";
|
|
75
|
+
type SortDir = "asc" | "desc";
|
|
76
|
+
|
|
77
|
+
/** Semantic controls shared by terminal key handling and portable UI clients. */
|
|
78
|
+
export type UsageAction =
|
|
79
|
+
| { type: "view"; view: ViewKey }
|
|
80
|
+
| { type: "window"; window: WindowKey }
|
|
81
|
+
| { type: "modelSort"; sort: SortKey }
|
|
82
|
+
| { type: "dailySort"; sort: DailySortField }
|
|
83
|
+
| { type: "statsRange"; range: StatsRange }
|
|
84
|
+
| { type: "providerSort"; sort: SortKey }
|
|
85
|
+
| { type: "wrappedYear"; year: number }
|
|
86
|
+
| { type: "wrappedYearDelta"; delta: number }
|
|
87
|
+
| { type: "refresh" }
|
|
88
|
+
| { type: "configure" }
|
|
89
|
+
| { type: "close" };
|
|
90
|
+
|
|
91
|
+
export interface UsageViewDeps {
|
|
92
|
+
theme: Theme;
|
|
93
|
+
tui: TUI | undefined;
|
|
94
|
+
maps: AttributionMaps;
|
|
95
|
+
home: string;
|
|
96
|
+
getConfig: () => {
|
|
97
|
+
fiveHourLimit?: number;
|
|
98
|
+
weeklyLimit?: number;
|
|
99
|
+
fiveHourTokenLimit?: number;
|
|
100
|
+
weeklyTokenLimit?: number;
|
|
101
|
+
};
|
|
102
|
+
onClose: () => void;
|
|
103
|
+
onRefresh: () => void;
|
|
104
|
+
onConfigure: () => void;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface ViewState {
|
|
108
|
+
report: Report | undefined;
|
|
109
|
+
windowKey: WindowKey;
|
|
110
|
+
/** Active top-level view (Overview / Models / Daily / Stats). */
|
|
111
|
+
view: ViewKey;
|
|
112
|
+
/** Sort field for the Models table. */
|
|
113
|
+
sortKey: SortKey;
|
|
114
|
+
/** Sort field + direction for the Daily table. */
|
|
115
|
+
dailySortField: DailySortField;
|
|
116
|
+
dailySortDir: SortDir;
|
|
117
|
+
/** Time range for the Stats view summary (All / 30d / 7d). */
|
|
118
|
+
statsRange: StatsRange;
|
|
119
|
+
/** Calendar year for the Wrapped AI view. */
|
|
120
|
+
wrappedYear: number;
|
|
121
|
+
/** Sort field for the Providers table. */
|
|
122
|
+
agentSortKey: SortKey;
|
|
123
|
+
scanProgress: { loaded: number; total: number } | null;
|
|
124
|
+
scroll: number;
|
|
125
|
+
error: string | null;
|
|
126
|
+
providerQuota: ProviderQuota | null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export class UsageView {
|
|
130
|
+
private readonly deps: UsageViewDeps;
|
|
131
|
+
private portableRendering = false;
|
|
132
|
+
private state: ViewState = {
|
|
133
|
+
report: undefined,
|
|
134
|
+
windowKey: "24h",
|
|
135
|
+
view: "overview",
|
|
136
|
+
sortKey: "value",
|
|
137
|
+
dailySortField: "tokens",
|
|
138
|
+
dailySortDir: "desc",
|
|
139
|
+
statsRange: "all",
|
|
140
|
+
wrappedYear: new Date().getFullYear(),
|
|
141
|
+
agentSortKey: "value",
|
|
142
|
+
scanProgress: null,
|
|
143
|
+
scroll: 0,
|
|
144
|
+
error: null,
|
|
145
|
+
providerQuota: null,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
constructor(deps: UsageViewDeps) {
|
|
149
|
+
this.deps = deps;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Set the initial view (used by /usage-models, /usage-daily, … shortcuts). */
|
|
153
|
+
setInitialView(view: ViewKey): void {
|
|
154
|
+
this.applyAction({ type: "view", view });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
get activeView(): ViewKey {
|
|
158
|
+
return this.state.view;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
get wrappedYears(): number[] {
|
|
162
|
+
return this.state.report ? availableYears(this.state.report) : [];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Apply a UI-independent dashboard action. */
|
|
166
|
+
applyAction(action: UsageAction): void {
|
|
167
|
+
switch (action.type) {
|
|
168
|
+
case "view":
|
|
169
|
+
this.setView(action.view);
|
|
170
|
+
break;
|
|
171
|
+
case "window":
|
|
172
|
+
this.setWindow(action.window);
|
|
173
|
+
break;
|
|
174
|
+
case "modelSort":
|
|
175
|
+
this.state.sortKey = action.sort;
|
|
176
|
+
this.state.scroll = 0;
|
|
177
|
+
this.deps.tui?.requestRender();
|
|
178
|
+
break;
|
|
179
|
+
case "dailySort":
|
|
180
|
+
this.setDailySort(action.sort);
|
|
181
|
+
break;
|
|
182
|
+
case "statsRange":
|
|
183
|
+
this.setStatsRange(action.range);
|
|
184
|
+
break;
|
|
185
|
+
case "providerSort":
|
|
186
|
+
this.state.agentSortKey = action.sort;
|
|
187
|
+
this.state.scroll = 0;
|
|
188
|
+
this.deps.tui?.requestRender();
|
|
189
|
+
break;
|
|
190
|
+
case "wrappedYear":
|
|
191
|
+
if (this.wrappedYears.includes(action.year)) {
|
|
192
|
+
this.state.wrappedYear = action.year;
|
|
193
|
+
this.state.scroll = 0;
|
|
194
|
+
this.deps.tui?.requestRender();
|
|
195
|
+
}
|
|
196
|
+
break;
|
|
197
|
+
case "wrappedYearDelta":
|
|
198
|
+
this.cycleWrappedYear(action.delta);
|
|
199
|
+
break;
|
|
200
|
+
case "refresh":
|
|
201
|
+
this.deps.onRefresh();
|
|
202
|
+
break;
|
|
203
|
+
case "configure":
|
|
204
|
+
this.deps.onConfigure();
|
|
205
|
+
break;
|
|
206
|
+
case "close":
|
|
207
|
+
this.deps.onClose();
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Re-bind the TUI/theme/close callback once pi's custom() factory runs. */
|
|
213
|
+
bind(tui: TUI, theme: Theme, onClose: () => void): void {
|
|
214
|
+
this.deps.tui = tui;
|
|
215
|
+
this.deps.theme = theme;
|
|
216
|
+
this.deps.onClose = onClose;
|
|
217
|
+
this.deps.tui?.requestRender();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// --- mutators used by the orchestrator (index.ts) ---
|
|
221
|
+
|
|
222
|
+
setReport(report: Report): void {
|
|
223
|
+
this.state.report = report;
|
|
224
|
+
this.state.scanProgress = null;
|
|
225
|
+
this.state.error = null;
|
|
226
|
+
this.state.wrappedYear = defaultWrappedYear(report);
|
|
227
|
+
this.clampScroll();
|
|
228
|
+
this.deps.tui?.requestRender();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
setScanning(loaded: number, total: number): void {
|
|
232
|
+
this.state.scanProgress = { loaded, total };
|
|
233
|
+
this.deps.tui?.requestRender();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
setError(message: string): void {
|
|
237
|
+
this.state.error = message;
|
|
238
|
+
this.state.scanProgress = null;
|
|
239
|
+
this.deps.tui?.requestRender();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
setProviderQuota(quota: ProviderQuota): void {
|
|
243
|
+
this.state.providerQuota = quota;
|
|
244
|
+
this.clampScroll();
|
|
245
|
+
this.deps.tui?.requestRender();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// --- Component interface ---
|
|
249
|
+
|
|
250
|
+
handleInput(data: string): void {
|
|
251
|
+
if (matchesKey(data, "q") || matchesKey(data, Key.escape)) {
|
|
252
|
+
this.applyAction({ type: "close" });
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
// View navigation: Tab / Shift+Tab + arrows + number keys.
|
|
256
|
+
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
|
|
257
|
+
this.cycleView(1);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (matchesKey(data, "shift+tab") || matchesKey(data, Key.left)) {
|
|
261
|
+
this.cycleView(-1);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (data === "1") {
|
|
265
|
+
this.applyAction({ type: "view", view: "overview" });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (data === "2") {
|
|
269
|
+
this.applyAction({ type: "view", view: "models" });
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (data === "3") {
|
|
273
|
+
this.applyAction({ type: "view", view: "delegation" });
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (data === "4") {
|
|
277
|
+
this.applyAction({ type: "view", view: "daily" });
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (data === "6") {
|
|
281
|
+
this.applyAction({ type: "view", view: "hourly" });
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (data === "7") {
|
|
285
|
+
this.applyAction({ type: "view", view: "providers" });
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (data === "8") {
|
|
289
|
+
this.applyAction({ type: "view", view: "wrapped" });
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (data === "5") {
|
|
293
|
+
if (this.state.view === "overview" || this.state.view === "models") {
|
|
294
|
+
this.applyAction({ type: "window", window: "5h" });
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
this.applyAction({ type: "view", view: "stats" });
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
// Wrapped AI: [ / ] or y cycle calendar years.
|
|
301
|
+
if (this.state.view === "wrapped") {
|
|
302
|
+
if (data === "[") {
|
|
303
|
+
this.applyAction({ type: "wrappedYearDelta", delta: -1 });
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (data === "]") {
|
|
307
|
+
this.applyAction({ type: "wrappedYearDelta", delta: 1 });
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (matchesKey(data, "y")) {
|
|
311
|
+
this.applyAction({ type: "wrappedYearDelta", delta: 1 });
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// Providers view: c/n sort by usage or name.
|
|
316
|
+
if (this.state.view === "providers") {
|
|
317
|
+
if (matchesKey(data, "c") || matchesKey(data, "t")) {
|
|
318
|
+
this.applyAction({ type: "providerSort", sort: "value" });
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (matchesKey(data, "n")) {
|
|
322
|
+
this.applyAction({ type: "providerSort", sort: "name" });
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
// Stats view: a/w/m pick the summary time range (intercept before the
|
|
327
|
+
// window-key handlers, since the time window doesn't apply to Stats).
|
|
328
|
+
if (this.state.view === "stats") {
|
|
329
|
+
if (matchesKey(data, "a")) {
|
|
330
|
+
this.applyAction({ type: "statsRange", range: "all" });
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (matchesKey(data, "w")) {
|
|
334
|
+
this.applyAction({ type: "statsRange", range: "7d" });
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (matchesKey(data, "m")) {
|
|
338
|
+
this.applyAction({ type: "statsRange", range: "30d" });
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// Daily view: t/c/d choose the sort field; pressing the same key flips
|
|
343
|
+
// direction. Intercept before the global sort/window handlers.
|
|
344
|
+
if (this.state.view === "daily") {
|
|
345
|
+
if (matchesKey(data, "t")) {
|
|
346
|
+
this.applyAction({ type: "dailySort", sort: "tokens" });
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (matchesKey(data, "c")) {
|
|
350
|
+
this.applyAction({ type: "dailySort", sort: "cost" });
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (matchesKey(data, "d")) {
|
|
354
|
+
this.applyAction({ type: "dailySort", sort: "date" });
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// Sorting (Models & Daily tables).
|
|
359
|
+
if (this.state.view === "models" && (matchesKey(data, "c") || matchesKey(data, "t"))) {
|
|
360
|
+
this.applyAction({ type: "modelSort", sort: "value" });
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (this.state.view === "models" && matchesKey(data, "n")) {
|
|
364
|
+
this.applyAction({ type: "modelSort", sort: "name" });
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (matchesKey(data, "d")) {
|
|
368
|
+
this.applyAction({ type: "window", window: "24h" });
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (matchesKey(data, "w")) {
|
|
372
|
+
this.applyAction({ type: "window", window: "7d" });
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
if (matchesKey(data, "a")) {
|
|
376
|
+
this.applyAction({ type: "window", window: "all" });
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (matchesKey(data, "r")) {
|
|
380
|
+
this.applyAction({ type: "refresh" });
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (matchesKey(data, "s")) {
|
|
384
|
+
this.applyAction({ type: "configure" });
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (matchesKey(data, "j") || matchesKey(data, Key.down)) {
|
|
388
|
+
this.scrollBy(1);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (matchesKey(data, "k") || matchesKey(data, Key.up)) {
|
|
392
|
+
this.scrollBy(-1);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (matchesKey(data, Key.space) || matchesKey(data, "ctrl+d")) {
|
|
396
|
+
this.scrollBy(this.availableHeight() / 2);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (matchesKey(data, "ctrl+u") || matchesKey(data, "b")) {
|
|
400
|
+
this.scrollBy(-this.availableHeight() / 2);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (data === "g") {
|
|
404
|
+
this.scrollTo(0);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (data === "G") {
|
|
408
|
+
this.scrollTo(Number.MAX_SAFE_INTEGER);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
render(width: number): string[] {
|
|
414
|
+
const { theme } = this.deps;
|
|
415
|
+
const all = this.buildLines(width, false).map((line) => this.clampLine(line, width));
|
|
416
|
+
const height = this.availableHeight();
|
|
417
|
+
|
|
418
|
+
if (all.length <= height) {
|
|
419
|
+
this.state.scroll = 0;
|
|
420
|
+
return all;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const max = all.length - height;
|
|
424
|
+
if (this.state.scroll > max) this.state.scroll = max;
|
|
425
|
+
if (this.state.scroll < 0) this.state.scroll = 0;
|
|
426
|
+
const start = this.state.scroll;
|
|
427
|
+
const slice = all.slice(start, start + height);
|
|
428
|
+
|
|
429
|
+
// Scroll indicator (top-right) so the user knows there is more content.
|
|
430
|
+
const indicator = ` ${start + 1}-${Math.min(start + height, all.length)}/${all.length} `;
|
|
431
|
+
const indicatorW = visibleWidth(indicator);
|
|
432
|
+
const last = truncateToWidth(slice[slice.length - 1] ?? "", Math.max(0, width - indicatorW));
|
|
433
|
+
const pad = Math.max(0, width - visibleWidth(last) - indicatorW);
|
|
434
|
+
slice[slice.length - 1] = last + " ".repeat(pad) + theme.fg("dim", indicator);
|
|
435
|
+
return slice;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Render all lines without terminal viewport assumptions or key-based instructions. */
|
|
439
|
+
renderPortable(width: number): string[] {
|
|
440
|
+
this.portableRendering = true;
|
|
441
|
+
try {
|
|
442
|
+
return this.buildLines(width, true).map((line) => this.clampLine(line, width));
|
|
443
|
+
} finally {
|
|
444
|
+
this.portableRendering = false;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Final width clamp so one long line can never break the TUI layout. */
|
|
449
|
+
private clampLine(line: string, width: number): string {
|
|
450
|
+
return visibleWidth(line) > width ? truncateToWidth(line, width) : line;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
invalidate(): void {
|
|
454
|
+
this.deps.tui?.requestRender();
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// --- internals ---
|
|
458
|
+
|
|
459
|
+
private availableHeight(): number {
|
|
460
|
+
// Reserve a couple of rows for pi's footer/status. Floor at a sane minimum.
|
|
461
|
+
const rows = this.deps.tui?.terminal?.rows ?? 24;
|
|
462
|
+
return Math.max(8, rows - 2);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
private setWindow(key: WindowKey): void {
|
|
466
|
+
this.state.windowKey = key;
|
|
467
|
+
this.state.scroll = 0;
|
|
468
|
+
this.deps.tui?.requestRender();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
private setView(view: ViewKey): void {
|
|
472
|
+
this.state.view = view;
|
|
473
|
+
this.state.scroll = 0;
|
|
474
|
+
this.deps.tui?.requestRender();
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private cycleView(delta: number): void {
|
|
478
|
+
const idx = VIEW_ORDER.indexOf(this.state.view);
|
|
479
|
+
const next = (idx + delta + VIEW_ORDER.length) % VIEW_ORDER.length;
|
|
480
|
+
this.setView(VIEW_ORDER[next]);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
private setStatsRange(range: StatsRange): void {
|
|
484
|
+
this.state.statsRange = range;
|
|
485
|
+
this.state.scroll = 0;
|
|
486
|
+
this.deps.tui?.requestRender();
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Set the Daily sort field; pressing the same field again flips direction. */
|
|
490
|
+
private setDailySort(field: DailySortField): void {
|
|
491
|
+
if (this.state.dailySortField === field) {
|
|
492
|
+
this.state.dailySortDir = this.state.dailySortDir === "desc" ? "asc" : "desc";
|
|
493
|
+
} else {
|
|
494
|
+
this.state.dailySortField = field;
|
|
495
|
+
this.state.dailySortDir = "desc";
|
|
496
|
+
}
|
|
497
|
+
this.state.scroll = 0;
|
|
498
|
+
this.deps.tui?.requestRender();
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
private cycleWrappedYear(delta: number): void {
|
|
502
|
+
const report = this.state.report;
|
|
503
|
+
if (!report) return;
|
|
504
|
+
const years = availableYears(report);
|
|
505
|
+
if (years.length === 0) return;
|
|
506
|
+
const cur = this.state.wrappedYear;
|
|
507
|
+
const idx = years.indexOf(cur);
|
|
508
|
+
const base = idx >= 0 ? idx : 0;
|
|
509
|
+
const next = (base + delta + years.length) % years.length;
|
|
510
|
+
this.state.wrappedYear = years[next];
|
|
511
|
+
this.state.scroll = 0;
|
|
512
|
+
this.deps.tui?.requestRender();
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
private scrollBy(delta: number): void {
|
|
516
|
+
this.scrollTo(this.state.scroll + delta);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
private scrollTo(pos: number): void {
|
|
520
|
+
this.state.scroll = Math.max(0, Math.round(pos));
|
|
521
|
+
this.clampScroll();
|
|
522
|
+
this.deps.tui?.requestRender();
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private clampScroll(): void {
|
|
526
|
+
// Re-clamped precisely in render(); keep a rough bound here.
|
|
527
|
+
if (this.state.scroll < 0) this.state.scroll = 0;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
private buildLines(width: number, portable: boolean): string[] {
|
|
531
|
+
const { theme } = this.deps;
|
|
532
|
+
const lines: string[] = [];
|
|
533
|
+
const w = Math.max(40, width);
|
|
534
|
+
|
|
535
|
+
lines.push(theme.fg("borderMuted", "─".repeat(w)));
|
|
536
|
+
lines.push(this.titleLineRaw(w));
|
|
537
|
+
for (const menuLine of portable ? this.portableMenuLines(w) : this.menuLines(w)) {
|
|
538
|
+
lines.push(menuLine);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (this.state.error) {
|
|
542
|
+
lines.push("");
|
|
543
|
+
lines.push(` ${theme.fg("error", this.state.error)}`);
|
|
544
|
+
lines.push("");
|
|
545
|
+
lines.push(portable ? this.portableFooterLine(w) : this.footerLine(w));
|
|
546
|
+
lines.push(theme.fg("borderMuted", "─".repeat(w)));
|
|
547
|
+
return lines;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (!this.state.report) {
|
|
551
|
+
lines.push("");
|
|
552
|
+
const prog = this.state.scanProgress;
|
|
553
|
+
const msg = prog ? `Scanning sessions… ${prog.loaded}/${prog.total}` : "Scanning sessions…";
|
|
554
|
+
lines.push(` ${theme.fg("accent", msg)}`);
|
|
555
|
+
lines.push("");
|
|
556
|
+
lines.push(portable ? this.portableFooterLine(w) : this.footerLine(w));
|
|
557
|
+
lines.push(theme.fg("borderMuted", "─".repeat(w)));
|
|
558
|
+
return lines;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
switch (this.state.view) {
|
|
562
|
+
case "overview":
|
|
563
|
+
this.renderOverview(lines, w);
|
|
564
|
+
break;
|
|
565
|
+
case "models":
|
|
566
|
+
this.renderModels(lines, w);
|
|
567
|
+
break;
|
|
568
|
+
case "delegation":
|
|
569
|
+
this.renderDelegation(lines, w);
|
|
570
|
+
break;
|
|
571
|
+
case "daily":
|
|
572
|
+
this.renderDaily(lines, w);
|
|
573
|
+
break;
|
|
574
|
+
case "stats":
|
|
575
|
+
this.renderStats(lines, w);
|
|
576
|
+
break;
|
|
577
|
+
case "hourly":
|
|
578
|
+
this.renderHourly(lines, w);
|
|
579
|
+
break;
|
|
580
|
+
case "providers":
|
|
581
|
+
this.renderProviders(lines, w);
|
|
582
|
+
break;
|
|
583
|
+
case "wrapped":
|
|
584
|
+
this.renderWrapped(lines, w);
|
|
585
|
+
break;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
lines.push(portable ? this.portableFooterLine(w) : this.footerLine(w));
|
|
589
|
+
lines.push(theme.fg("borderMuted", "─".repeat(w)));
|
|
590
|
+
return lines;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Subscription-aware breakdown unit: token-priced providers (Codex, ZAI
|
|
595
|
+
* plans) always show tokens; otherwise USD when the window has real cost.
|
|
596
|
+
*/
|
|
597
|
+
private unitForWindow(win: WindowedReport): "usd" | "tokens" {
|
|
598
|
+
const activeProviderName = this.state.providerQuota?.active?.provider ?? "";
|
|
599
|
+
const isSubscription =
|
|
600
|
+
activeProviderName === "openai-codex" ||
|
|
601
|
+
activeProviderName.startsWith("openai-codex-") ||
|
|
602
|
+
!!this.state.providerQuota?.planQuota;
|
|
603
|
+
return isSubscription || win.total.cost <= 0 ? "tokens" : "usd";
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// ---------------------------------------------------------------- Overview
|
|
607
|
+
|
|
608
|
+
private renderOverview(lines: string[], w: number): void {
|
|
609
|
+
const { theme } = this.deps;
|
|
610
|
+
const report = this.state.report;
|
|
611
|
+
if (!report) return;
|
|
612
|
+
const win = windowize(report, this.state.windowKey, this.deps.maps);
|
|
613
|
+
lines.push(this.subheaderLine(win, w));
|
|
614
|
+
lines.push("");
|
|
615
|
+
|
|
616
|
+
const unit = this.unitForWindow(win);
|
|
617
|
+
this.renderQuotaBlock(lines, win, w, unit);
|
|
618
|
+
lines.push("");
|
|
619
|
+
|
|
620
|
+
// Headline stats for the selected window.
|
|
621
|
+
lines.push(this.statsLine(win, w));
|
|
622
|
+
if (bucketTokens(win.delegated) > 0) {
|
|
623
|
+
const share = percent(bucketTokens(win.delegated), bucketTokens(win.total));
|
|
624
|
+
const peak = win.concurrency.peak == null ? "—" : `${win.concurrency.peak}`;
|
|
625
|
+
// Only mark the peak as estimated when child timing was inferred from
|
|
626
|
+
// transcript mtimes; precise spawn/end records make it a real measurement.
|
|
627
|
+
const peakLabel = win.concurrency.inferred ? "est. peak" : "peak";
|
|
628
|
+
const childWord = win.children.length === 1 ? "child session" : "child sessions";
|
|
629
|
+
const detail = truncateToWidth(
|
|
630
|
+
`Delegated ${share} · ${win.children.length} ${childWord} · ${peakLabel} ${peak}`,
|
|
631
|
+
Math.max(20, w - 4),
|
|
632
|
+
);
|
|
633
|
+
lines.push(` ${theme.fg("muted", detail)}`);
|
|
634
|
+
}
|
|
635
|
+
lines.push("");
|
|
636
|
+
this.appendTokenComposition(lines, win, w);
|
|
637
|
+
lines.push("");
|
|
638
|
+
|
|
639
|
+
// Active provider + live quota (from the provider itself).
|
|
640
|
+
this.appendProviderSection(lines, w);
|
|
641
|
+
|
|
642
|
+
// "Top consumer" sentence (single biggest independent characteristic).
|
|
643
|
+
const top = this.topConsumer(win, unit);
|
|
644
|
+
if (top) {
|
|
645
|
+
lines.push(` ${theme.fg("muted", "Top consumer")}`);
|
|
646
|
+
lines.push(
|
|
647
|
+
` ${theme.fg("text", `${top.pct} of usage came from ${top.kind} `)}${theme.fg("accent", top.name)}`,
|
|
648
|
+
);
|
|
649
|
+
lines.push("");
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Mini trend: last 30 active-window days as a sparkline.
|
|
653
|
+
this.appendTrendSparkline(lines, w);
|
|
654
|
+
|
|
655
|
+
// Compact top models for at-a-glance context (full table in Models view).
|
|
656
|
+
const total = unit === "tokens" ? bucketTokens(win.total) : win.total.cost;
|
|
657
|
+
this.appendSection(lines, "Top models", win.byModel, total, w, 5, undefined, unit);
|
|
658
|
+
lines.push(
|
|
659
|
+
` ${theme.fg("dim", this.portableRendering ? "Use the action menu to explore other views." : "→ Tab or 1-8 to explore · ✦8 opens Wrapped AI")}`,
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** Render the always-on quota bars (plan quota or session-derived budget). */
|
|
664
|
+
private renderQuotaBlock(
|
|
665
|
+
lines: string[],
|
|
666
|
+
win: WindowedReport,
|
|
667
|
+
w: number,
|
|
668
|
+
unit: "usd" | "tokens",
|
|
669
|
+
): void {
|
|
670
|
+
const { theme } = this.deps;
|
|
671
|
+
const cfg = this.deps.getConfig();
|
|
672
|
+
const activeProviderName = this.state.providerQuota?.active?.provider ?? "";
|
|
673
|
+
const planQuota = this.state.providerQuota?.planQuota;
|
|
674
|
+
if (planQuota) {
|
|
675
|
+
// Provider-native plan quota (ZAI GLM coding plans): the upstream reports
|
|
676
|
+
// the authoritative used % + live reset countdown directly. No budget
|
|
677
|
+
// config needed — these ARE the 5h/weekly used/remaining from upstream.
|
|
678
|
+
const planLabel = planQuota.plan
|
|
679
|
+
? ` ${theme.fg("accent", planQuota.plan)} plan · upstream quota`
|
|
680
|
+
: "";
|
|
681
|
+
if (planQuota.session5h) {
|
|
682
|
+
// Session-derived cost/tokens in the same window, combined with the
|
|
683
|
+
// upstream percentage. The right-side text reads:
|
|
684
|
+
// "100% used / $02.12 · 0% left · resets 3m 9s"
|
|
685
|
+
lines.push(this.percentLine("5-hour quota", planQuota.session5h, w, unit, win.fiveHour));
|
|
686
|
+
} else if (planQuota.weekly) {
|
|
687
|
+
// Upstream reports weekly but not 5h — show an explicit line so the
|
|
688
|
+
// row doesn't silently disappear (some plans/plans-in-certain-regions
|
|
689
|
+
// only expose the weekly window).
|
|
690
|
+
lines.push(
|
|
691
|
+
` ${theme.fg("text", "5-hour quota".padEnd(16))} ${theme.fg("dim", "not reported by upstream for this plan")}`,
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
if (planQuota.weekly) {
|
|
695
|
+
lines.push(this.percentLine("Weekly quota", planQuota.weekly, w, unit, win.weekly));
|
|
696
|
+
}
|
|
697
|
+
lines.push(` ${theme.fg("dim", "live from provider")}${planLabel}`);
|
|
698
|
+
if (planQuota.webSearches) {
|
|
699
|
+
const ws = planQuota.webSearches;
|
|
700
|
+
lines.push(
|
|
701
|
+
` ${theme.fg("text", "Web searches")} ${theme.fg("muted", `${ws.used}/${ws.limit}`)}${ws.resetMs ? ` ${theme.fg("dim", `resets ${countdown(ws.resetMs)}`)}` : ""}`,
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
if (planQuota.credits) {
|
|
705
|
+
const c = planQuota.credits;
|
|
706
|
+
const value = c.unlimited ? "unlimited" : `${c.balance} credits`;
|
|
707
|
+
lines.push(` ${theme.fg("text", "Credits")} ${theme.fg("muted", value)}`);
|
|
708
|
+
}
|
|
709
|
+
} else {
|
|
710
|
+
// For subscription providers (OpenAI Codex, ZAI coding plans) the session-derived
|
|
711
|
+
// fallback is misleading — the panel should never suggest `/usage-config` for
|
|
712
|
+
// a subscription, because the real quota comes from the upstream. Show a
|
|
713
|
+
// clear action hint instead so the user knows what to do.
|
|
714
|
+
const isSubscriptionProvider =
|
|
715
|
+
activeProviderName === "openai-codex" ||
|
|
716
|
+
activeProviderName.startsWith("openai-codex-") ||
|
|
717
|
+
activeProviderName === "zai";
|
|
718
|
+
if (isSubscriptionProvider) {
|
|
719
|
+
lines.push(` ${theme.fg("warning", this.buildSubscriptionHint(activeProviderName))}`);
|
|
720
|
+
} else {
|
|
721
|
+
// Fallback: session-derived usage. Unit adapts to the provider (USD for
|
|
722
|
+
// priced providers, tokens for token-priced ones) against a user budget.
|
|
723
|
+
const unitTag = theme.fg("dim", unit === "tokens" ? "(tokens)" : "(USD)");
|
|
724
|
+
lines.push(
|
|
725
|
+
this.quotaLine(
|
|
726
|
+
"5-hour quota",
|
|
727
|
+
win.fiveHour,
|
|
728
|
+
unit === "usd" ? cfg.fiveHourLimit : cfg.fiveHourTokenLimit,
|
|
729
|
+
w,
|
|
730
|
+
unit,
|
|
731
|
+
),
|
|
732
|
+
);
|
|
733
|
+
lines.push(
|
|
734
|
+
this.quotaLine(
|
|
735
|
+
"Weekly quota",
|
|
736
|
+
win.weekly,
|
|
737
|
+
unit === "usd" ? cfg.weeklyLimit : cfg.weeklyTokenLimit,
|
|
738
|
+
w,
|
|
739
|
+
unit,
|
|
740
|
+
),
|
|
741
|
+
);
|
|
742
|
+
lines.push(
|
|
743
|
+
` ${unitTag} ${theme.fg("dim", `session history · set a budget via /usage-config`)}`,
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// ------------------------------------------------------------------ Models
|
|
750
|
+
|
|
751
|
+
private renderModels(lines: string[], w: number): void {
|
|
752
|
+
const report = this.state.report;
|
|
753
|
+
if (!report) return;
|
|
754
|
+
const win = windowize(report, this.state.windowKey, this.deps.maps);
|
|
755
|
+
lines.push(this.subheaderLine(win, w));
|
|
756
|
+
lines.push("");
|
|
757
|
+
|
|
758
|
+
const unit = this.unitForWindow(win);
|
|
759
|
+
// Breakdown sections use the same unit as the quota bars (tokens when
|
|
760
|
+
// the provider has no pricing, USD otherwise).
|
|
761
|
+
const total = unit === "tokens" ? bucketTokens(win.total) : win.total.cost;
|
|
762
|
+
this.appendModelTable(lines, win, total, w, unit);
|
|
763
|
+
this.appendSection(lines, "Skills", win.bySkill, total, w, 8, undefined, unit);
|
|
764
|
+
this.appendSection(lines, "Bundles", win.byBundle, total, w, 8, (k) => k, unit);
|
|
765
|
+
// Plugin usage: ranked plugins with the skills/tools that drove each, plus
|
|
766
|
+
// the “core” remainder (turns that used only builtin tools and no skill).
|
|
767
|
+
this.appendPluginUsageSection(lines, win, total, w, unit);
|
|
768
|
+
this.appendToolsSection(lines, win.byTool, total, w, 8, unit);
|
|
769
|
+
this.appendSection(
|
|
770
|
+
lines,
|
|
771
|
+
"Projects",
|
|
772
|
+
win.byProject,
|
|
773
|
+
total,
|
|
774
|
+
w,
|
|
775
|
+
6,
|
|
776
|
+
(k) => shortenPath(k, this.deps.home),
|
|
777
|
+
unit,
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Models table styled like the Skills section (name · % · bar · value), with
|
|
783
|
+
* an extra column for the average generation speed (estimated tok/s).
|
|
784
|
+
*/
|
|
785
|
+
private appendModelTable(
|
|
786
|
+
lines: string[],
|
|
787
|
+
win: WindowedReport,
|
|
788
|
+
total: number,
|
|
789
|
+
width: number,
|
|
790
|
+
unit: "usd" | "tokens",
|
|
791
|
+
): void {
|
|
792
|
+
const { theme } = this.deps;
|
|
793
|
+
const bucketValue = (b: Bucket) => (unit === "tokens" ? bucketTokens(b) : b.cost);
|
|
794
|
+
const fmt = (n: number) => (unit === "tokens" ? formatTokens(n) : formatCost(n));
|
|
795
|
+
|
|
796
|
+
const rows =
|
|
797
|
+
this.state.sortKey === "name"
|
|
798
|
+
? [...win.byModel.entries()].sort((a, b) => a[0].localeCompare(b[0]))
|
|
799
|
+
: ranked(win.byModel, bucketValue);
|
|
800
|
+
|
|
801
|
+
// Match the Skills/appendSection geometry so both sections line up, plus a
|
|
802
|
+
// fixed-width value column so the tok/s column aligns under its header.
|
|
803
|
+
const labelW = Math.max(16, Math.min(36, Math.floor((width - 30) * 0.6)));
|
|
804
|
+
const barW = Math.max(6, Math.min(20, width - labelW - 26));
|
|
805
|
+
const valueW = 9;
|
|
806
|
+
const unitLabel = unit === "tokens" ? "tokens" : "cost";
|
|
807
|
+
|
|
808
|
+
lines.push(this.tableHeader("Models", labelW, barW, unitLabel, "tok/s", valueW));
|
|
809
|
+
if (rows.length === 0) {
|
|
810
|
+
lines.push(` ${theme.fg("dim", "— none in this window —")}`);
|
|
811
|
+
lines.push("");
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const shown = rows.slice(0, 12);
|
|
816
|
+
for (const [key, b] of shown) {
|
|
817
|
+
const value = bucketValue(b);
|
|
818
|
+
const name = truncateToWidth(key, labelW).padEnd(labelW);
|
|
819
|
+
const pctStr = percent(value, total).padStart(4);
|
|
820
|
+
const ratio = total > 0 ? value / total : 0;
|
|
821
|
+
const filled = Math.max(ratio > 0 ? 1 : 0, Math.round(ratio * barW));
|
|
822
|
+
const barStr =
|
|
823
|
+
theme.fg("accent", "█".repeat(filled)) + theme.fg("borderMuted", "░".repeat(barW - filled));
|
|
824
|
+
const valueStr = fmt(value).padEnd(valueW);
|
|
825
|
+
const tps = tokensPerSecond(b);
|
|
826
|
+
const rate = tps > 0 ? formatRate(tps) : "—";
|
|
827
|
+
lines.push(
|
|
828
|
+
` ${theme.fg("text", name)} ${theme.fg("muted", pctStr)} ${barStr} ${theme.fg("dim", valueStr)} ${theme.fg("success", rate)}`,
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
const rest = rows.length - shown.length;
|
|
832
|
+
if (rest > 0) {
|
|
833
|
+
lines.push(` ${theme.fg("dim", `… +${rest} more`)}`);
|
|
834
|
+
}
|
|
835
|
+
lines.push(` ${theme.fg("dim", "tok/s · est. avg output speed")}`);
|
|
836
|
+
lines.push("");
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// ------------------------------------------------------------ Delegation
|
|
840
|
+
|
|
841
|
+
private renderDelegation(lines: string[], w: number): void {
|
|
842
|
+
const { theme } = this.deps;
|
|
843
|
+
const report = this.state.report;
|
|
844
|
+
if (!report) return;
|
|
845
|
+
const win = windowize(report, this.state.windowKey, this.deps.maps);
|
|
846
|
+
lines.push(this.subheaderLine(win, w));
|
|
847
|
+
lines.push("");
|
|
848
|
+
if (win.children.length === 0 && bucketTokens(win.delegated) === 0) {
|
|
849
|
+
lines.push(` ${theme.fg("muted", "No delegated child sessions in this window.")}`);
|
|
850
|
+
lines.push(
|
|
851
|
+
` ${theme.fg("dim", "Generic tasks/subagents transcripts are discovered automatically.")}`,
|
|
852
|
+
);
|
|
853
|
+
lines.push("");
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
const direct = bucketTokens(win.direct);
|
|
857
|
+
const delegated = bucketTokens(win.delegated);
|
|
858
|
+
const total = bucketTokens(win.total);
|
|
859
|
+
const unit = this.unitForWindow(win);
|
|
860
|
+
const delegatedValue = unit === "usd" ? win.delegated.cost : delegated;
|
|
861
|
+
const cutoff = win.window === "all" ? -1 : Date.now() - this.windowDuration(win.window);
|
|
862
|
+
const selectedTurns = report.entries.filter((turn) => cutoff === -1 || turn.ts >= cutoff);
|
|
863
|
+
lines.push(` ${theme.fg("accent", theme.bold("Composition"))}`);
|
|
864
|
+
if (w < 54) {
|
|
865
|
+
// Narrow terminals: stack so the delegated half is never clamped away.
|
|
866
|
+
lines.push(
|
|
867
|
+
` ${theme.fg("text", `Direct ${formatTokens(direct)} (${percent(direct, total)})`)}`,
|
|
868
|
+
);
|
|
869
|
+
lines.push(
|
|
870
|
+
` ${theme.fg("muted", `Delegated ${formatTokens(delegated)} (${percent(delegated, total)})`)}`,
|
|
871
|
+
);
|
|
872
|
+
} else {
|
|
873
|
+
lines.push(
|
|
874
|
+
` ${theme.fg("text", `Direct ${formatTokens(direct)} (${percent(direct, total)})`)} ${theme.fg("muted", `Delegated ${formatTokens(delegated)} (${percent(delegated, total)})`)}`,
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
if (w >= 64) {
|
|
878
|
+
const detail = `direct in/out/cache ${formatTokens(win.direct.input)}/${formatTokens(win.direct.output)}/${formatTokens(win.direct.cacheRead + win.direct.cacheWrite)} · delegated ${formatTokens(win.delegated.input)}/${formatTokens(win.delegated.output)}/${formatTokens(win.delegated.cacheRead + win.delegated.cacheWrite)}`;
|
|
879
|
+
lines.push(` ${theme.fg("dim", truncateToWidth(detail, Math.max(20, w - 4)))}`);
|
|
880
|
+
}
|
|
881
|
+
lines.push("");
|
|
882
|
+
const c = win.concurrency;
|
|
883
|
+
const pairs: Array<[string, string]> = [
|
|
884
|
+
["Child sessions", `${c.childCount}`],
|
|
885
|
+
["Parents", `${c.parentCount}`],
|
|
886
|
+
// Recorded timing is a plain number; only inferred timing is flagged.
|
|
887
|
+
[
|
|
888
|
+
"Peak concurrency",
|
|
889
|
+
c.peak == null ? "—" : c.inferred ? `${c.peak} (inferred)` : `${c.peak}`,
|
|
890
|
+
],
|
|
891
|
+
["Union wall span", c.unionMs == null ? "—" : formatDuration(c.unionMs)],
|
|
892
|
+
["Summed spans", c.summedMs == null ? "—" : formatDuration(c.summedMs)],
|
|
893
|
+
["Parallelism", c.parallelism == null ? "—" : `${c.parallelism.toFixed(2)}×`],
|
|
894
|
+
[
|
|
895
|
+
"Overlap saved",
|
|
896
|
+
c.overlapSavedMs == null || c.overlapSavedMs <= 0
|
|
897
|
+
? "—"
|
|
898
|
+
: `est. ${formatDuration(c.overlapSavedMs)}`,
|
|
899
|
+
],
|
|
900
|
+
];
|
|
901
|
+
lines.push(` ${theme.fg("accent", theme.bold("Concurrency"))}`);
|
|
902
|
+
this.appendStatGrid(lines, pairs, w);
|
|
903
|
+
lines.push("");
|
|
904
|
+
|
|
905
|
+
const childTurnsBySession = new Map<string, typeof selectedTurns>();
|
|
906
|
+
for (const turn of selectedTurns) {
|
|
907
|
+
if (!turn.delegated) continue;
|
|
908
|
+
const turns = childTurnsBySession.get(turn.sessionId) ?? [];
|
|
909
|
+
turns.push(turn);
|
|
910
|
+
childTurnsBySession.set(turn.sessionId, turns);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const grouped = new Map<string, Bucket>();
|
|
914
|
+
for (const child of win.children) {
|
|
915
|
+
const bucket = grouped.get(child.agentType) ?? this.zeroBucket();
|
|
916
|
+
for (const turn of childTurnsBySession.get(child.id) ?? []) {
|
|
917
|
+
this.addUsageToBucket(bucket, turn.usage);
|
|
918
|
+
}
|
|
919
|
+
grouped.set(child.agentType, bucket);
|
|
920
|
+
}
|
|
921
|
+
this.appendSection(lines, "Agent / profile", grouped, delegatedValue, w, 8, undefined, unit);
|
|
922
|
+
const parentGroups = new Map<string, Bucket>();
|
|
923
|
+
for (const turn of selectedTurns) {
|
|
924
|
+
if (!turn.delegated) continue;
|
|
925
|
+
const label =
|
|
926
|
+
win.children.find((child) => child.id === turn.sessionId)?.parentLabel ||
|
|
927
|
+
turn.parentSessionId ||
|
|
928
|
+
"(unknown)";
|
|
929
|
+
const bucket = parentGroups.get(label) ?? this.zeroBucket();
|
|
930
|
+
this.addUsageToBucket(bucket, turn.usage);
|
|
931
|
+
parentGroups.set(label, bucket);
|
|
932
|
+
}
|
|
933
|
+
this.appendSection(lines, "Parents", parentGroups, delegatedValue, w, 8, undefined, unit);
|
|
934
|
+
lines.push(` ${theme.fg("accent", theme.bold("Child sessions"))}`);
|
|
935
|
+
if (win.children.length === 0) {
|
|
936
|
+
lines.push(` ${theme.fg("dim", "— none in this window —")}`);
|
|
937
|
+
lines.push("");
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
const childRow = (child: (typeof win.children)[number]) => {
|
|
941
|
+
const childTurns = childTurnsBySession.get(child.id) ?? [];
|
|
942
|
+
const tokens = childTurns.reduce(
|
|
943
|
+
(sum, turn) =>
|
|
944
|
+
sum + turn.usage.input + turn.usage.output + turn.usage.cacheRead + turn.usage.cacheWrite,
|
|
945
|
+
0,
|
|
946
|
+
);
|
|
947
|
+
const cost = childTurns.reduce((sum, turn) => sum + turn.usage.cost.total, 0);
|
|
948
|
+
const value = unit === "usd" ? formatCost(cost) : formatTokens(tokens);
|
|
949
|
+
const duration =
|
|
950
|
+
child.endedAt > child.startedAt ? formatDuration(child.endedAt - child.startedAt) : "—";
|
|
951
|
+
const mode = child.isBackground == null ? "" : child.isBackground ? " bg" : " fg";
|
|
952
|
+
return { value, duration, status: `${child.status}${mode}` };
|
|
953
|
+
};
|
|
954
|
+
if (w < 54) {
|
|
955
|
+
// Narrow terminals: two-line rows keep the task name readable.
|
|
956
|
+
for (const child of win.children.slice(0, 20)) {
|
|
957
|
+
const row = childRow(child);
|
|
958
|
+
lines.push(` ${theme.fg("text", truncateToWidth(child.task, Math.max(10, w - 4)))}`);
|
|
959
|
+
lines.push(
|
|
960
|
+
` ${theme.fg("dim", `${row.value} · ${row.duration} · `)}${theme.fg("success", row.status)}`,
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
} else {
|
|
964
|
+
const taskW = Math.max(6, w - 43);
|
|
965
|
+
lines.push(
|
|
966
|
+
` ${" ".repeat(taskW)} ${theme.fg("dim", "tokens".padStart(8))} ${theme.fg("dim", "span".padStart(7))} ${theme.fg("dim", "status")}`,
|
|
967
|
+
);
|
|
968
|
+
for (const child of win.children.slice(0, 20)) {
|
|
969
|
+
const row = childRow(child);
|
|
970
|
+
lines.push(
|
|
971
|
+
` ${theme.fg("text", truncateToWidth(child.task, taskW).padEnd(taskW))} ${theme.fg("muted", truncateToWidth(row.value, 8).padStart(8))} ${theme.fg("dim", truncateToWidth(row.duration, 7).padStart(7))} ${theme.fg("success", truncateToWidth(row.status, 12))}`,
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
if (win.children.length > 20)
|
|
976
|
+
lines.push(` ${theme.fg("dim", `… +${win.children.length - 20} more`)}`);
|
|
977
|
+
lines.push("");
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
private windowDuration(key: WindowKey): number {
|
|
981
|
+
return key === "5h"
|
|
982
|
+
? 5 * 60 * 60 * 1000
|
|
983
|
+
: key === "24h"
|
|
984
|
+
? 24 * 60 * 60 * 1000
|
|
985
|
+
: key === "7d"
|
|
986
|
+
? 7 * 24 * 60 * 60 * 1000
|
|
987
|
+
: Number.POSITIVE_INFINITY;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
private zeroBucket(): Bucket {
|
|
991
|
+
return {
|
|
992
|
+
cost: 0,
|
|
993
|
+
costInput: 0,
|
|
994
|
+
costOutput: 0,
|
|
995
|
+
costCacheRead: 0,
|
|
996
|
+
costCacheWrite: 0,
|
|
997
|
+
input: 0,
|
|
998
|
+
output: 0,
|
|
999
|
+
cacheRead: 0,
|
|
1000
|
+
cacheWrite: 0,
|
|
1001
|
+
cacheWrite1h: 0,
|
|
1002
|
+
reasoning: 0,
|
|
1003
|
+
turns: 0,
|
|
1004
|
+
genMs: 0,
|
|
1005
|
+
timedTurns: 0,
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
private addUsageToBucket(bucket: Bucket, usage: import("@earendil-works/pi-ai").Usage): void {
|
|
1010
|
+
bucket.cost += usage.cost.total;
|
|
1011
|
+
bucket.costInput += usage.cost.input ?? 0;
|
|
1012
|
+
bucket.costOutput += usage.cost.output ?? 0;
|
|
1013
|
+
bucket.costCacheRead += usage.cost.cacheRead ?? 0;
|
|
1014
|
+
bucket.costCacheWrite += usage.cost.cacheWrite ?? 0;
|
|
1015
|
+
bucket.input += usage.input;
|
|
1016
|
+
bucket.output += usage.output;
|
|
1017
|
+
bucket.cacheRead += usage.cacheRead;
|
|
1018
|
+
bucket.cacheWrite += usage.cacheWrite;
|
|
1019
|
+
bucket.cacheWrite1h += usage.cacheWrite1h ?? 0;
|
|
1020
|
+
bucket.reasoning += usage.reasoning ?? 0;
|
|
1021
|
+
bucket.turns += 1;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// ------------------------------------------------------------ Daily Summary
|
|
1025
|
+
|
|
1026
|
+
private renderDaily(lines: string[], w: number): void {
|
|
1027
|
+
const { theme } = this.deps;
|
|
1028
|
+
const report = this.state.report;
|
|
1029
|
+
if (!report) return;
|
|
1030
|
+
|
|
1031
|
+
const days = dailyStats(report);
|
|
1032
|
+
|
|
1033
|
+
// Totals across all active days (uptime / tokens / cost).
|
|
1034
|
+
let totalCost = 0;
|
|
1035
|
+
let totalTokens = 0;
|
|
1036
|
+
let totalUptime = 0;
|
|
1037
|
+
for (const d of days) {
|
|
1038
|
+
totalCost += d.bucket.cost;
|
|
1039
|
+
totalTokens += bucketTokens(d.bucket);
|
|
1040
|
+
totalUptime += dayUptimeMs(d);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
const field = this.state.dailySortField;
|
|
1044
|
+
const dir = this.state.dailySortDir;
|
|
1045
|
+
const arrow = dir === "asc" ? "↑" : "↓";
|
|
1046
|
+
lines.push(
|
|
1047
|
+
` ${theme.fg("muted", "Daily")} ${theme.fg("dim", `${days.length} active days · all time · sort: ${field} ${arrow}`)}`,
|
|
1048
|
+
);
|
|
1049
|
+
if (days.length > 0) {
|
|
1050
|
+
const totalCostStr =
|
|
1051
|
+
totalCost > 0
|
|
1052
|
+
? ` ${theme.fg("dim", "·")} ${theme.fg("dim", "cost")} ${theme.fg("success", formatCost(totalCost))}`
|
|
1053
|
+
: "";
|
|
1054
|
+
lines.push(
|
|
1055
|
+
` ${theme.fg("dim", "uptime")} ${theme.fg("text", formatDuration(totalUptime))}` +
|
|
1056
|
+
` ${theme.fg("dim", "·")} ${theme.fg("dim", "tokens")} ${theme.fg("text", formatTokens(totalTokens))}` +
|
|
1057
|
+
totalCostStr,
|
|
1058
|
+
);
|
|
1059
|
+
}
|
|
1060
|
+
lines.push("");
|
|
1061
|
+
|
|
1062
|
+
if (days.length === 0) {
|
|
1063
|
+
lines.push(` ${theme.fg("dim", "— no activity recorded —")}`);
|
|
1064
|
+
lines.push("");
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// The bar always tracks tokens (activity): most models are token-priced
|
|
1069
|
+
// (cost 0), so a cost-based bar would collapse to empty.
|
|
1070
|
+
const dayTokens = (d: (typeof days)[number]) => bucketTokens(d.bucket);
|
|
1071
|
+
const maxVal = days.reduce((m, d) => Math.max(m, dayTokens(d)), 0);
|
|
1072
|
+
|
|
1073
|
+
// Sort by the chosen field + direction.
|
|
1074
|
+
const sortValue = (d: (typeof days)[number]) =>
|
|
1075
|
+
field === "cost" ? d.bucket.cost : field === "date" ? d.ts : dayTokens(d);
|
|
1076
|
+
const sign = dir === "asc" ? 1 : -1;
|
|
1077
|
+
const sorted = [...days].sort((a, b) => sign * (sortValue(a) - sortValue(b)));
|
|
1078
|
+
|
|
1079
|
+
// Column geometry. The bar is the "graph"; numeric columns give the
|
|
1080
|
+
// exact cost / tokens / uptime, and the last column names the day's top
|
|
1081
|
+
// model (the model that drove most of that day's spend).
|
|
1082
|
+
const labelW = 14;
|
|
1083
|
+
const costW = 8;
|
|
1084
|
+
const tokW = 9;
|
|
1085
|
+
const upW = 7;
|
|
1086
|
+
const fixed = 2 + labelW + 1 + 1 + costW + 1 + tokW + 1 + upW + 1;
|
|
1087
|
+
const barW = Math.max(6, Math.min(12, w - fixed - 10));
|
|
1088
|
+
const modelW = w - fixed - barW;
|
|
1089
|
+
const showModel = modelW >= 8;
|
|
1090
|
+
|
|
1091
|
+
// Aligned header.
|
|
1092
|
+
let header = ` ${theme.fg("accent", theme.bold("Day".padEnd(labelW)))} ${" ".repeat(barW)}`;
|
|
1093
|
+
header += ` ${theme.fg("dim", "cost".padStart(costW))}`;
|
|
1094
|
+
header += ` ${theme.fg("dim", "tokens".padStart(tokW))}`;
|
|
1095
|
+
header += ` ${theme.fg("dim", "uptime".padStart(upW))}`;
|
|
1096
|
+
if (showModel) header += ` ${theme.fg("dim", "top model")}`;
|
|
1097
|
+
lines.push(header);
|
|
1098
|
+
|
|
1099
|
+
for (const d of sorted.slice(0, 60)) {
|
|
1100
|
+
const value = dayTokens(d);
|
|
1101
|
+
const ratio = maxVal > 0 ? value / maxVal : 0;
|
|
1102
|
+
const filled = Math.max(value > 0 ? 1 : 0, Math.round(ratio * barW));
|
|
1103
|
+
const barColor: ThemeColor = ratio > 0.66 ? "accent" : ratio > 0.33 ? "success" : "warning";
|
|
1104
|
+
const barStr =
|
|
1105
|
+
theme.fg(barColor, "█".repeat(filled)) + theme.fg("borderMuted", "░".repeat(barW - filled));
|
|
1106
|
+
const label = truncateToWidth(formatDayLabel(d.dateKey), labelW).padEnd(labelW);
|
|
1107
|
+
// Cost is "—" when the day's models are token-priced (no pricing → 0),
|
|
1108
|
+
// so the column doesn't read as a broken row of $0.00.
|
|
1109
|
+
const cost = (d.bucket.cost > 0 ? formatCost(d.bucket.cost) : "—").padStart(costW);
|
|
1110
|
+
const tokens = formatTokens(bucketTokens(d.bucket)).padStart(tokW);
|
|
1111
|
+
const uptime = formatDuration(dayUptimeMs(d)).padStart(upW);
|
|
1112
|
+
const costCell = d.bucket.cost > 0 ? theme.fg("success", cost) : theme.fg("dim", cost);
|
|
1113
|
+
let line =
|
|
1114
|
+
` ${theme.fg("text", label)} ${barStr}` +
|
|
1115
|
+
` ${costCell} ${theme.fg("muted", tokens)} ${theme.fg("dim", uptime)}`;
|
|
1116
|
+
if (showModel) {
|
|
1117
|
+
const top = dayTopModel(d) ?? "—";
|
|
1118
|
+
const m = truncateToWidth(top, modelW - 1);
|
|
1119
|
+
line += ` ${theme.fg("accent", m)}`;
|
|
1120
|
+
}
|
|
1121
|
+
lines.push(line);
|
|
1122
|
+
}
|
|
1123
|
+
if (sorted.length > 60) {
|
|
1124
|
+
lines.push(` ${theme.fg("dim", `… +${sorted.length - 60} more days`)}`);
|
|
1125
|
+
}
|
|
1126
|
+
lines.push("");
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// ---------------------------------------------------------------- Hourly
|
|
1130
|
+
|
|
1131
|
+
private renderHourly(lines: string[], w: number): void {
|
|
1132
|
+
const { theme } = this.deps;
|
|
1133
|
+
const report = this.state.report;
|
|
1134
|
+
if (!report) return;
|
|
1135
|
+
|
|
1136
|
+
const hours = hourlyStats(report);
|
|
1137
|
+
let totalTokens = 0;
|
|
1138
|
+
let totalTurns = 0;
|
|
1139
|
+
let activeHours = 0;
|
|
1140
|
+
for (const h of hours) {
|
|
1141
|
+
const tok = bucketTokens(h.bucket);
|
|
1142
|
+
totalTokens += tok;
|
|
1143
|
+
totalTurns += h.bucket.turns;
|
|
1144
|
+
if (tok > 0) activeHours += 1;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
lines.push(
|
|
1148
|
+
` ${theme.fg("muted", "Hourly")} ${theme.fg("dim", "by time of day · all days combined")}`,
|
|
1149
|
+
);
|
|
1150
|
+
if (totalTurns > 0) {
|
|
1151
|
+
lines.push(
|
|
1152
|
+
` ${theme.fg("dim", "turns")} ${theme.fg("text", formatInt(totalTurns))}` +
|
|
1153
|
+
` ${theme.fg("dim", "·")} ${theme.fg("dim", "tokens")} ${theme.fg("text", formatTokens(totalTokens))}` +
|
|
1154
|
+
` ${theme.fg("dim", "·")} ${theme.fg("dim", "active hours")} ${theme.fg("text", `${activeHours}/24`)}`,
|
|
1155
|
+
);
|
|
1156
|
+
}
|
|
1157
|
+
lines.push("");
|
|
1158
|
+
|
|
1159
|
+
if (totalTurns === 0) {
|
|
1160
|
+
lines.push(` ${theme.fg("dim", "— no activity recorded —")}`);
|
|
1161
|
+
lines.push("");
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
const maxVal = hours.reduce((m, h) => Math.max(m, bucketTokens(h.bucket)), 0);
|
|
1166
|
+
const labelW = 6;
|
|
1167
|
+
const tokW = 9;
|
|
1168
|
+
const turnW = 6;
|
|
1169
|
+
const fixed = 2 + labelW + 1 + 1 + tokW + 1 + turnW + 1;
|
|
1170
|
+
const barW = Math.max(8, Math.min(24, w - fixed - 14));
|
|
1171
|
+
const modelW = Math.max(0, w - fixed - barW);
|
|
1172
|
+
const showModel = modelW >= 10;
|
|
1173
|
+
|
|
1174
|
+
let header = ` ${theme.fg("accent", theme.bold("Hour".padEnd(labelW)))} ${" ".repeat(barW)}`;
|
|
1175
|
+
header += ` ${theme.fg("dim", "tokens".padStart(tokW))}`;
|
|
1176
|
+
header += ` ${theme.fg("dim", "turns".padStart(turnW))}`;
|
|
1177
|
+
if (showModel) header += ` ${theme.fg("dim", "top model")}`;
|
|
1178
|
+
lines.push(header);
|
|
1179
|
+
|
|
1180
|
+
for (const h of hours) {
|
|
1181
|
+
const value = bucketTokens(h.bucket);
|
|
1182
|
+
const ratio = maxVal > 0 ? value / maxVal : 0;
|
|
1183
|
+
const filled = Math.max(value > 0 ? 1 : 0, Math.round(ratio * barW));
|
|
1184
|
+
const barColor: ThemeColor =
|
|
1185
|
+
ratio > 0.66 ? "accent" : ratio > 0.33 ? "success" : value > 0 ? "warning" : "borderMuted";
|
|
1186
|
+
const barStr =
|
|
1187
|
+
value > 0
|
|
1188
|
+
? theme.fg(barColor, "█".repeat(filled)) +
|
|
1189
|
+
theme.fg("borderMuted", "░".repeat(barW - filled))
|
|
1190
|
+
: theme.fg("borderMuted", "·".repeat(barW));
|
|
1191
|
+
const label = formatHour(h.hour).padEnd(labelW);
|
|
1192
|
+
const tokens = (value > 0 ? formatTokens(value) : "—").padStart(tokW);
|
|
1193
|
+
const turns = (h.bucket.turns > 0 ? formatInt(h.bucket.turns) : "—").padStart(turnW);
|
|
1194
|
+
let line =
|
|
1195
|
+
` ${theme.fg(value > 0 ? "text" : "dim", label)} ${barStr}` +
|
|
1196
|
+
` ${theme.fg(value > 0 ? "text" : "dim", tokens)}` +
|
|
1197
|
+
` ${theme.fg("dim", turns)}`;
|
|
1198
|
+
if (showModel) {
|
|
1199
|
+
const top = hourTopModel(h);
|
|
1200
|
+
const modelCell = top ? truncateToWidth(top, modelW) : theme.fg("dim", "—");
|
|
1201
|
+
line += ` ${theme.fg("muted", modelCell)}`;
|
|
1202
|
+
}
|
|
1203
|
+
lines.push(line);
|
|
1204
|
+
}
|
|
1205
|
+
lines.push("");
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// -------------------------------------------------------------- Providers
|
|
1209
|
+
|
|
1210
|
+
private renderProviders(lines: string[], w: number): void {
|
|
1211
|
+
const { theme } = this.deps;
|
|
1212
|
+
const report = this.state.report;
|
|
1213
|
+
if (!report) return;
|
|
1214
|
+
|
|
1215
|
+
const agents = agentStats(report);
|
|
1216
|
+
let totalTokens = 0;
|
|
1217
|
+
let totalCost = 0;
|
|
1218
|
+
for (const a of agents) {
|
|
1219
|
+
totalTokens += bucketTokens(a.bucket);
|
|
1220
|
+
totalCost += a.bucket.cost;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
const sortLabel = this.state.agentSortKey === "name" ? "name" : "usage";
|
|
1224
|
+
lines.push(
|
|
1225
|
+
` ${theme.fg("muted", "Providers")} ${theme.fg("dim", `${agents.length} providers · sort: ${sortLabel}`)}`,
|
|
1226
|
+
);
|
|
1227
|
+
if (agents.length > 0) {
|
|
1228
|
+
const costStr =
|
|
1229
|
+
totalCost > 0
|
|
1230
|
+
? ` ${theme.fg("dim", "·")} ${theme.fg("dim", "cost")} ${theme.fg("success", formatCost(totalCost))}`
|
|
1231
|
+
: "";
|
|
1232
|
+
lines.push(
|
|
1233
|
+
` ${theme.fg("dim", "tokens")} ${theme.fg("text", formatTokens(totalTokens))}${costStr}`,
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
lines.push("");
|
|
1237
|
+
|
|
1238
|
+
if (agents.length === 0) {
|
|
1239
|
+
lines.push(` ${theme.fg("dim", "— no providers recorded —")}`);
|
|
1240
|
+
lines.push("");
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
const rows =
|
|
1245
|
+
this.state.agentSortKey === "name"
|
|
1246
|
+
? [...agents].sort((a, b) => a.provider.localeCompare(b.provider))
|
|
1247
|
+
: agents;
|
|
1248
|
+
|
|
1249
|
+
// The proj column is the first casualty of a narrow terminal; drop it
|
|
1250
|
+
// below 48 columns so the tokens column stays intact.
|
|
1251
|
+
const showProj = w >= 48;
|
|
1252
|
+
const labelW = showProj
|
|
1253
|
+
? Math.max(14, Math.min(28, Math.floor((w - 36) * 0.45)))
|
|
1254
|
+
: Math.max(12, Math.min(28, Math.floor((w - 30) * 0.45)));
|
|
1255
|
+
const barW = Math.max(6, Math.min(18, w - labelW - (showProj ? 28 : 22)));
|
|
1256
|
+
const tokW = 9;
|
|
1257
|
+
const projW = 5;
|
|
1258
|
+
|
|
1259
|
+
lines.push(
|
|
1260
|
+
showProj
|
|
1261
|
+
? this.tableHeader("Provider", labelW, barW, "tokens", "proj", tokW)
|
|
1262
|
+
: this.tableHeader("Provider", labelW, barW, "tokens"),
|
|
1263
|
+
);
|
|
1264
|
+
|
|
1265
|
+
for (const a of rows.slice(0, 16)) {
|
|
1266
|
+
const value = bucketTokens(a.bucket);
|
|
1267
|
+
const name = truncateToWidth(a.provider, labelW).padEnd(labelW);
|
|
1268
|
+
const pctStr = percent(value, totalTokens).padStart(4);
|
|
1269
|
+
const ratio = totalTokens > 0 ? value / totalTokens : 0;
|
|
1270
|
+
const filled = Math.max(ratio > 0 ? 1 : 0, Math.round(ratio * barW));
|
|
1271
|
+
const barStr =
|
|
1272
|
+
theme.fg("accent", "█".repeat(filled)) + theme.fg("borderMuted", "░".repeat(barW - filled));
|
|
1273
|
+
const tokStr = showProj ? formatTokens(value).padEnd(tokW) : formatTokens(value);
|
|
1274
|
+
const top = agentTopModel(a);
|
|
1275
|
+
const projCell = showProj
|
|
1276
|
+
? ` ${theme.fg("success", formatInt(a.projects.size).padStart(projW))}`
|
|
1277
|
+
: "";
|
|
1278
|
+
lines.push(
|
|
1279
|
+
` ${theme.fg("text", name)} ${theme.fg("muted", pctStr)} ${barStr} ${theme.fg("dim", tokStr)}${projCell}`,
|
|
1280
|
+
);
|
|
1281
|
+
if (top && w >= labelW + barW + 40) {
|
|
1282
|
+
lines.push(
|
|
1283
|
+
` ${" ".repeat(labelW + barW + 8)}${theme.fg("dim", `↳ ${truncateToWidth(top, w - labelW - barW - 12)}`)}`,
|
|
1284
|
+
);
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
if (rows.length > 16) {
|
|
1288
|
+
lines.push(` ${theme.fg("dim", `… +${rows.length - 16} more providers`)}`);
|
|
1289
|
+
}
|
|
1290
|
+
if (showProj) {
|
|
1291
|
+
lines.push(` ${theme.fg("dim", "proj · distinct project paths per provider")}`);
|
|
1292
|
+
}
|
|
1293
|
+
lines.push("");
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// ----------------------------------------------------------- Wrapped AI
|
|
1297
|
+
|
|
1298
|
+
private renderWrapped(lines: string[], w: number): void {
|
|
1299
|
+
const { theme } = this.deps;
|
|
1300
|
+
const report = this.state.report;
|
|
1301
|
+
if (!report) return;
|
|
1302
|
+
|
|
1303
|
+
const year = this.state.wrappedYear;
|
|
1304
|
+
const stats = wrappedStats(report, year);
|
|
1305
|
+
const years = availableYears(report);
|
|
1306
|
+
const pose = mascotPose("wrapped", stats);
|
|
1307
|
+
const mascot = renderWrappedMascot(pose, theme);
|
|
1308
|
+
|
|
1309
|
+
lines.push(this.wrappedBannerLine(years, w));
|
|
1310
|
+
lines.push("");
|
|
1311
|
+
|
|
1312
|
+
if (!stats) {
|
|
1313
|
+
this.appendMascotBlock(lines, mascot, w, (content) => {
|
|
1314
|
+
content.push(` ${theme.fg("muted", "No activity recorded")}`);
|
|
1315
|
+
content.push(` ${theme.fg("dim", `Nothing to summarize for ${year}.`)}`);
|
|
1316
|
+
if (years.length > 0) {
|
|
1317
|
+
const instruction = this.portableRendering
|
|
1318
|
+
? "select a year from the action menu"
|
|
1319
|
+
: "[ ] to switch";
|
|
1320
|
+
content.push(
|
|
1321
|
+
` ${theme.fg("dim", `Available: ${years.map(String).join(", ")} · ${instruction}`)}`,
|
|
1322
|
+
);
|
|
1323
|
+
}
|
|
1324
|
+
});
|
|
1325
|
+
lines.push("");
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
this.appendMascotBlock(lines, mascot, w, (content) => {
|
|
1330
|
+
this.appendWrappedHero(content, stats, w);
|
|
1331
|
+
});
|
|
1332
|
+
lines.push("");
|
|
1333
|
+
lines.push(this.wrappedSectionHeader("Highlights", w));
|
|
1334
|
+
this.appendWrappedHighlights(lines, stats, w);
|
|
1335
|
+
lines.push("");
|
|
1336
|
+
lines.push(this.wrappedSectionHeader("Monthly activity", w));
|
|
1337
|
+
this.appendWrappedMonthly(lines, stats, w);
|
|
1338
|
+
lines.push("");
|
|
1339
|
+
lines.push(this.wrappedSectionHeader("Rankings", w));
|
|
1340
|
+
this.appendWrappedTops(lines, stats, w);
|
|
1341
|
+
lines.push("");
|
|
1342
|
+
const caption = wrappedMascotCaption(stats, year);
|
|
1343
|
+
lines.push(this.wrappedInsightBox(caption, pose, w));
|
|
1344
|
+
lines.push("");
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/** Hairline section label — matches Stats/Daily report rhythm. */
|
|
1348
|
+
private wrappedSectionHeader(title: string, width: number): string {
|
|
1349
|
+
const { theme } = this.deps;
|
|
1350
|
+
const label = ` ${theme.fg("muted", title)} `;
|
|
1351
|
+
const ruleW = Math.max(4, width - visibleWidth(label) - 2);
|
|
1352
|
+
return `${label}${theme.fg("borderMuted", "─".repeat(ruleW))}`;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/** Pi-chan footer card — character accent, professional tone. */
|
|
1356
|
+
private wrappedInsightBox(
|
|
1357
|
+
caption: string,
|
|
1358
|
+
pose: ReturnType<typeof mascotPose>,
|
|
1359
|
+
width: number,
|
|
1360
|
+
): string {
|
|
1361
|
+
const { theme } = this.deps;
|
|
1362
|
+
const face = renderWrappedMascot(pose, theme)[1] ?? "";
|
|
1363
|
+
const tag = theme.fg("accent", "Pi-chan");
|
|
1364
|
+
const body = theme.fg("text", truncateToWidth(caption, Math.max(20, width - 24)));
|
|
1365
|
+
const inner = `${tag} ${body}`;
|
|
1366
|
+
const pad = Math.max(0, width - visibleWidth(face) - visibleWidth(inner) - 6);
|
|
1367
|
+
return ` ${face} ${theme.fg("borderMuted", "│")} ${inner}${" ".repeat(pad)}`;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
/** Side-by-side mascot + content when the terminal is wide enough. */
|
|
1371
|
+
private appendMascotBlock(
|
|
1372
|
+
lines: string[],
|
|
1373
|
+
mascot: string[],
|
|
1374
|
+
width: number,
|
|
1375
|
+
renderContent: (content: string[]) => void,
|
|
1376
|
+
): void {
|
|
1377
|
+
const { theme } = this.deps;
|
|
1378
|
+
const content: string[] = [];
|
|
1379
|
+
renderContent(content);
|
|
1380
|
+
const mascotW = 14;
|
|
1381
|
+
const gap = 2;
|
|
1382
|
+
const sideBySide = width >= 72 && mascot.length > 0;
|
|
1383
|
+
|
|
1384
|
+
if (!sideBySide) {
|
|
1385
|
+
for (const m of mascot) lines.push(m);
|
|
1386
|
+
lines.push("");
|
|
1387
|
+
lines.push(...content);
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
const rule = theme.fg("borderMuted", "│");
|
|
1392
|
+
const rows = Math.max(mascot.length, content.length);
|
|
1393
|
+
for (let i = 0; i < rows; i++) {
|
|
1394
|
+
const left = (mascot[i] ?? "").padEnd(mascotW);
|
|
1395
|
+
const right = content[i] ?? "";
|
|
1396
|
+
if (right) {
|
|
1397
|
+
lines.push(`${left}${rule}${" ".repeat(gap)}${right}`);
|
|
1398
|
+
} else if (left.trim()) {
|
|
1399
|
+
lines.push(left);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
private wrappedBannerLine(years: number[], width: number): string {
|
|
1405
|
+
const { theme } = this.deps;
|
|
1406
|
+
const year = this.state.wrappedYear;
|
|
1407
|
+
const title = theme.fg("accent", theme.bold(" Wrapped "));
|
|
1408
|
+
const yearBadge = theme.bg("selectedBg", theme.fg("text", theme.bold(` ${year} `)));
|
|
1409
|
+
const nav =
|
|
1410
|
+
years.length > 1
|
|
1411
|
+
? theme.fg("dim", this.portableRendering ? " choose year below" : " ◂ [ ] ▸ · y")
|
|
1412
|
+
: theme.fg("dim", " single year");
|
|
1413
|
+
const left = ` ${title}${yearBadge}${nav}`;
|
|
1414
|
+
const pad = Math.max(0, width - visibleWidth(left) - 2);
|
|
1415
|
+
return `${left}${theme.fg("borderMuted", "─".repeat(Math.max(2, pad)))}`;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
private appendWrappedHero(lines: string[], stats: WrappedStats, w: number): void {
|
|
1419
|
+
const { theme } = this.deps;
|
|
1420
|
+
const headline =
|
|
1421
|
+
stats.metric === "tokens" ? formatTokens(stats.totalTokens) : formatCost(stats.totalCost);
|
|
1422
|
+
const unit = stats.metric === "tokens" ? "tokens" : "estimated spend";
|
|
1423
|
+
lines.push(` ${theme.fg("text", theme.bold(headline))} ${theme.fg("muted", unit)}`);
|
|
1424
|
+
const kpis = [
|
|
1425
|
+
`${formatInt(stats.totalTurns)} turns`,
|
|
1426
|
+
`${stats.activeDays} active days`,
|
|
1427
|
+
`${stats.modelCount} models`,
|
|
1428
|
+
`${stats.providerCount} providers`,
|
|
1429
|
+
];
|
|
1430
|
+
lines.push(` ${kpis.map((k) => theme.fg("dim", k)).join(theme.fg("borderMuted", " · "))}`);
|
|
1431
|
+
lines.push("");
|
|
1432
|
+
|
|
1433
|
+
const pairs: Array<[string, string]> = [
|
|
1434
|
+
["Favorite model", stats.favoriteModel ?? "—"],
|
|
1435
|
+
["Top provider", stats.favoriteProvider ?? "—"],
|
|
1436
|
+
["Busiest day", stats.busiestDay ? formatDayLabel(stats.busiestDay.dateKey) : "—"],
|
|
1437
|
+
["Peak hour", stats.peakHour != null ? formatHour(stats.peakHour) : "—"],
|
|
1438
|
+
["Longest streak", `${stats.longestStreak} day${stats.longestStreak === 1 ? "" : "s"}`],
|
|
1439
|
+
[
|
|
1440
|
+
"Avg / active day",
|
|
1441
|
+
stats.metric === "tokens"
|
|
1442
|
+
? formatTokens(Math.round(stats.avgPerActiveDay))
|
|
1443
|
+
: formatCost(stats.avgPerActiveDay),
|
|
1444
|
+
],
|
|
1445
|
+
];
|
|
1446
|
+
this.appendStatGrid(lines, pairs, w);
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
private appendWrappedMonthly(lines: string[], stats: WrappedStats, w: number): void {
|
|
1450
|
+
if (w < 54) {
|
|
1451
|
+
this.appendWrappedMonthlyRows(lines, stats, w);
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
this.appendWrappedMonthlyHeatmap(lines, stats, w);
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/**
|
|
1458
|
+
* Claude Code / Stats-style vertical month columns: graded blocks, month
|
|
1459
|
+
* labels, Less→More legend, and a peak-month callout.
|
|
1460
|
+
*/
|
|
1461
|
+
private appendWrappedMonthlyHeatmap(lines: string[], stats: WrappedStats, width: number): void {
|
|
1462
|
+
const { theme } = this.deps;
|
|
1463
|
+
const values = stats.monthlyTokens;
|
|
1464
|
+
const max = Math.max(...values, 1);
|
|
1465
|
+
const colW = width >= 68 ? 3 : 2;
|
|
1466
|
+
const gap = colW === 3 ? " " : "";
|
|
1467
|
+
const barH = width >= 68 ? 5 : 4;
|
|
1468
|
+
const block = "█".repeat(colW);
|
|
1469
|
+
const empty = "·".repeat(colW);
|
|
1470
|
+
|
|
1471
|
+
const unitLabel = "tokens by month";
|
|
1472
|
+
lines.push(` ${theme.fg("dim", unitLabel)}`);
|
|
1473
|
+
|
|
1474
|
+
const levels = values.map((v) => this.heatmapLevel(v / max));
|
|
1475
|
+
const filledRows = values.map((v) => (v > 0 ? Math.max(1, Math.round((v / max) * barH)) : 0));
|
|
1476
|
+
|
|
1477
|
+
for (let row = 0; row < barH; row++) {
|
|
1478
|
+
let line = " ";
|
|
1479
|
+
for (let m = 0; m < 12; m++) {
|
|
1480
|
+
const rowFromBottom = barH - 1 - row;
|
|
1481
|
+
if (filledRows[m] > 0 && rowFromBottom < filledRows[m]) {
|
|
1482
|
+
line += theme.fg(this.heatmapColor(levels[m]), block);
|
|
1483
|
+
} else {
|
|
1484
|
+
line += theme.fg("borderMuted", empty);
|
|
1485
|
+
}
|
|
1486
|
+
if (m < 11) line += gap;
|
|
1487
|
+
}
|
|
1488
|
+
lines.push(line);
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
let labelLine = " ";
|
|
1492
|
+
for (let m = 0; m < 12; m++) {
|
|
1493
|
+
const lab = colW >= 3 ? monthLabel(m + 1).slice(0, 3) : monthLabel(m + 1).slice(0, 1);
|
|
1494
|
+
labelLine += theme.fg("dim", lab.padEnd(colW));
|
|
1495
|
+
if (m < 11) labelLine += gap;
|
|
1496
|
+
}
|
|
1497
|
+
lines.push(labelLine);
|
|
1498
|
+
|
|
1499
|
+
let legend = ` ${theme.fg("dim", "Less ")}`;
|
|
1500
|
+
legend += theme.fg("borderMuted", empty);
|
|
1501
|
+
for (let l = 1; l < 5; l++) {
|
|
1502
|
+
legend += theme.fg(this.heatmapColor(l), block);
|
|
1503
|
+
}
|
|
1504
|
+
legend += theme.fg("dim", " More");
|
|
1505
|
+
lines.push(legend);
|
|
1506
|
+
|
|
1507
|
+
const activeMonths = values.filter((v) => v > 0).length;
|
|
1508
|
+
let peakIdx = 0;
|
|
1509
|
+
let peakVal = 0;
|
|
1510
|
+
for (let i = 0; i < 12; i++) {
|
|
1511
|
+
if (values[i] > peakVal) {
|
|
1512
|
+
peakVal = values[i];
|
|
1513
|
+
peakIdx = i;
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
if (peakVal > 0) {
|
|
1517
|
+
lines.push(
|
|
1518
|
+
` ${theme.fg("muted", "Peak month")} ${theme.fg("text", monthLabel(peakIdx + 1))}` +
|
|
1519
|
+
` ${theme.fg("dim", formatTokens(peakVal))}` +
|
|
1520
|
+
` ${theme.fg("borderMuted", "·")} ${theme.fg("dim", `${activeMonths} active month${activeMonths === 1 ? "" : "s"}`)}`,
|
|
1521
|
+
);
|
|
1522
|
+
} else {
|
|
1523
|
+
lines.push(
|
|
1524
|
+
` ${theme.fg("dim", `${activeMonths} active month${activeMonths === 1 ? "" : "s"}`)}`,
|
|
1525
|
+
);
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/** Narrow-terminal fallback: horizontal share bars (same geometry as Rankings). */
|
|
1530
|
+
private appendWrappedMonthlyRows(lines: string[], stats: WrappedStats, width: number): void {
|
|
1531
|
+
const { theme } = this.deps;
|
|
1532
|
+
const values = stats.monthlyTokens;
|
|
1533
|
+
const max = Math.max(...values, 1);
|
|
1534
|
+
const total = values.reduce((s, v) => s + v, 0);
|
|
1535
|
+
const labelW = 5;
|
|
1536
|
+
const barW = Math.max(6, Math.min(14, width - labelW - 16));
|
|
1537
|
+
const valueW = 8;
|
|
1538
|
+
|
|
1539
|
+
lines.push(` ${theme.fg("dim", "tokens by month")}`);
|
|
1540
|
+
for (let m = 0; m < 12; m++) {
|
|
1541
|
+
const v = values[m];
|
|
1542
|
+
const name = monthLabel(m + 1)
|
|
1543
|
+
.slice(0, 3)
|
|
1544
|
+
.padEnd(labelW);
|
|
1545
|
+
if (v <= 0) {
|
|
1546
|
+
lines.push(
|
|
1547
|
+
` ${theme.fg("dim", name)} ${theme.fg("borderMuted", "·".repeat(barW))} ${theme.fg("dim", "—".padStart(valueW))}`,
|
|
1548
|
+
);
|
|
1549
|
+
continue;
|
|
1550
|
+
}
|
|
1551
|
+
const ratio = v / max;
|
|
1552
|
+
const filled = Math.max(1, Math.round(ratio * barW));
|
|
1553
|
+
const level = this.heatmapLevel(ratio);
|
|
1554
|
+
const bar =
|
|
1555
|
+
theme.fg(this.heatmapColor(level), "█".repeat(filled)) +
|
|
1556
|
+
theme.fg("borderMuted", "░".repeat(barW - filled));
|
|
1557
|
+
const pct = total > 0 ? `${Math.round((v / total) * 100)}%`.padStart(4) : " —";
|
|
1558
|
+
lines.push(
|
|
1559
|
+
` ${theme.fg("text", name)} ${theme.fg("muted", pct)} ${bar} ${theme.fg("dim", formatTokens(v).padStart(valueW))}`,
|
|
1560
|
+
);
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
private appendWrappedHighlights(lines: string[], stats: WrappedStats, w: number): void {
|
|
1565
|
+
const topProj = stats.topProject
|
|
1566
|
+
? truncateToWidth(shortenPath(stats.topProject, this.deps.home), Math.max(20, w - 28))
|
|
1567
|
+
: "—";
|
|
1568
|
+
const pairs: Array<[string, string]> = [
|
|
1569
|
+
["Models used", `${stats.modelCount}`],
|
|
1570
|
+
["Providers", `${stats.providerCount}`],
|
|
1571
|
+
["Projects", `${stats.projectCount}`],
|
|
1572
|
+
["Top project", topProj],
|
|
1573
|
+
];
|
|
1574
|
+
this.appendStatGrid(lines, pairs, w);
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
private appendWrappedTops(lines: string[], stats: WrappedStats, w: number): void {
|
|
1578
|
+
const { theme } = this.deps;
|
|
1579
|
+
const labelW = Math.max(16, Math.min(28, Math.floor(w * 0.38)));
|
|
1580
|
+
const barW = Math.max(6, Math.min(16, w - labelW - 22));
|
|
1581
|
+
const pctW = 5;
|
|
1582
|
+
|
|
1583
|
+
lines.push(` ${theme.fg("muted", "Models")}`);
|
|
1584
|
+
if (stats.topModels.length === 0) {
|
|
1585
|
+
lines.push(` ${theme.fg("dim", "—")}`);
|
|
1586
|
+
}
|
|
1587
|
+
for (const m of stats.topModels) {
|
|
1588
|
+
const name = truncateToWidth(m.name, labelW).padEnd(labelW);
|
|
1589
|
+
const pct = `${Math.round(m.pct)}%`.padStart(pctW);
|
|
1590
|
+
const filled = Math.max(m.pct > 0 ? 1 : 0, Math.round((m.pct / 100) * barW));
|
|
1591
|
+
const bar =
|
|
1592
|
+
theme.fg("accent", "█".repeat(filled)) + theme.fg("borderMuted", "░".repeat(barW - filled));
|
|
1593
|
+
lines.push(
|
|
1594
|
+
` ${theme.fg("text", name)} ${theme.fg("muted", pct)} ${bar} ${theme.fg("dim", formatTokens(m.tokens))}`,
|
|
1595
|
+
);
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
lines.push("");
|
|
1599
|
+
lines.push(` ${theme.fg("muted", "Providers")}`);
|
|
1600
|
+
if (stats.topProviders.length === 0) {
|
|
1601
|
+
lines.push(` ${theme.fg("dim", "—")}`);
|
|
1602
|
+
}
|
|
1603
|
+
for (const p of stats.topProviders) {
|
|
1604
|
+
const name = truncateToWidth(p.name, labelW).padEnd(labelW);
|
|
1605
|
+
const pct = `${Math.round(p.pct)}%`.padStart(pctW);
|
|
1606
|
+
const filled = Math.max(p.pct > 0 ? 1 : 0, Math.round((p.pct / 100) * barW));
|
|
1607
|
+
const bar =
|
|
1608
|
+
theme.fg("success", "█".repeat(filled)) +
|
|
1609
|
+
theme.fg("borderMuted", "░".repeat(barW - filled));
|
|
1610
|
+
lines.push(
|
|
1611
|
+
` ${theme.fg("text", name)} ${theme.fg("muted", pct)} ${bar} ${theme.fg("dim", formatTokens(p.tokens))}`,
|
|
1612
|
+
);
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
// ------------------------------------------------------------------- Stats
|
|
1617
|
+
|
|
1618
|
+
private renderStats(lines: string[], w: number): void {
|
|
1619
|
+
const { theme } = this.deps;
|
|
1620
|
+
const report = this.state.report;
|
|
1621
|
+
if (!report) return;
|
|
1622
|
+
|
|
1623
|
+
const since = rangeSince(this.state.statsRange);
|
|
1624
|
+
const stats = computeStats(report, undefined, since);
|
|
1625
|
+
// The calendar always shows the trailing ~year (GitHub-style), regardless
|
|
1626
|
+
// of the summary range below it.
|
|
1627
|
+
const graph = contributionGraph(report, 53, stats.metric);
|
|
1628
|
+
const fmt = (n: number) => (stats.metric === "tokens" ? formatTokens(n) : formatCost(n));
|
|
1629
|
+
const headline =
|
|
1630
|
+
stats.metric === "tokens"
|
|
1631
|
+
? `${formatTokens(stats.totalTokens)} tokens`
|
|
1632
|
+
: formatCost(stats.totalCost);
|
|
1633
|
+
|
|
1634
|
+
// Title + interactive range selector.
|
|
1635
|
+
lines.push(this.statsRangeLine());
|
|
1636
|
+
lines.push("");
|
|
1637
|
+
this.appendContribGraph(lines, graph, w);
|
|
1638
|
+
lines.push("");
|
|
1639
|
+
|
|
1640
|
+
const fmtKey = (k: string | null) => (k ? formatDayLabel(k) : "—");
|
|
1641
|
+
const dayWord = (n: number) => `${n} day${n === 1 ? "" : "s"}`;
|
|
1642
|
+
const pairs: Array<[string, string]> = [
|
|
1643
|
+
["Total", headline],
|
|
1644
|
+
["Total turns", formatInt(stats.totalTurns)],
|
|
1645
|
+
["Active days", `${stats.activeDays}`],
|
|
1646
|
+
["Favorite model", stats.favoriteModel ?? "—"],
|
|
1647
|
+
["Current streak", dayWord(stats.currentStreak)],
|
|
1648
|
+
["Longest streak", dayWord(stats.longestStreak)],
|
|
1649
|
+
["Busiest day", stats.busiestDay ? fmtKey(stats.busiestDay.dateKey) : "—"],
|
|
1650
|
+
["Peak hour", stats.peakHour != null ? formatHour(stats.peakHour) : "—"],
|
|
1651
|
+
["First activity", fmtKey(stats.firstDay)],
|
|
1652
|
+
["Avg / active day", fmt(stats.avgPerActiveDay)],
|
|
1653
|
+
];
|
|
1654
|
+
this.appendStatGrid(lines, pairs, w);
|
|
1655
|
+
|
|
1656
|
+
const fun = this.statsFunFact(stats);
|
|
1657
|
+
if (fun) {
|
|
1658
|
+
lines.push("");
|
|
1659
|
+
lines.push(` ${theme.fg("accent", fun)}`);
|
|
1660
|
+
}
|
|
1661
|
+
lines.push("");
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
/** Interactive range selector for the Stats view (All / 7d / 30d). */
|
|
1665
|
+
private statsRangeLine(): string {
|
|
1666
|
+
const { theme } = this.deps;
|
|
1667
|
+
const ranges: StatsRange[] = ["all", "7d", "30d"];
|
|
1668
|
+
const tabs = ranges
|
|
1669
|
+
.map((r) => {
|
|
1670
|
+
const text = ` ${rangeLabel(r)} `;
|
|
1671
|
+
return r === this.state.statsRange
|
|
1672
|
+
? theme.bg("selectedBg", theme.fg("accent", theme.bold(text)))
|
|
1673
|
+
: theme.fg("dim", text);
|
|
1674
|
+
})
|
|
1675
|
+
.join(theme.fg("borderMuted", "│"));
|
|
1676
|
+
return ` ${theme.fg("muted", "Stats")} ${tabs}`;
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
/** Render stat pairs in two aligned columns. */
|
|
1680
|
+
private appendStatGrid(lines: string[], pairs: Array<[string, string]>, width: number): void {
|
|
1681
|
+
const { theme } = this.deps;
|
|
1682
|
+
const step = width < 54 ? 1 : 2;
|
|
1683
|
+
// Single-column mode (narrow terminals) gets the full row width so values
|
|
1684
|
+
// like "2 (inferred)" aren't needlessly truncated to the half-width cell.
|
|
1685
|
+
const colW = step === 1 ? width - 2 : Math.max(24, Math.floor((width - 2) / 2));
|
|
1686
|
+
const labelW = 16;
|
|
1687
|
+
const cell = (label: string, value: string) => {
|
|
1688
|
+
const v = truncateToWidth(value, Math.max(6, colW - labelW - 1));
|
|
1689
|
+
return `${theme.fg("muted", label.padEnd(labelW))} ${theme.fg("text", v)}`;
|
|
1690
|
+
};
|
|
1691
|
+
for (let i = 0; i < pairs.length; i += step) {
|
|
1692
|
+
const left = cell(pairs[i][0], pairs[i][1]);
|
|
1693
|
+
let line = ` ${left}`;
|
|
1694
|
+
const next = step === 2 ? pairs[i + 1] : undefined;
|
|
1695
|
+
if (next) {
|
|
1696
|
+
const pad = Math.max(2, colW - visibleWidth(left));
|
|
1697
|
+
line += `${" ".repeat(pad)}${cell(next[0], next[1])}`;
|
|
1698
|
+
}
|
|
1699
|
+
lines.push(line);
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
/** A playful one-liner comparing total usage to a familiar reference. */
|
|
1704
|
+
private statsFunFact(stats: {
|
|
1705
|
+
totalTokens: number;
|
|
1706
|
+
totalCost: number;
|
|
1707
|
+
metric: "usd" | "tokens";
|
|
1708
|
+
}): string | null {
|
|
1709
|
+
// The Great Gatsby ≈ 47k words ≈ ~62k tokens.
|
|
1710
|
+
const GATSBY_TOKENS = 62000;
|
|
1711
|
+
if (stats.totalTokens >= GATSBY_TOKENS) {
|
|
1712
|
+
const ratio = stats.totalTokens / GATSBY_TOKENS;
|
|
1713
|
+
return `You've used ~${formatInt(ratio)}x more tokens than The Great Gatsby`;
|
|
1714
|
+
}
|
|
1715
|
+
if (stats.metric === "usd" && stats.totalCost > 0) {
|
|
1716
|
+
return `Total spend across these sessions: ${formatCost(stats.totalCost)}`;
|
|
1717
|
+
}
|
|
1718
|
+
return null;
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
/**
|
|
1722
|
+
* GitHub-style contribution heatmap: a month-label header row, then 7 day
|
|
1723
|
+
* rows (Sun..Sat) of graded square cells, then a Less→More legend.
|
|
1724
|
+
*/
|
|
1725
|
+
private heatmapColor(level: number): ThemeColor {
|
|
1726
|
+
switch (level) {
|
|
1727
|
+
case 4:
|
|
1728
|
+
return "accent";
|
|
1729
|
+
case 3:
|
|
1730
|
+
return "success";
|
|
1731
|
+
case 2:
|
|
1732
|
+
return "warning";
|
|
1733
|
+
case 1:
|
|
1734
|
+
return "muted";
|
|
1735
|
+
default:
|
|
1736
|
+
return "borderMuted";
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
/** Map a 0–1 usage ratio to heatmap intensity (matches Stats view). */
|
|
1741
|
+
private heatmapLevel(ratio: number): number {
|
|
1742
|
+
if (ratio <= 0) return 0;
|
|
1743
|
+
if (ratio >= 0.75) return 4;
|
|
1744
|
+
if (ratio >= 0.5) return 3;
|
|
1745
|
+
if (ratio >= 0.25) return 2;
|
|
1746
|
+
return 1;
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
private appendContribGraph(lines: string[], graph: ContribGraph, width: number): void {
|
|
1750
|
+
const { theme } = this.deps;
|
|
1751
|
+
const colorFor = (level: number) => this.heatmapColor(level);
|
|
1752
|
+
// Active days are solid 2-wide blocks so consecutive activity fuses into
|
|
1753
|
+
// chunky, seamless squares (the tokscale / Claude Code look). Inactive
|
|
1754
|
+
// days stay a faint dot on the dark background — never a filled block —
|
|
1755
|
+
// so only real activity is colored.
|
|
1756
|
+
const cellW = 2;
|
|
1757
|
+
const block = "█".repeat(cellW);
|
|
1758
|
+
const empty = "·".padEnd(cellW);
|
|
1759
|
+
|
|
1760
|
+
// Left gutter holds the weekday labels; keep month header aligned to it.
|
|
1761
|
+
const gutter = 5;
|
|
1762
|
+
const leftPad = gutter + 1;
|
|
1763
|
+
const maxWeeks = Math.max(6, Math.floor((width - leftPad - 1) / cellW));
|
|
1764
|
+
const weeks =
|
|
1765
|
+
graph.weeks.length > maxWeeks
|
|
1766
|
+
? graph.weeks.slice(graph.weeks.length - maxWeeks)
|
|
1767
|
+
: graph.weeks;
|
|
1768
|
+
|
|
1769
|
+
// Month-label header: place each month abbreviation at the week where it
|
|
1770
|
+
// first appears (GitHub-style), so the timeline reads left→right.
|
|
1771
|
+
const firstTs = (col: Array<{ ts: number } | null>): number | null => {
|
|
1772
|
+
for (const c of col) if (c) return c.ts;
|
|
1773
|
+
return null;
|
|
1774
|
+
};
|
|
1775
|
+
const monthRow = new Array<string>(weeks.length * cellW).fill(" ");
|
|
1776
|
+
let lastMonth = -1;
|
|
1777
|
+
for (let i = 0; i < weeks.length; i++) {
|
|
1778
|
+
const ts = firstTs(weeks[i]);
|
|
1779
|
+
if (ts == null) continue;
|
|
1780
|
+
const mon = new Date(ts).getMonth();
|
|
1781
|
+
if (mon !== lastMonth) {
|
|
1782
|
+
lastMonth = mon;
|
|
1783
|
+
const label = monthLabel(mon + 1);
|
|
1784
|
+
const at = i * cellW;
|
|
1785
|
+
for (let k = 0; k < label.length && at + k < monthRow.length; k++) {
|
|
1786
|
+
monthRow[at + k] = label[k];
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
lines.push(`${" ".repeat(leftPad)}${theme.fg("dim", monthRow.join(""))}`);
|
|
1791
|
+
|
|
1792
|
+
const dowLabels = ["", "Mon", "", "Wed", "", "Fri", ""];
|
|
1793
|
+
for (let row = 0; row < 7; row++) {
|
|
1794
|
+
let line = ` ${theme.fg("dim", (dowLabels[row] ?? "").padEnd(gutter - 2))} `;
|
|
1795
|
+
for (const col of weeks) {
|
|
1796
|
+
const cell = col[row];
|
|
1797
|
+
if (!cell) {
|
|
1798
|
+
line += " ".repeat(cellW);
|
|
1799
|
+
continue;
|
|
1800
|
+
}
|
|
1801
|
+
// Inactive day: faint dot on the dark background (no fill).
|
|
1802
|
+
if (cell.level === 0) {
|
|
1803
|
+
line += theme.fg("borderMuted", empty);
|
|
1804
|
+
continue;
|
|
1805
|
+
}
|
|
1806
|
+
line += theme.fg(colorFor(cell.level), block);
|
|
1807
|
+
}
|
|
1808
|
+
lines.push(line);
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
let legend = ` ${theme.fg("dim", "Less ")}`;
|
|
1812
|
+
legend += theme.fg("borderMuted", empty);
|
|
1813
|
+
for (let l = 1; l < 5; l++) legend += theme.fg(colorFor(l), block);
|
|
1814
|
+
legend += theme.fg("dim", " More");
|
|
1815
|
+
lines.push(legend);
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
/** Sparkline of the last 30 active-window days (Overview trend strip). */
|
|
1819
|
+
private appendTrendSparkline(lines: string[], _w: number): void {
|
|
1820
|
+
const { theme } = this.deps;
|
|
1821
|
+
const report = this.state.report;
|
|
1822
|
+
if (!report) return;
|
|
1823
|
+
const days = dailyStats(report);
|
|
1824
|
+
if (days.length === 0) return;
|
|
1825
|
+
const metric = naturalMetric(days);
|
|
1826
|
+
const recent = days.slice(-30);
|
|
1827
|
+
const values = recent.map((d) => metricValue(d.bucket, metric));
|
|
1828
|
+
const spark = sparkline(values);
|
|
1829
|
+
const span =
|
|
1830
|
+
recent.length > 1
|
|
1831
|
+
? `${formatDayLabel(recent[0].dateKey).slice(4)} → ${formatDayLabel(recent[recent.length - 1].dateKey).slice(4)}`
|
|
1832
|
+
: formatDayLabel(recent[0].dateKey).slice(4);
|
|
1833
|
+
lines.push(
|
|
1834
|
+
` ${theme.fg("muted", "Trend")} ${theme.fg("accent", spark)} ${theme.fg("dim", span)}`,
|
|
1835
|
+
);
|
|
1836
|
+
lines.push("");
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
private portableMenuLines(width: number): string[] {
|
|
1840
|
+
const { theme } = this.deps;
|
|
1841
|
+
const current = VIEW_TABS[this.state.view].label;
|
|
1842
|
+
const views = VIEW_ORDER.map((key) => VIEW_TABS[key].short).join(" · ");
|
|
1843
|
+
return [
|
|
1844
|
+
` ${theme.fg("accent", current)} ${theme.fg("dim", `(${VIEW_ORDER.indexOf(this.state.view) + 1}/${VIEW_ORDER.length})`)}`,
|
|
1845
|
+
` ${theme.fg("dim", truncateToWidth(`Views: ${views}`, Math.max(10, width - 2)))}`,
|
|
1846
|
+
];
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
private menuLines(width: number): string[] {
|
|
1850
|
+
const { theme } = this.deps;
|
|
1851
|
+
const renderTab = (key: ViewKey, num: number, icon: boolean, long: boolean): string => {
|
|
1852
|
+
const tab = VIEW_TABS[key];
|
|
1853
|
+
const text = icon
|
|
1854
|
+
? ` ${tab.icon}${num} ${long ? tab.label : tab.short} `
|
|
1855
|
+
: ` ${num} ${tab.short} `;
|
|
1856
|
+
return key === this.state.view
|
|
1857
|
+
? theme.bg("selectedBg", theme.fg(tab.color, theme.bold(text)))
|
|
1858
|
+
: theme.fg("dim", text);
|
|
1859
|
+
};
|
|
1860
|
+
const rail = theme.fg("borderMuted", "╭─ views ");
|
|
1861
|
+
const close = theme.fg("borderMuted", " ─╮");
|
|
1862
|
+
const sep = theme.fg("borderMuted", " │ ");
|
|
1863
|
+
|
|
1864
|
+
// Pick the richest tab style that fits: full labels, then short labels,
|
|
1865
|
+
// then iconless short labels. An 8-tab rail simply cannot fit below ~90
|
|
1866
|
+
// columns, so the fallback centers the active tab with its neighbours.
|
|
1867
|
+
let row1 = "";
|
|
1868
|
+
for (const variant of [
|
|
1869
|
+
{ icon: true, long: true },
|
|
1870
|
+
{ icon: true, long: false },
|
|
1871
|
+
{ icon: false, long: false },
|
|
1872
|
+
]) {
|
|
1873
|
+
const tabs = VIEW_ORDER.map((key, i) =>
|
|
1874
|
+
renderTab(key, i + 1, variant.icon, variant.long),
|
|
1875
|
+
).join(sep);
|
|
1876
|
+
const pad = Math.max(
|
|
1877
|
+
0,
|
|
1878
|
+
width - visibleWidth(rail) - visibleWidth(tabs) - visibleWidth(close) - 2,
|
|
1879
|
+
);
|
|
1880
|
+
const candidate = `${rail}${tabs}${" ".repeat(pad)}${close}`;
|
|
1881
|
+
row1 = candidate;
|
|
1882
|
+
if (visibleWidth(candidate) <= width) break;
|
|
1883
|
+
row1 = "";
|
|
1884
|
+
}
|
|
1885
|
+
if (!row1) {
|
|
1886
|
+
const idx = VIEW_ORDER.indexOf(this.state.view);
|
|
1887
|
+
const prev = VIEW_TABS[VIEW_ORDER[(idx - 1 + VIEW_ORDER.length) % VIEW_ORDER.length]];
|
|
1888
|
+
const next = VIEW_TABS[VIEW_ORDER[(idx + 1) % VIEW_ORDER.length]];
|
|
1889
|
+
const cur = VIEW_TABS[this.state.view];
|
|
1890
|
+
const active = theme.bg(
|
|
1891
|
+
"selectedBg",
|
|
1892
|
+
theme.fg(cur.color, theme.bold(` ${cur.icon}${idx + 1} ${cur.label} `)),
|
|
1893
|
+
);
|
|
1894
|
+
row1 = ` ${theme.fg("dim", `‹ ${prev.short}`)} ${active} ${theme.fg("dim", `${next.short} ›`)} ${theme.fg("dim", `${idx + 1}/${VIEW_ORDER.length}`)}`;
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
const pose = mascotPose(this.state.view);
|
|
1898
|
+
const miniMascot = renderMascot(pose, theme)[1] ?? "";
|
|
1899
|
+
const quip = mascotQuip(
|
|
1900
|
+
this.state.view,
|
|
1901
|
+
this.state.view === "wrapped" && this.state.report
|
|
1902
|
+
? wrappedStats(this.state.report, this.state.wrappedYear)
|
|
1903
|
+
: null,
|
|
1904
|
+
);
|
|
1905
|
+
// Drop the quip on narrow terminals so the key hints survive clamping;
|
|
1906
|
+
// truncate it to whatever room remains left of the hint on mid widths.
|
|
1907
|
+
const hintRight = theme.fg("dim", width < 64 ? "Tab · ←→" : "Tab · ←→ · 1-8 jump");
|
|
1908
|
+
const quipRoom =
|
|
1909
|
+
width - visibleWidth(miniMascot) - visibleWidth("Pi-chan") - visibleWidth(hintRight) - 9;
|
|
1910
|
+
const quipText = width >= 64 && quipRoom > 8 ? truncateToWidth(quip, quipRoom) : "";
|
|
1911
|
+
const hintLeft = quipText
|
|
1912
|
+
? `${theme.fg("success", "Pi-chan")} ${theme.fg("muted", quipText)}`
|
|
1913
|
+
: `${theme.fg("success", "Pi-chan")}`;
|
|
1914
|
+
const hintPad = Math.max(
|
|
1915
|
+
2,
|
|
1916
|
+
width - visibleWidth(miniMascot) - visibleWidth(hintLeft) - visibleWidth(hintRight) - 4,
|
|
1917
|
+
);
|
|
1918
|
+
const row2 = ` ${miniMascot} ${hintLeft}${" ".repeat(hintPad)}${hintRight}`;
|
|
1919
|
+
|
|
1920
|
+
return [row1, row2];
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
private titleLineRaw(width: number): string {
|
|
1924
|
+
const { theme } = this.deps;
|
|
1925
|
+
const title = theme.fg("accent", theme.bold(" Usage "));
|
|
1926
|
+
const tabs = this.windowTabs();
|
|
1927
|
+
const dots = Math.max(2, width - visibleWidth(title) - visibleWidth(tabs) - 2);
|
|
1928
|
+
return `${title}${theme.fg("borderMuted", "─".repeat(dots))}${tabs}`;
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
private windowTabs(): string {
|
|
1932
|
+
const { theme } = this.deps;
|
|
1933
|
+
const cur = this.state.windowKey;
|
|
1934
|
+
const tabs: string[] = [];
|
|
1935
|
+
for (const key of ["5h", "24h", "7d", "all"] as WindowKey[]) {
|
|
1936
|
+
const label = key.toUpperCase().replace("24H", "DAY").replace("7D", "WEEK");
|
|
1937
|
+
const text = ` ${label} `;
|
|
1938
|
+
tabs.push(
|
|
1939
|
+
key === cur
|
|
1940
|
+
? theme.bg("selectedBg", theme.fg("accent", theme.bold(text)))
|
|
1941
|
+
: theme.fg("dim", text),
|
|
1942
|
+
);
|
|
1943
|
+
}
|
|
1944
|
+
return tabs.join(theme.fg("borderMuted", "│"));
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
private subheaderLine(win: WindowedReport, width: number): string {
|
|
1948
|
+
const { theme } = this.deps;
|
|
1949
|
+
const left = theme.fg("muted", ` Showing: ${labelForWindow(win.window)}`);
|
|
1950
|
+
const ago = win.latest > 0 ? `last activity ${relativeTime(win.latest)}` : "no activity";
|
|
1951
|
+
const right = theme.fg(
|
|
1952
|
+
"dim",
|
|
1953
|
+
width < 72 ? `${win.sessionCount} sessions ` : `${ago} · ${win.sessionCount} sessions `,
|
|
1954
|
+
);
|
|
1955
|
+
const pad = Math.max(1, width - visibleWidth(left) - visibleWidth(right));
|
|
1956
|
+
return left + " ".repeat(pad) + right;
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
private quotaLine(
|
|
1960
|
+
label: string,
|
|
1961
|
+
bucket: Bucket,
|
|
1962
|
+
limit: number | undefined,
|
|
1963
|
+
width: number,
|
|
1964
|
+
unit: "usd" | "tokens" = "usd",
|
|
1965
|
+
): string {
|
|
1966
|
+
const { theme } = this.deps;
|
|
1967
|
+
// Pick the measured value for the chosen unit. When a provider has no
|
|
1968
|
+
// pricing (cost === 0 across the window) dollars are meaningless, so the
|
|
1969
|
+
// caller switches the unit to tokens — the real usage signal.
|
|
1970
|
+
const used = unit === "tokens" ? bucketTokens(bucket) : bucket.cost;
|
|
1971
|
+
const hasLimit = typeof limit === "number" && limit > 0;
|
|
1972
|
+
const ratio = hasLimit ? used / limit : 0;
|
|
1973
|
+
const color: ThemeColor = ratio >= 1 ? "error" : ratio >= 0.85 ? "warning" : "success";
|
|
1974
|
+
|
|
1975
|
+
const fmt = (n: number) => (unit === "tokens" ? formatTokens(n) : formatCost(n));
|
|
1976
|
+
const labelW = 16;
|
|
1977
|
+
const lbl = truncateToWidth(label, labelW).padEnd(labelW);
|
|
1978
|
+
const barW = Math.max(8, Math.min(28, width - labelW - 34));
|
|
1979
|
+
const filled = hasLimit
|
|
1980
|
+
? Math.round(Math.min(1, ratio) * barW)
|
|
1981
|
+
: Math.min(
|
|
1982
|
+
barW,
|
|
1983
|
+
Math.max(
|
|
1984
|
+
used > 0 ? 1 : 0,
|
|
1985
|
+
Math.round(Math.sqrt(Math.max(0, used)) * (unit === "tokens" ? 0.02 : 2)),
|
|
1986
|
+
),
|
|
1987
|
+
);
|
|
1988
|
+
const barStr =
|
|
1989
|
+
theme.fg(color, "█".repeat(filled)) + theme.fg("borderMuted", "░".repeat(barW - filled));
|
|
1990
|
+
|
|
1991
|
+
let right: string;
|
|
1992
|
+
if (hasLimit) {
|
|
1993
|
+
right = `${fmt(used)} / ${fmt(limit as number)} (${percent(used, limit as number)})`;
|
|
1994
|
+
} else {
|
|
1995
|
+
const hint = this.portableRendering
|
|
1996
|
+
? unit === "tokens"
|
|
1997
|
+
? "(no token budget — choose Configure)"
|
|
1998
|
+
: "(no limit set — choose Configure)"
|
|
1999
|
+
: unit === "tokens"
|
|
2000
|
+
? "(no token budget — press s)"
|
|
2001
|
+
: "(no limit set — press s)";
|
|
2002
|
+
right = `${fmt(used)} ${theme.fg("dim", hint)}`;
|
|
2003
|
+
}
|
|
2004
|
+
return ` ${theme.fg("text", lbl)} ${barStr} ${theme.fg("muted", right)}`;
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
/**
|
|
2008
|
+
* Render an upstream-reported percentage quota bar (e.g. ZAI 5h/weekly).
|
|
2009
|
+
* The provider only exposes `usedPct` (0-100) + a reset countdown, so the bar
|
|
2010
|
+
* shows used%, remaining%, and when the window resets.
|
|
2011
|
+
*/
|
|
2012
|
+
private percentLine(
|
|
2013
|
+
label: string,
|
|
2014
|
+
window: { usedPct: number; resetMs: number },
|
|
2015
|
+
width: number,
|
|
2016
|
+
unit: "usd" | "tokens",
|
|
2017
|
+
sessionBucket?: Bucket,
|
|
2018
|
+
): string {
|
|
2019
|
+
const { theme } = this.deps;
|
|
2020
|
+
const pct = Math.max(0, Math.min(100, window.usedPct));
|
|
2021
|
+
const remaining = 100 - pct;
|
|
2022
|
+
const color: ThemeColor = pct >= 90 ? "error" : pct >= 75 ? "warning" : "success";
|
|
2023
|
+
|
|
2024
|
+
const labelW = 16;
|
|
2025
|
+
const lbl = truncateToWidth(label, labelW).padEnd(labelW);
|
|
2026
|
+
|
|
2027
|
+
// Session-derived cost/tokens in the same window, combined with the
|
|
2028
|
+
// upstream percentage. Format: "<pct>% used / $<cost> · <rem>% left · resets X".
|
|
2029
|
+
let sessionText = "";
|
|
2030
|
+
if (sessionBucket) {
|
|
2031
|
+
const value = unit === "tokens" ? bucketTokens(sessionBucket) : sessionBucket.cost;
|
|
2032
|
+
const fmt = (n: number) => (unit === "tokens" ? formatTokens(n) : formatCost(n));
|
|
2033
|
+
// Always show the session value, even if 0 (so the user sees the bar
|
|
2034
|
+
// is genuinely empty for the chosen unit, not just hidden).
|
|
2035
|
+
sessionText = ` / ${fmt(value)}`;
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
const right = `${pct}% used${sessionText} · ${remaining}% left${
|
|
2039
|
+
window.resetMs ? ` · resets ${countdown(window.resetMs)}` : ""
|
|
2040
|
+
}`;
|
|
2041
|
+
const rightW = visibleWidth(right);
|
|
2042
|
+
const barW = Math.max(8, Math.min(28, width - labelW - rightW - 6));
|
|
2043
|
+
// Guarantee at least one filled cell when usedPct > 0, so a low-usage
|
|
2044
|
+
// window (e.g. 1% of 5h) is still visible rather than rendering as a
|
|
2045
|
+
// fully-empty bar that looks like "nothing".
|
|
2046
|
+
const filled = pct > 0 ? Math.max(1, Math.round((pct / 100) * barW)) : 0;
|
|
2047
|
+
const barStr =
|
|
2048
|
+
theme.fg(color, "█".repeat(filled)) + theme.fg("borderMuted", "░".repeat(barW - filled));
|
|
2049
|
+
return ` ${theme.fg("text", lbl)} ${barStr} ${theme.fg("muted", right)}`;
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
/**
|
|
2053
|
+
* Build a context-aware hint explaining why the subscription quota isn't
|
|
2054
|
+
* shown yet. Subscriptions (OpenAI Codex, ZAI coding plans) get their quota
|
|
2055
|
+
* from the upstream — the panel must never suggest `/usage-config` for
|
|
2056
|
+
* these, because that would be wrong (it would just aggregate session
|
|
2057
|
+
* history, not the real plan quota).
|
|
2058
|
+
*/
|
|
2059
|
+
private buildSubscriptionHint(provider: string): string {
|
|
2060
|
+
const notes = this.state.providerQuota?.notes ?? [];
|
|
2061
|
+
if (provider === "zai") {
|
|
2062
|
+
// ZAI quota comes from the monitor endpoint. If we have notes from
|
|
2063
|
+
// fetchProviderQuota, surface them (e.g. token expired).
|
|
2064
|
+
return notes[0] ?? "No upstream quota yet — press r to refresh, or make a request to retry.";
|
|
2065
|
+
}
|
|
2066
|
+
// openai-codex: quota comes from response headers (`x-codex-*`) captured by
|
|
2067
|
+
// pi on every Codex request. If we haven't made one this session, no
|
|
2068
|
+
// headers are captured yet.
|
|
2069
|
+
if (notes.some((n) => n.includes("expired") || n.includes("sign in"))) {
|
|
2070
|
+
return "OpenAI Codex token expired — sign in to Codex CLI (`codex auth`), then press r to refresh.";
|
|
2071
|
+
}
|
|
2072
|
+
return "No Codex headers captured yet — make any request to refresh, then press r.";
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
private appendTokenComposition(lines: string[], win: WindowedReport, width: number): void {
|
|
2076
|
+
const { theme } = this.deps;
|
|
2077
|
+
const t = win.total;
|
|
2078
|
+
lines.push(` ${theme.fg("accent", theme.bold("Token composition"))}`);
|
|
2079
|
+
const rows: Array<[string, number, number]> = [
|
|
2080
|
+
["Input", t.input, t.costInput],
|
|
2081
|
+
["Output", t.output, t.costOutput],
|
|
2082
|
+
["Cache read", t.cacheRead, t.costCacheRead],
|
|
2083
|
+
["Cache write", t.cacheWrite, t.costCacheWrite],
|
|
2084
|
+
];
|
|
2085
|
+
if (t.reasoning > 0) rows.push(["Reasoning (of output)", t.reasoning, 0]);
|
|
2086
|
+
if (t.cacheWrite1h > 0) rows.push(["Cache write (1h)", t.cacheWrite1h, 0]);
|
|
2087
|
+
const compact = width < 64;
|
|
2088
|
+
for (const [label, tokens, cost] of rows) {
|
|
2089
|
+
if (tokens <= 0) continue;
|
|
2090
|
+
const priced = cost > 0 ? ` · ${formatCost(cost)}` : "";
|
|
2091
|
+
lines.push(
|
|
2092
|
+
` ${theme.fg("muted", truncateToWidth(label, compact ? 18 : 24).padEnd(compact ? 18 : 24))} ${theme.fg("text", formatTokens(tokens))}${theme.fg("dim", priced)}`,
|
|
2093
|
+
);
|
|
2094
|
+
}
|
|
2095
|
+
const denominator = t.input + t.cacheRead;
|
|
2096
|
+
const reuse = denominator > 0 ? percent(t.cacheRead, denominator) : "—";
|
|
2097
|
+
lines.push(` ${theme.fg("dim", `cache-input reuse ratio ${reuse}`)}`);
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
private statsLine(win: WindowedReport, _width: number): string {
|
|
2101
|
+
const { theme } = this.deps;
|
|
2102
|
+
const t = win.total;
|
|
2103
|
+
const totalTokens = bucketTokens(t);
|
|
2104
|
+
const parts = [
|
|
2105
|
+
`${theme.fg("accent", "↑")}${theme.fg("text", formatTokens(t.input))}`,
|
|
2106
|
+
`${theme.fg("accent", "↓")}${theme.fg("text", formatTokens(t.output))}`,
|
|
2107
|
+
`${theme.fg("accent", "⚡")}${theme.fg("text", formatTokens(t.cacheRead))}`,
|
|
2108
|
+
`${theme.fg("accent", "↥")}${theme.fg("text", formatTokens(t.cacheWrite))}`,
|
|
2109
|
+
];
|
|
2110
|
+
// Only show $ when there's real pricing; otherwise emphasize total tokens.
|
|
2111
|
+
if (t.cost > 0) {
|
|
2112
|
+
parts.push(theme.fg("success", formatCost(t.cost)));
|
|
2113
|
+
} else {
|
|
2114
|
+
parts.push(`${theme.fg("success", formatTokens(totalTokens))} ${theme.fg("dim", "tokens")}`);
|
|
2115
|
+
}
|
|
2116
|
+
const meta = theme.fg("dim", `· ${win.turnCount} turns`);
|
|
2117
|
+
return ` ${parts.join(" ")} ${meta}`;
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
private topConsumer(
|
|
2121
|
+
win: WindowedReport,
|
|
2122
|
+
unit: "usd" | "tokens",
|
|
2123
|
+
): { kind: string; name: string; pct: string } | null {
|
|
2124
|
+
// Use the same subscription-aware unit as the rest of the panel. Ranking by
|
|
2125
|
+
// cost when a subscription provider is active would wrongly credit the one
|
|
2126
|
+
// priced legacy turn (e.g. $0.20 of codex) as "100% of usage" while ignoring
|
|
2127
|
+
// the token-heavy subscription turns (e.g. glm-5.2 with 81M tokens).
|
|
2128
|
+
const useTokens = unit === "tokens";
|
|
2129
|
+
const total = useTokens ? bucketTokens(win.total) : win.total.cost;
|
|
2130
|
+
if (total <= 0) return null;
|
|
2131
|
+
const bucketValue = (b: Bucket) => (useTokens ? bucketTokens(b) : b.cost);
|
|
2132
|
+
const candidates: Array<{ kind: string; name: string; value: number }> = [];
|
|
2133
|
+
const pick = (kind: string, map: Map<string, Bucket>) => {
|
|
2134
|
+
for (const [name, b] of ranked(map, bucketValue).slice(0, 1)) {
|
|
2135
|
+
candidates.push({ kind, name, value: bucketValue(b) });
|
|
2136
|
+
}
|
|
2137
|
+
};
|
|
2138
|
+
pick("model", win.byModel);
|
|
2139
|
+
pick("skill", win.bySkill);
|
|
2140
|
+
pick("plugin", win.byPlugin);
|
|
2141
|
+
candidates.sort((a, b) => b.value - a.value);
|
|
2142
|
+
const best = candidates[0];
|
|
2143
|
+
if (!best || best.value <= 0) return null;
|
|
2144
|
+
return {
|
|
2145
|
+
kind: best.kind,
|
|
2146
|
+
name: best.name,
|
|
2147
|
+
pct: percent(best.value, total),
|
|
2148
|
+
};
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
/** Render the active-provider banner + live quota + rate-limit windows. */
|
|
2152
|
+
private appendProviderSection(lines: string[], width: number): void {
|
|
2153
|
+
const { theme } = this.deps;
|
|
2154
|
+
const quota = this.state.providerQuota;
|
|
2155
|
+
|
|
2156
|
+
lines.push(` ${theme.fg("accent", theme.bold("Active provider"))}`);
|
|
2157
|
+
|
|
2158
|
+
if (!quota?.active) {
|
|
2159
|
+
lines.push(` ${theme.fg("dim", "— no active model yet —")}`);
|
|
2160
|
+
lines.push("");
|
|
2161
|
+
return;
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
const a = quota.active;
|
|
2165
|
+
const host = hostFromUrl(a.baseUrl);
|
|
2166
|
+
const keyBadge = a.hasKey ? theme.fg("success", "key ✓") : theme.fg("warning", "no env key");
|
|
2167
|
+
lines.push(
|
|
2168
|
+
` ${theme.fg("text", `${a.provider} / ${a.modelId}`)} ${theme.fg("dim", host)} ${keyBadge}`,
|
|
2169
|
+
);
|
|
2170
|
+
|
|
2171
|
+
// Live money quota from the provider's billing API.
|
|
2172
|
+
if (quota.credits) {
|
|
2173
|
+
const c = quota.credits;
|
|
2174
|
+
lines.push(
|
|
2175
|
+
this.miniBar(
|
|
2176
|
+
"Account credits",
|
|
2177
|
+
c.remaining,
|
|
2178
|
+
c.total,
|
|
2179
|
+
`${formatCost(c.remaining)} / ${formatCost(c.total)}`,
|
|
2180
|
+
width,
|
|
2181
|
+
),
|
|
2182
|
+
);
|
|
2183
|
+
}
|
|
2184
|
+
if (quota.spend5h != null || quota.spend7d != null) {
|
|
2185
|
+
const parts: string[] = [];
|
|
2186
|
+
if (quota.spend5h != null) parts.push(`5h ${formatCost(quota.spend5h)}`);
|
|
2187
|
+
if (quota.spend7d != null) parts.push(`7d ${formatCost(quota.spend7d)}`);
|
|
2188
|
+
if (quota.monthlyLimit != null) parts.push(`limit ${formatCost(quota.monthlyLimit)}/mo`);
|
|
2189
|
+
lines.push(
|
|
2190
|
+
` ${theme.fg("muted", "Provider spend")} ${theme.fg("text", parts.join(" "))}`,
|
|
2191
|
+
);
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
// Rate-limit windows captured from the latest provider response.
|
|
2195
|
+
if (quota.rateLimits.length > 0) {
|
|
2196
|
+
lines.push(` ${theme.fg("muted", "Rate limits (live, from last response)")}`);
|
|
2197
|
+
for (const rl of quota.rateLimits.slice(0, 6)) {
|
|
2198
|
+
const limit = rl.limit > 0 ? rl.limit : 0;
|
|
2199
|
+
const ratio = limit > 0 ? rl.remaining / limit : 0;
|
|
2200
|
+
const used = Math.max(0, limit - rl.remaining);
|
|
2201
|
+
const reset = rl.resetMs > 0 ? `resets in ${countdown(rl.resetMs)}` : "";
|
|
2202
|
+
const right = `${formatLimit(rl.remaining)}/${formatLimit(limit)}${reset ? ` ${reset}` : ""}`;
|
|
2203
|
+
lines.push(
|
|
2204
|
+
this.miniBar(rl.resource, Math.max(0, ratio), undefined, right, width, used, limit),
|
|
2205
|
+
);
|
|
2206
|
+
}
|
|
2207
|
+
} else if (quota.source === "none") {
|
|
2208
|
+
lines.push(
|
|
2209
|
+
` ${theme.fg("dim", "No rate-limit headers captured yet — make a request first.")}`,
|
|
2210
|
+
);
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
for (const note of quota.notes) {
|
|
2214
|
+
lines.push(` ${theme.fg("dim", `• ${note}`)}`);
|
|
2215
|
+
}
|
|
2216
|
+
if (quota.error) {
|
|
2217
|
+
lines.push(` ${theme.fg("error", quota.error)}`);
|
|
2218
|
+
}
|
|
2219
|
+
lines.push("");
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
/** Compact single-line bar: `[label] ██████░░░░ right` */
|
|
2223
|
+
private miniBar(
|
|
2224
|
+
label: string,
|
|
2225
|
+
ratioOrValue: number,
|
|
2226
|
+
limitForRatio: number | undefined,
|
|
2227
|
+
right: string,
|
|
2228
|
+
width: number,
|
|
2229
|
+
used?: number,
|
|
2230
|
+
limitNum?: number,
|
|
2231
|
+
): string {
|
|
2232
|
+
const { theme } = this.deps;
|
|
2233
|
+
const labelW = 16;
|
|
2234
|
+
const lbl = truncateToWidth(label, labelW).padEnd(labelW);
|
|
2235
|
+
|
|
2236
|
+
let ratio: number;
|
|
2237
|
+
if (limitForRatio === undefined) {
|
|
2238
|
+
// ratioOrValue is itself a 0..1 ratio
|
|
2239
|
+
ratio = Math.max(0, Math.min(1, ratioOrValue));
|
|
2240
|
+
} else if (limitForRatio > 0) {
|
|
2241
|
+
// remaining/limit → bar shows remaining; color by usage pressure
|
|
2242
|
+
const remaining = ratioOrValue;
|
|
2243
|
+
ratio = remaining / limitForRatio;
|
|
2244
|
+
} else {
|
|
2245
|
+
ratio = 0;
|
|
2246
|
+
}
|
|
2247
|
+
// Color: green when plenty remaining, yellow mid, red low.
|
|
2248
|
+
const color: ThemeColor = ratio >= 0.5 ? "success" : ratio >= 0.2 ? "warning" : "error";
|
|
2249
|
+
|
|
2250
|
+
const rightW = visibleWidth(right);
|
|
2251
|
+
const barW = Math.max(6, Math.min(22, width - labelW - rightW - 6));
|
|
2252
|
+
const filled = Math.max(ratio > 0 ? 1 : 0, Math.round(ratio * barW));
|
|
2253
|
+
const barStr =
|
|
2254
|
+
theme.fg(color, "█".repeat(filled)) + theme.fg("borderMuted", "░".repeat(barW - filled));
|
|
2255
|
+
void used;
|
|
2256
|
+
void limitNum;
|
|
2257
|
+
return ` ${theme.fg("text", lbl)} ${barStr} ${theme.fg("muted", right)}`;
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
/**
|
|
2261
|
+
* One aligned table-header row: the section title fills the label column and
|
|
2262
|
+
* the column labels (`%`, value unit, optional extra) sit directly above
|
|
2263
|
+
* their data columns. Keeps every breakdown section visually consistent.
|
|
2264
|
+
*/
|
|
2265
|
+
private tableHeader(
|
|
2266
|
+
title: string,
|
|
2267
|
+
labelW: number,
|
|
2268
|
+
barW: number,
|
|
2269
|
+
valueLabel: string,
|
|
2270
|
+
extraLabel?: string,
|
|
2271
|
+
valueW?: number,
|
|
2272
|
+
): string {
|
|
2273
|
+
const { theme } = this.deps;
|
|
2274
|
+
const titleCell = theme.fg("accent", theme.bold(truncateToWidth(title, labelW).padEnd(labelW)));
|
|
2275
|
+
const pctCell = theme.fg("dim", "%".padStart(4));
|
|
2276
|
+
const barSpace = " ".repeat(barW);
|
|
2277
|
+
const valueCell = theme.fg("dim", valueW ? valueLabel.padEnd(valueW) : valueLabel);
|
|
2278
|
+
let line = ` ${titleCell} ${pctCell} ${barSpace} ${valueCell}`;
|
|
2279
|
+
if (extraLabel) line += ` ${theme.fg("dim", extraLabel)}`;
|
|
2280
|
+
return line;
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
private appendToolsSection(
|
|
2284
|
+
lines: string[],
|
|
2285
|
+
map: Map<string, Bucket>,
|
|
2286
|
+
total: number,
|
|
2287
|
+
width: number,
|
|
2288
|
+
limit: number,
|
|
2289
|
+
unit: "usd" | "tokens",
|
|
2290
|
+
): void {
|
|
2291
|
+
const { theme } = this.deps;
|
|
2292
|
+
const bucketValue = (b: Bucket) => (unit === "tokens" ? bucketTokens(b) : b.cost);
|
|
2293
|
+
const fmt = (n: number) => (unit === "tokens" ? formatTokens(n) : formatCost(n));
|
|
2294
|
+
const rows = ranked(map, bucketValue);
|
|
2295
|
+
const unitLabel = unit === "tokens" ? "tokens" : "cost";
|
|
2296
|
+
|
|
2297
|
+
const labelW = Math.max(16, Math.min(36, Math.floor((width - 30) * 0.6)));
|
|
2298
|
+
const barW = Math.max(6, Math.min(20, width - labelW - 26));
|
|
2299
|
+
lines.push(
|
|
2300
|
+
` ${theme.fg("warning", "⚙")} ${theme.fg("dim", "Pi-chan tracked these tool calls")}`,
|
|
2301
|
+
);
|
|
2302
|
+
lines.push(this.tableHeader("Tools", labelW, barW, unitLabel));
|
|
2303
|
+
|
|
2304
|
+
if (rows.length === 0) {
|
|
2305
|
+
lines.push(` ${theme.fg("dim", "— none in this window —")}`);
|
|
2306
|
+
lines.push("");
|
|
2307
|
+
return;
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
const shown = rows.slice(0, limit);
|
|
2311
|
+
for (let i = 0; i < shown.length; i++) {
|
|
2312
|
+
const [key, bucket] = shown[i];
|
|
2313
|
+
const value = bucketValue(bucket);
|
|
2314
|
+
const glyph = toolGlyph(key);
|
|
2315
|
+
const rawName = truncateToWidth(key, labelW - 2);
|
|
2316
|
+
const name = `${glyph} ${rawName}`.padEnd(labelW);
|
|
2317
|
+
const pct = percent(value, total);
|
|
2318
|
+
const ratio = total > 0 ? value / total : 0;
|
|
2319
|
+
const filled = Math.max(ratio > 0 ? 1 : 0, Math.round(ratio * barW));
|
|
2320
|
+
const color = RAINBOW[i % RAINBOW.length];
|
|
2321
|
+
const barStr =
|
|
2322
|
+
theme.fg(color, "█".repeat(filled)) + theme.fg("borderMuted", "░".repeat(barW - filled));
|
|
2323
|
+
const pctStr = pct.padStart(4);
|
|
2324
|
+
lines.push(
|
|
2325
|
+
` ${theme.fg("text", name)} ${theme.fg("muted", pctStr)} ${barStr} ${theme.fg(color, fmt(value))}`,
|
|
2326
|
+
);
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
const rest = rows.length - shown.length;
|
|
2330
|
+
if (rest > 0) {
|
|
2331
|
+
lines.push(` ${theme.fg("dim", `… +${rest} more tools`)}`);
|
|
2332
|
+
}
|
|
2333
|
+
lines.push(` ${theme.fg("dim", "glyph · tool type hint")}`);
|
|
2334
|
+
lines.push("");
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
private appendSection(
|
|
2338
|
+
lines: string[],
|
|
2339
|
+
title: string,
|
|
2340
|
+
map: Map<string, Bucket>,
|
|
2341
|
+
total: number,
|
|
2342
|
+
width: number,
|
|
2343
|
+
limit: number,
|
|
2344
|
+
labelFn: (key: string) => string = (k) => k,
|
|
2345
|
+
unit: "usd" | "tokens" = "usd",
|
|
2346
|
+
): void {
|
|
2347
|
+
const { theme } = this.deps;
|
|
2348
|
+
const bucketValue = (b: Bucket) => (unit === "tokens" ? bucketTokens(b) : b.cost);
|
|
2349
|
+
const fmt = (n: number) => (unit === "tokens" ? formatTokens(n) : formatCost(n));
|
|
2350
|
+
const rows = ranked(map, bucketValue);
|
|
2351
|
+
const unitLabel = unit === "tokens" ? "tokens" : "cost";
|
|
2352
|
+
|
|
2353
|
+
// Column geometry (shared with the row layout below) so the header labels
|
|
2354
|
+
// sit directly above their columns instead of drifting to the far right.
|
|
2355
|
+
const labelW = Math.max(16, Math.min(36, Math.floor((width - 30) * 0.6)));
|
|
2356
|
+
const barW = Math.max(6, Math.min(20, width - labelW - 26));
|
|
2357
|
+
lines.push(this.tableHeader(title, labelW, barW, unitLabel));
|
|
2358
|
+
|
|
2359
|
+
if (rows.length === 0) {
|
|
2360
|
+
lines.push(` ${theme.fg("dim", "— none in this window —")}`);
|
|
2361
|
+
lines.push("");
|
|
2362
|
+
return;
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
const shown = rows.slice(0, limit);
|
|
2366
|
+
|
|
2367
|
+
for (const [key, bucket] of shown) {
|
|
2368
|
+
const value = bucketValue(bucket);
|
|
2369
|
+
const name = truncateToWidth(labelFn(key), labelW).padEnd(labelW);
|
|
2370
|
+
const pct = percent(value, total);
|
|
2371
|
+
const ratio = total > 0 ? value / total : 0;
|
|
2372
|
+
const filled = Math.max(ratio > 0 ? 1 : 0, Math.round(ratio * barW));
|
|
2373
|
+
const barStr =
|
|
2374
|
+
theme.fg("accent", "█".repeat(filled)) + theme.fg("borderMuted", "░".repeat(barW - filled));
|
|
2375
|
+
const pctStr = pct.padStart(4);
|
|
2376
|
+
lines.push(
|
|
2377
|
+
` ${theme.fg("text", name)} ${theme.fg("muted", pctStr)} ${barStr} ${theme.fg("dim", fmt(value))}`,
|
|
2378
|
+
);
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
const rest = rows.length - shown.length;
|
|
2382
|
+
if (rest > 0) {
|
|
2383
|
+
lines.push(` ${theme.fg("dim", `… +${rest} more`)}`);
|
|
2384
|
+
}
|
|
2385
|
+
lines.push("");
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
/**
|
|
2389
|
+
* Plugin usage section: ranks plugins by their attributed usage and shows the
|
|
2390
|
+
* specific skills/tools that drove each one, plus the "core" remainder
|
|
2391
|
+
* (turns with only builtin tools and no skill — i.e. plain pi usage).
|
|
2392
|
+
*
|
|
2393
|
+
* Plugins are independent characteristics: a single turn can credit several
|
|
2394
|
+
* plugins, so the percentages need not sum to 100. The core line is the
|
|
2395
|
+
* complement (turns attributed to NO plugin).
|
|
2396
|
+
*/
|
|
2397
|
+
private appendPluginUsageSection(
|
|
2398
|
+
lines: string[],
|
|
2399
|
+
win: WindowedReport,
|
|
2400
|
+
total: number,
|
|
2401
|
+
width: number,
|
|
2402
|
+
unit: "usd" | "tokens",
|
|
2403
|
+
): void {
|
|
2404
|
+
const { theme } = this.deps;
|
|
2405
|
+
const fmt = (n: number) => (unit === "tokens" ? formatTokens(n) : formatCost(n));
|
|
2406
|
+
const bucketValue = (b: Bucket) => (unit === "tokens" ? bucketTokens(b) : b.cost);
|
|
2407
|
+
|
|
2408
|
+
const rows = ranked(win.pluginDetail, (c) => bucketValue(c.bucket));
|
|
2409
|
+
const coreValue = bucketValue(win.byCore);
|
|
2410
|
+
|
|
2411
|
+
// Share the geometry with the other breakdown sections so columns and bars
|
|
2412
|
+
// line up, and give the value a fixed width so the "via" detail aligns.
|
|
2413
|
+
const labelW = Math.max(16, Math.min(36, Math.floor((width - 30) * 0.6)));
|
|
2414
|
+
const barW = Math.max(6, Math.min(20, width - labelW - 26));
|
|
2415
|
+
const valueW = 9;
|
|
2416
|
+
const unitLabel = unit === "tokens" ? "tokens" : "cost";
|
|
2417
|
+
|
|
2418
|
+
lines.push(this.tableHeader("Plugin usage", labelW, barW, unitLabel, "via", valueW));
|
|
2419
|
+
if (rows.length === 0 && coreValue <= 0) {
|
|
2420
|
+
lines.push(` ${theme.fg("dim", "— none in this window —")}`);
|
|
2421
|
+
lines.push("");
|
|
2422
|
+
return;
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
const renderRow = (name: string, value: number, detail?: string) => {
|
|
2426
|
+
const pct = total > 0 ? percent(value, total) : "0%";
|
|
2427
|
+
const ratio = total > 0 ? value / total : 0;
|
|
2428
|
+
const filled = Math.max(ratio > 0 ? 1 : 0, Math.round(ratio * barW));
|
|
2429
|
+
const barStr =
|
|
2430
|
+
theme.fg("accent", "█".repeat(filled)) + theme.fg("borderMuted", "░".repeat(barW - filled));
|
|
2431
|
+
const nm = truncateToWidth(name, labelW).padEnd(labelW);
|
|
2432
|
+
const valueStr = fmt(value).padEnd(valueW);
|
|
2433
|
+
// The "via" detail trails the fixed name/%/bar/value columns plus one
|
|
2434
|
+
// separating space (2-indent + labelW + 1 + 4 + 1 + barW + 1 + valueW + 1
|
|
2435
|
+
// = labelW + barW + valueW + 10). Tool/skill names can be long (e.g.
|
|
2436
|
+
// "firecrawl_firecrawl_scrape"), so truncate to the remaining width to
|
|
2437
|
+
// keep the row within the terminal and avoid a TUI width-overflow crash.
|
|
2438
|
+
const viaW = width - (labelW + barW + valueW) - 10;
|
|
2439
|
+
const detailStr =
|
|
2440
|
+
detail && viaW > 0 ? ` ${theme.fg("dim", truncateToWidth(detail, viaW))}` : "";
|
|
2441
|
+
lines.push(
|
|
2442
|
+
` ${theme.fg("text", nm)} ${theme.fg("muted", pct.padStart(4))} ${barStr} ${theme.fg("dim", valueStr)}${detailStr}`,
|
|
2443
|
+
);
|
|
2444
|
+
};
|
|
2445
|
+
|
|
2446
|
+
for (const [name, contrib] of rows.slice(0, 8)) {
|
|
2447
|
+
// Summarize which skills/tools of this plugin contributed.
|
|
2448
|
+
const parts: string[] = [];
|
|
2449
|
+
const topSkills = ranked(contrib.skills, (b) => bucketValue(b)).slice(0, 2);
|
|
2450
|
+
for (const [s] of topSkills) parts.push(s);
|
|
2451
|
+
const topTools = ranked(contrib.tools, (b) => bucketValue(b)).slice(0, 2);
|
|
2452
|
+
for (const [t] of topTools) parts.push(t);
|
|
2453
|
+
const detail = parts.length > 0 ? parts.join(", ") : undefined;
|
|
2454
|
+
renderRow(name, bucketValue(contrib.bucket), detail);
|
|
2455
|
+
}
|
|
2456
|
+
if (rows.length > 8) {
|
|
2457
|
+
lines.push(` ${theme.fg("dim", `… +${rows.length - 8} more`)}`);
|
|
2458
|
+
}
|
|
2459
|
+
// Core remainder: turns with no plugin attribution (builtin tools only).
|
|
2460
|
+
if (coreValue > 0) {
|
|
2461
|
+
renderRow("(core / no plugin)", coreValue, "builtin tools only");
|
|
2462
|
+
}
|
|
2463
|
+
lines.push("");
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
private portableFooterLine(width: number): string {
|
|
2467
|
+
const text = "Choose an action below to navigate, refresh, configure, page, or close.";
|
|
2468
|
+
return ` ${this.deps.theme.fg("dim", truncateToWidth(text, Math.max(10, width - 2)))}`;
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
private footerLine(width: number): string {
|
|
2472
|
+
const { theme } = this.deps;
|
|
2473
|
+
const view = this.state.view;
|
|
2474
|
+
const keys: Array<[string, string]> = [["⇥/←→", "views"]];
|
|
2475
|
+
if (view === "overview" || view === "models") {
|
|
2476
|
+
keys.push(["5/d/w/a", "window"]);
|
|
2477
|
+
} else if (view === "delegation") {
|
|
2478
|
+
// Delegation honours the window too (5 stays reserved for the Stats tab).
|
|
2479
|
+
keys.push(["d/w/a", "window"]);
|
|
2480
|
+
}
|
|
2481
|
+
if (view === "models") {
|
|
2482
|
+
keys.push(["c/n", "sort"]);
|
|
2483
|
+
}
|
|
2484
|
+
if (view === "daily") {
|
|
2485
|
+
keys.push(["t/c/d", "sort ±"]);
|
|
2486
|
+
}
|
|
2487
|
+
if (view === "stats") {
|
|
2488
|
+
keys.push(["a/w/m", "range"]);
|
|
2489
|
+
}
|
|
2490
|
+
if (view === "providers") {
|
|
2491
|
+
keys.push(["c/n", "sort"]);
|
|
2492
|
+
}
|
|
2493
|
+
if (view === "wrapped") {
|
|
2494
|
+
keys.push(["[ ]/y", "year"]);
|
|
2495
|
+
}
|
|
2496
|
+
keys.push(
|
|
2497
|
+
["1-8", "jump"],
|
|
2498
|
+
["r", "refresh"],
|
|
2499
|
+
["s", "limits"],
|
|
2500
|
+
["j/k", "scroll"],
|
|
2501
|
+
["q", "close"],
|
|
2502
|
+
);
|
|
2503
|
+
const sep = theme.fg("borderMuted", " · ");
|
|
2504
|
+
const render = (parts: Array<[string, string]>, labels: boolean) =>
|
|
2505
|
+
` ${parts
|
|
2506
|
+
.map(([k, label]) =>
|
|
2507
|
+
labels ? `${theme.fg("accent", k)} ${theme.fg("dim", label)}` : theme.fg("accent", k),
|
|
2508
|
+
)
|
|
2509
|
+
.join(sep)}`;
|
|
2510
|
+
let parts = keys;
|
|
2511
|
+
let line = render(parts, true);
|
|
2512
|
+
// Drop the least-critical hints until the footer fits the terminal.
|
|
2513
|
+
for (const key of ["j/k", "1-8", "s"]) {
|
|
2514
|
+
if (visibleWidth(line) <= width) break;
|
|
2515
|
+
parts = parts.filter(([k]) => k !== key);
|
|
2516
|
+
line = render(parts, true);
|
|
2517
|
+
}
|
|
2518
|
+
if (visibleWidth(line) > width) {
|
|
2519
|
+
line = render(parts, false);
|
|
2520
|
+
}
|
|
2521
|
+
return line;
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
function labelForWindow(key: WindowKey): string {
|
|
2526
|
+
switch (key) {
|
|
2527
|
+
case "5h":
|
|
2528
|
+
return "last 5 hours";
|
|
2529
|
+
case "24h":
|
|
2530
|
+
return "last 24 hours";
|
|
2531
|
+
case "7d":
|
|
2532
|
+
return "last 7 days";
|
|
2533
|
+
case "all":
|
|
2534
|
+
return "all time";
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
function relativeTime(ts: number): string {
|
|
2539
|
+
const diff = Date.now() - ts;
|
|
2540
|
+
const min = 60 * 1000;
|
|
2541
|
+
const hour = 60 * min;
|
|
2542
|
+
const day = 24 * hour;
|
|
2543
|
+
if (diff < min) return "just now";
|
|
2544
|
+
if (diff < hour) return `${Math.floor(diff / min)}m ago`;
|
|
2545
|
+
if (diff < day) return `${Math.floor(diff / hour)}h ago`;
|
|
2546
|
+
return `${Math.floor(diff / day)}d ago`;
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
/** Extract a short host from a base URL for the provider banner. */
|
|
2550
|
+
function hostFromUrl(url: string): string {
|
|
2551
|
+
if (!url) return "";
|
|
2552
|
+
try {
|
|
2553
|
+
const u = new URL(url);
|
|
2554
|
+
return u.host;
|
|
2555
|
+
} catch {
|
|
2556
|
+
return url.replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
/** Human countdown to a future epoch-ms timestamp. */
|
|
2561
|
+
function countdown(resetMs: number): string {
|
|
2562
|
+
const diff = resetMs - Date.now();
|
|
2563
|
+
if (diff <= 0) return "now";
|
|
2564
|
+
const s = Math.round(diff / 1000);
|
|
2565
|
+
if (s < 60) return `${s}s`;
|
|
2566
|
+
const m = Math.floor(s / 60);
|
|
2567
|
+
if (m < 60) return `${m}m${s % 60 ? ` ${s % 60}s` : ""}`;
|
|
2568
|
+
const h = Math.floor(m / 60);
|
|
2569
|
+
return `${h}h${m % 60 ? ` ${m % 60}m` : ""}`;
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
/** Format a tokens/second rate compactly (e.g. "47", "8.2", "1.2k"). */
|
|
2573
|
+
function formatRate(n: number): string {
|
|
2574
|
+
if (!Number.isFinite(n) || n <= 0) return "0";
|
|
2575
|
+
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
|
2576
|
+
if (n >= 100) return `${Math.round(n)}`;
|
|
2577
|
+
return n.toFixed(1);
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
/** Format a rate-limit count (tokens use k/M suffixes). */
|
|
2581
|
+
function formatLimit(n: number): string {
|
|
2582
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
2583
|
+
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
|
2584
|
+
return `${Math.round(n)}`;
|
|
2585
|
+
}
|