@latentminds/pi-quotas 0.2.4 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,102 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ } from "@mariozechner/pi-coding-agent";
5
+ import { aggregateAllSessions } from "../../lib/session-tokens.js";
6
+ import { TokensComponent } from "./tokens-display.js";
7
+
8
+ async function openTokensView(
9
+ ctx: ExtensionCommandContext,
10
+ cwd?: string,
11
+ ): Promise<void> {
12
+ const result = await ctx.ui.custom<null>((tui, theme, _kb, done) => {
13
+ const controller = new AbortController();
14
+ const component = new TokensComponent(
15
+ theme,
16
+ tui,
17
+ () => {
18
+ controller.abort();
19
+ done(null);
20
+ },
21
+ () => {
22
+ component.setState({ type: "loading" });
23
+ tui.requestRender();
24
+ void load();
25
+ },
26
+ cwd,
27
+ );
28
+
29
+ async function load(): Promise<void> {
30
+ try {
31
+ const aggregateResult = await aggregateAllSessions({ cwd });
32
+ if (controller.signal.aborted) return;
33
+ component.setState({ type: "loaded", result: aggregateResult });
34
+ tui.requestRender();
35
+ } catch {
36
+ if (controller.signal.aborted) return;
37
+ component.setState({
38
+ type: "loaded",
39
+ result: {
40
+ totals: {
41
+ input: 0,
42
+ output: 0,
43
+ cacheRead: 0,
44
+ cacheWrite: 0,
45
+ totalTokens: 0,
46
+ costTotal: 0,
47
+ },
48
+ byModel: [],
49
+ byProvider: [],
50
+ bySession: [],
51
+ sessionCount: 0,
52
+ messageCount: 0,
53
+ },
54
+ });
55
+ tui.requestRender();
56
+ }
57
+ }
58
+
59
+ void load();
60
+
61
+ return {
62
+ render: (width: number) => component.render(width),
63
+ invalidate: () => component.invalidate(),
64
+ handleInput: (data: string) => component.handleInput(data),
65
+ dispose: () => {
66
+ controller.abort();
67
+ component.destroy();
68
+ },
69
+ };
70
+ });
71
+
72
+ // Fallback for non-interactive mode
73
+ if (result === undefined) {
74
+ const aggregateResult = await aggregateAllSessions({ cwd });
75
+ const { totals } = aggregateResult;
76
+ const lines = [
77
+ `Token Usage: ${aggregateResult.sessionCount} sessions, ${aggregateResult.messageCount} messages`,
78
+ ` Input: ${totals.input.toLocaleString()} · Output: ${totals.output.toLocaleString()}`,
79
+ ` Cache Read: ${totals.cacheRead.toLocaleString()} · Cache Write: ${totals.cacheWrite.toLocaleString()}`,
80
+ ` Total: ${totals.totalTokens.toLocaleString()} tokens · $${totals.costTotal.toFixed(2)}`,
81
+ ];
82
+ for (const model of aggregateResult.byModel) {
83
+ lines.push(
84
+ ` ${model.provider}/${model.model}: $${model.tokens.costTotal.toFixed(2)} (${model.messageCount} msgs)`,
85
+ );
86
+ }
87
+ ctx.ui.notify(lines.join("\n"), "info");
88
+ }
89
+ }
90
+
91
+ export function registerTokensCommand(pi: ExtensionAPI): void {
92
+ pi.registerCommand("tokens", {
93
+ description: "Display token usage and cost across all sessions",
94
+ handler: async (_args, ctx) => {
95
+ await openTokensView(ctx, ctx.cwd);
96
+ },
97
+ });
98
+ }
99
+
100
+ export default async function (pi: ExtensionAPI) {
101
+ registerTokensCommand(pi);
102
+ }
@@ -0,0 +1 @@
1
+ export { default } from "./command.js";
@@ -0,0 +1,357 @@
1
+ import type { Theme } from "@mariozechner/pi-coding-agent";
2
+ import { DynamicBorder } from "@mariozechner/pi-coding-agent";
3
+ import type { Component } from "@mariozechner/pi-tui";
4
+ import { Loader, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
5
+ import pkg from "../../../package.json" with { type: "json" };
6
+ import type {
7
+ AggregateResult,
8
+ } from "../../lib/session-tokens.js";
9
+ import {
10
+ formatCost,
11
+ formatNumber,
12
+ formatTokenSummary,
13
+ } from "../../lib/session-tokens.js";
14
+
15
+ type TokensState =
16
+ | { type: "loading" }
17
+ | { type: "loaded"; result: AggregateResult };
18
+
19
+ type TabId = "overview" | "models" | "sessions";
20
+
21
+ const TABS: Array<{ id: TabId; label: string }> = [
22
+ { id: "overview", label: "Overview" },
23
+ { id: "models", label: "Models" },
24
+ { id: "sessions", label: "Sessions" },
25
+ ];
26
+
27
+ function renderProgressBar(
28
+ value: number,
29
+ max: number,
30
+ width: number,
31
+ theme: Theme,
32
+ ): string {
33
+ if (max <= 0 || value <= 0) return theme.fg("dim", "░".repeat(width));
34
+ const ratio = Math.min(1, value / max);
35
+ const filled = Math.round(ratio * width);
36
+ const parts: string[] = [];
37
+ for (let i = 0; i < width; i++) {
38
+ parts.push(i < filled ? theme.fg("accent", "█") : theme.fg("dim", "░"));
39
+ }
40
+ return parts.join("");
41
+ }
42
+
43
+ export class TokensComponent implements Component {
44
+ private state: TokensState = { type: "loading" };
45
+ private loader: Loader | null = null;
46
+ private activeTab: TabId = "overview";
47
+ private scrollOffset = 0;
48
+
49
+ constructor(
50
+ private theme: Theme,
51
+ private tui: any,
52
+ private onClose: () => void,
53
+ private onRefetch: () => void,
54
+ private cwd?: string,
55
+ ) {
56
+ this.startLoader();
57
+ }
58
+
59
+ private startLoader(): void {
60
+ this.loader = new Loader(
61
+ this.tui,
62
+ (s: string) => this.theme.fg("accent", s),
63
+ (s: string) => this.theme.fg("muted", s),
64
+ "Scanning sessions...",
65
+ );
66
+ }
67
+
68
+ destroy(): void {
69
+ this.loader?.stop();
70
+ this.loader = null;
71
+ }
72
+
73
+ setState(state: TokensState): void {
74
+ if (state.type === "loading") {
75
+ this.loader?.stop();
76
+ this.startLoader();
77
+ this.scrollOffset = 0;
78
+ } else if (this.state.type === "loading") {
79
+ this.loader?.stop();
80
+ this.loader = null;
81
+ }
82
+ this.state = state;
83
+ }
84
+
85
+ handleInput(data: string): boolean {
86
+ if (matchesKey(data, "escape") || data === "q") {
87
+ this.onClose();
88
+ return true;
89
+ }
90
+ if (data === "r") {
91
+ this.onRefetch();
92
+ return true;
93
+ }
94
+ if (data === "\t" || data === "right") {
95
+ const idx = TABS.findIndex((t) => t.id === this.activeTab);
96
+ this.activeTab = TABS[(idx + 1) % TABS.length].id;
97
+ this.scrollOffset = 0;
98
+ this.tui.requestRender();
99
+ return true;
100
+ }
101
+ if (data === "left") {
102
+ const idx = TABS.findIndex((t) => t.id === this.activeTab);
103
+ this.activeTab = TABS[(idx - 1 + TABS.length) % TABS.length].id;
104
+ this.scrollOffset = 0;
105
+ this.tui.requestRender();
106
+ return true;
107
+ }
108
+ if (data === "1") {
109
+ this.activeTab = "overview";
110
+ this.scrollOffset = 0;
111
+ this.tui.requestRender();
112
+ return true;
113
+ }
114
+ if (data === "2") {
115
+ this.activeTab = "models";
116
+ this.scrollOffset = 0;
117
+ this.tui.requestRender();
118
+ return true;
119
+ }
120
+ if (data === "3") {
121
+ this.activeTab = "sessions";
122
+ this.scrollOffset = 0;
123
+ this.tui.requestRender();
124
+ return true;
125
+ }
126
+ if (matchesKey(data, "down") || data === "j") {
127
+ this.scrollOffset++;
128
+ this.tui.requestRender();
129
+ return true;
130
+ }
131
+ if (matchesKey(data, "up") || data === "k") {
132
+ this.scrollOffset = Math.max(0, this.scrollOffset - 1);
133
+ this.tui.requestRender();
134
+ return true;
135
+ }
136
+ return false;
137
+ }
138
+
139
+ render(width: number): string[] {
140
+ const lines: string[] = [];
141
+ const border = new DynamicBorder((s: string) => this.theme.fg("border", s));
142
+ lines.push(...border.render(width));
143
+ lines.push(
144
+ truncateToWidth(
145
+ ` ${this.theme.fg("accent", this.theme.bold("Token Usage"))}`,
146
+ width,
147
+ ),
148
+ );
149
+
150
+ // Tab bar
151
+ const tabParts = TABS.map((tab) => {
152
+ if (tab.id === this.activeTab) {
153
+ return this.theme.fg("accent", this.theme.bold(`[${tab.label}]`));
154
+ }
155
+ return this.theme.fg("dim", ` ${tab.label} `);
156
+ });
157
+ lines.push(truncateToWidth(` ${tabParts.join(" ")}`, width));
158
+ lines.push("");
159
+
160
+ if (this.state.type === "loading") {
161
+ lines.push(
162
+ ...(this.loader
163
+ ? this.loader.render(width)
164
+ : [this.theme.fg("muted", " Scanning sessions...")]),
165
+ );
166
+ } else {
167
+ const contentLines = this.renderTab(width);
168
+ // Apply scroll
169
+ const maxVisible = Math.max(1, 20); // approximate visible lines
170
+ const visible = contentLines.slice(
171
+ this.scrollOffset,
172
+ this.scrollOffset + maxVisible,
173
+ );
174
+ lines.push(...visible);
175
+ }
176
+
177
+ lines.push("");
178
+ lines.push(
179
+ this.theme.fg(
180
+ "dim",
181
+ ` pi-quotas v${pkg.version} · 1/2/3 switch tab r refresh q/Esc close`,
182
+ ),
183
+ );
184
+ lines.push(...border.render(width));
185
+ return lines;
186
+ }
187
+
188
+ private renderTab(width: number): string[] {
189
+ if (this.state.type !== "loaded") return [];
190
+ const { result } = this.state;
191
+
192
+ switch (this.activeTab) {
193
+ case "overview":
194
+ return this.renderOverview(result, width);
195
+ case "models":
196
+ return this.renderModels(result, width);
197
+ case "sessions":
198
+ return this.renderSessions(result, width);
199
+ }
200
+ }
201
+
202
+ private renderOverview(result: AggregateResult, width: number): string[] {
203
+ const lines: string[] = [];
204
+ const { totals } = result;
205
+
206
+ lines.push(
207
+ truncateToWidth(
208
+ ` ${this.theme.fg("accent", this.theme.bold("Summary"))}`,
209
+ width,
210
+ ),
211
+ );
212
+ lines.push("");
213
+
214
+ const statRows = [
215
+ ["Sessions", String(result.sessionCount)],
216
+ ["Messages", String(result.messageCount)],
217
+ ["Input tokens", formatNumber(totals.input)],
218
+ ["Output tokens", formatNumber(totals.output)],
219
+ ["Cache read", formatNumber(totals.cacheRead)],
220
+ ["Cache write", formatNumber(totals.cacheWrite)],
221
+ ["Total tokens", formatNumber(totals.totalTokens)],
222
+ ["Total cost", formatCost(totals.costTotal)],
223
+ ];
224
+
225
+ const labelWidth = Math.max(...statRows.map(([l]) => l.length));
226
+ for (const [label, value] of statRows) {
227
+ lines.push(
228
+ truncateToWidth(
229
+ ` ${this.theme.fg("dim", label.padEnd(labelWidth))} ${this.theme.fg("accent", value)}`,
230
+ width,
231
+ ),
232
+ );
233
+ }
234
+
235
+ // Provider breakdown
236
+ if (result.byProvider.length > 0) {
237
+ lines.push("");
238
+ lines.push(
239
+ truncateToWidth(
240
+ ` ${this.theme.fg("accent", this.theme.bold("By Provider"))}`,
241
+ width,
242
+ ),
243
+ );
244
+ lines.push("");
245
+ const maxCost = Math.max(
246
+ ...result.byProvider.map((p) => p.tokens.costTotal),
247
+ );
248
+ for (const provider of result.byProvider) {
249
+ const barWidth = Math.min(20, Math.max(8, width - 40));
250
+ const bar = renderProgressBar(
251
+ provider.tokens.costTotal,
252
+ maxCost,
253
+ barWidth,
254
+ this.theme,
255
+ );
256
+ const cost = formatCost(provider.tokens.costTotal);
257
+ const msgs = `${provider.messageCount} msgs`;
258
+ lines.push(
259
+ truncateToWidth(
260
+ ` ${this.theme.fg("accent", provider.provider.padEnd(20))} ${bar} ${this.theme.fg("accent", cost)} ${this.theme.fg("dim", msgs)}`,
261
+ width,
262
+ ),
263
+ );
264
+ }
265
+ }
266
+
267
+ return lines;
268
+ }
269
+
270
+ private renderModels(result: AggregateResult, width: number): string[] {
271
+ const lines: string[] = [];
272
+
273
+ if (result.byModel.length === 0) {
274
+ lines.push(this.theme.fg("dim", " No assistant messages found"));
275
+ return lines;
276
+ }
277
+
278
+ const maxCost = Math.max(...result.byModel.map((m) => m.tokens.costTotal));
279
+
280
+ for (const model of result.byModel) {
281
+ const barWidth = Math.min(16, Math.max(8, width - 50));
282
+ const bar = renderProgressBar(
283
+ model.tokens.costTotal,
284
+ maxCost,
285
+ barWidth,
286
+ this.theme,
287
+ );
288
+ const cost = formatCost(model.tokens.costTotal);
289
+ const label =
290
+ model.provider === model.model
291
+ ? model.model
292
+ : `${model.provider}/${model.model}`;
293
+ const shortLabel = label.length > 30 ? label.slice(0, 27) + "..." : label;
294
+
295
+ lines.push(
296
+ truncateToWidth(` ${this.theme.fg("accent", shortLabel)}`, width),
297
+ );
298
+ lines.push(
299
+ truncateToWidth(
300
+ ` ${bar} ${this.theme.fg("accent", cost)} ${this.theme.fg("dim", formatTokenSummary(model.tokens))}`,
301
+ width,
302
+ ),
303
+ );
304
+ }
305
+
306
+ return lines;
307
+ }
308
+
309
+ private renderSessions(result: AggregateResult, width: number): string[] {
310
+ const lines: string[] = [];
311
+
312
+ if (result.bySession.length === 0) {
313
+ lines.push(this.theme.fg("dim", " No sessions found"));
314
+ return lines;
315
+ }
316
+
317
+ const maxCost = Math.max(
318
+ ...result.bySession.map((s) => s.tokens.costTotal),
319
+ );
320
+
321
+ for (const session of result.bySession) {
322
+ const dateStr = session.created.toISOString().slice(0, 10);
323
+ const name = session.name ?? session.sessionId.slice(0, 8);
324
+ const cost = formatCost(session.tokens.costTotal);
325
+ const msgs = `${session.messageCount} msgs`;
326
+
327
+ const barWidth = Math.min(12, Math.max(6, width - 50));
328
+ const bar = renderProgressBar(
329
+ session.tokens.costTotal,
330
+ maxCost,
331
+ barWidth,
332
+ this.theme,
333
+ );
334
+
335
+ lines.push(
336
+ truncateToWidth(
337
+ ` ${this.theme.fg("dim", dateStr)} ${this.theme.fg("accent", name)} ${bar} ${this.theme.fg("accent", cost)} ${this.theme.fg("dim", msgs)}`,
338
+ width,
339
+ ),
340
+ );
341
+ }
342
+
343
+ if (result.sessionCount > result.bySession.length) {
344
+ lines.push("");
345
+ lines.push(
346
+ this.theme.fg(
347
+ "dim",
348
+ ` Showing ${result.bySession.length} of ${result.sessionCount} sessions`,
349
+ ),
350
+ );
351
+ }
352
+
353
+ return lines;
354
+ }
355
+
356
+ invalidate(): void {}
357
+ }
@@ -1,4 +1,7 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ } from "@mariozechner/pi-coding-agent";
2
5
  import {
3
6
  QUOTAS_CONFIG_UPDATED_EVENT,
4
7
  QUOTAS_EXTENSIONS_REGISTER_EVENT,
@@ -6,7 +9,11 @@ import {
6
9
  type QuotasConfigUpdatedPayload,
7
10
  configLoader,
8
11
  } from "../../config.js";
9
- import { fetchProviderQuotas, isSupportedProvider } from "../../lib/quotas.js";
12
+ import {
13
+ fetchProviderQuotas,
14
+ isSupportedProvider,
15
+ PROVIDER_LABELS,
16
+ } from "../../lib/quotas.js";
10
17
  import {
11
18
  assessWindow,
12
19
  formatTimeRemaining,
@@ -24,9 +31,11 @@ function shouldNotify(key: string, severity: RiskSeverity): boolean {
24
31
  const current = alertState.get(key);
25
32
  if (!current) return true;
26
33
  const order: RiskSeverity[] = ["none", "warning", "high", "critical"];
27
- if (order.indexOf(severity) > order.indexOf(current.lastSeverity)) return true;
34
+ if (order.indexOf(severity) > order.indexOf(current.lastSeverity))
35
+ return true;
28
36
  if (severity === "high" || severity === "critical") return true;
29
- if (severity === "warning") return Date.now() - current.lastNotifiedAt >= COOLDOWN_MS;
37
+ if (severity === "warning")
38
+ return Date.now() - current.lastNotifiedAt >= COOLDOWN_MS;
30
39
  return false;
31
40
  }
32
41
 
@@ -50,7 +59,10 @@ export default async function (pi: ExtensionAPI) {
50
59
  if (onlyNew && now - lastFetchAt < MIN_FETCH_INTERVAL_MS) return;
51
60
  lastFetchAt = now;
52
61
 
53
- const result = await fetchProviderQuotas(ctx.modelRegistry.authStorage, provider);
62
+ const result = await fetchProviderQuotas(
63
+ ctx.modelRegistry.authStorage,
64
+ provider,
65
+ );
54
66
  if (!result.success) return;
55
67
 
56
68
  const risky = result.data.windows
@@ -59,19 +71,23 @@ export default async function (pi: ExtensionAPI) {
59
71
  if (risky.length === 0) return;
60
72
 
61
73
  const toNotify = onlyNew
62
- ? risky.filter((entry) => shouldNotify(`${provider}:${entry.window.label}`, entry.assessment.severity))
74
+ ? risky.filter((entry) =>
75
+ shouldNotify(
76
+ `${provider}:${entry.window.label}`,
77
+ entry.assessment.severity,
78
+ ),
79
+ )
63
80
  : risky;
64
81
  if (toNotify.length === 0) return;
65
82
 
66
83
  for (const entry of toNotify) {
67
- markNotified(`${provider}:${entry.window.label}`, entry.assessment.severity);
84
+ markNotified(
85
+ `${provider}:${entry.window.label}`,
86
+ entry.assessment.severity,
87
+ );
68
88
  }
69
89
 
70
- const providerName = provider === "openai-codex"
71
- ? "Codex"
72
- : provider === "github-copilot"
73
- ? "GitHub Copilot"
74
- : "Anthropic";
90
+ const providerName = PROVIDER_LABELS[provider];
75
91
 
76
92
  const lines = toNotify.map(({ window, assessment }) => {
77
93
  const projected = Math.round(assessment.projectedPercent);
@@ -79,12 +95,22 @@ export default async function (pi: ExtensionAPI) {
79
95
  return `- ${window.label}: ${used}% used, projected ${projected}% (${assessment.severity}), resets in ${formatTimeRemaining(window.resetsAt)}`;
80
96
  });
81
97
 
82
- const level = toNotify.some((entry) => entry.assessment.severity === "critical" || entry.assessment.severity === "high")
98
+ const level = toNotify.some(
99
+ (entry) =>
100
+ entry.assessment.severity === "critical" ||
101
+ entry.assessment.severity === "high",
102
+ )
83
103
  ? "error"
84
104
  : "warning";
85
105
  ctx.ui.notify(`${providerName} quota warning:\n${lines.join("\n")}`, level);
86
106
  }
87
107
 
108
+ function scheduleCheck(ctx: ExtensionContext, onlyNew: boolean): void {
109
+ void check(ctx, onlyNew).catch(() => {
110
+ // Quota warnings are opportunistic; never let a failed quota check block Pi events.
111
+ });
112
+ }
113
+
88
114
  pi.events.on(QUOTAS_CONFIG_UPDATED_EVENT, (data: unknown) => {
89
115
  enabled = (data as QuotasConfigUpdatedPayload).config.quotaWarnings;
90
116
  if (!enabled) {
@@ -93,21 +119,21 @@ export default async function (pi: ExtensionAPI) {
93
119
  }
94
120
  if (currentContext) {
95
121
  clearAlertState();
96
- void check(currentContext, false);
122
+ scheduleCheck(currentContext, false);
97
123
  }
98
124
  });
99
125
 
100
- pi.on("session_start", async (_event, ctx) => {
126
+ pi.on("session_start", (_event, ctx) => {
101
127
  currentContext = ctx;
102
128
  clearAlertState();
103
129
  if (!enabled) return;
104
- await check(ctx, false);
130
+ scheduleCheck(ctx, false);
105
131
  });
106
132
 
107
- pi.on("turn_end", async (_event, ctx) => {
133
+ pi.on("turn_end", (_event, ctx) => {
108
134
  currentContext = ctx;
109
135
  if (!enabled) return;
110
- await check(ctx, true);
136
+ scheduleCheck(ctx, true);
111
137
  });
112
138
 
113
139
  pi.on("model_select", async (_event, ctx) => {
@@ -122,7 +148,9 @@ export default async function (pi: ExtensionAPI) {
122
148
 
123
149
  pi.events.on(QUOTAS_EXTENSIONS_REQUEST_EVENT, () => {
124
150
  if (configLoader.getConfig().quotaWarnings) {
125
- pi.events.emit(QUOTAS_EXTENSIONS_REGISTER_EVENT, { feature: "quotaWarnings" });
151
+ pi.events.emit(QUOTAS_EXTENSIONS_REGISTER_EVENT, {
152
+ feature: "quotaWarnings",
153
+ });
126
154
  }
127
155
  });
128
156
  }