@marcosgilf/pi-usage 0.1.0 → 0.2.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/README.md CHANGED
@@ -1,63 +1,25 @@
1
1
  # 📊 pi-usage
2
2
 
3
- [![npm](https://img.shields.io/npm/v/@marcosgilf/pi-usage)](https://www.npmjs.com/package/@marcosgilf/pi-usage) [![Pi extension](https://img.shields.io/badge/Pi-extension-blue)](https://pi.dev) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
3
+ Show GitHub Copilot plan and usage percentage in Pi's default footer, with a modal for quota details.
4
4
 
5
- Show GitHub Copilot plan and quota usage in Pi.
6
-
7
- ## ✨ Features
8
-
9
- - Show Copilot plan and usage beside Pi's model in the footer.
10
- - Open a quota modal by clicking the footer usage in fullscreen mode, running `/copilot-usage`, or pressing `super+shift+u`.
11
- - Refresh modal data with `r` and copy a plain-text summary with `c`.
12
- - Hide the progress bar for unlimited plans.
13
-
14
- ## 📦 Install
15
-
16
- Install the published package:
5
+ ## Install
17
6
 
18
7
  ```sh
19
8
  pi install npm:@marcosgilf/pi-usage
20
9
  ```
21
10
 
22
- For one-run testing, use `pi -e npm:@marcosgilf/pi-usage`. To load a local checkout, run `npm install` in the monorepo, then `pi -e ./packages/pi-usage`.
11
+ - When installed alone, the default footer shows Copilot plan and percentage.
12
+ - `/copilot-usage` opens quota details.
13
+ - `super+shift+u` opens the same modal.
14
+ - In modal: `c` copies full summary; Ctrl/Cmd+C copies selected text; click outside closes.
15
+ - Install `@marcosgilf/pi-footer` to place usage beside the model and coordinate it with session context. It loads this extension as a dependency; do not install both packages separately.
23
16
 
24
17
  Pi extensions run with Pi's operating-system permissions. Review package source before installing.
25
18
 
26
- ## 🚀 Quick start
27
-
28
- Install `gh`, sign in to GitHub, and grant the `user` OAuth scope if needed:
19
+ The quota request uses GitHub's undocumented `copilot_internal/user` endpoint through `gh`. Install and sign in to GitHub CLI. The `user` OAuth scope may be required:
29
20
 
30
21
  ```sh
31
22
  gh auth refresh -h github.com -s user
32
23
  ```
33
24
 
34
- Start Pi. The footer shows the plan and quota percentage. Run `/copilot-usage` for the modal.
35
-
36
- ## 💬 Commands
37
-
38
- - `/copilot-usage` opens the modal and refreshes account quota.
39
- - `super+shift+u` opens the same modal.
40
- - In the modal, `r` refreshes data and `c` copies plain text. Esc or Enter closes it.
41
-
42
- The quota request uses GitHub's undocumented `copilot_internal/user` endpoint through `gh`. GitHub may change this endpoint.
43
-
44
- ## 🚧 Limitations
45
-
46
- - Footer mouse clicks work in Pi fullscreen mode. Use the command or shortcut in regular mode.
47
- - Terminal mouse selection copies the rendered modal layout. Press `c` to copy plain text.
48
- - Clipboard copy uses macOS `pbcopy`.
49
-
50
- ## 🗂️ Package layout
51
-
52
- - `src/index.ts` is the Pi extension entrypoint.
53
- - `src/copilot-usage.ts` registers commands, footer, and modal behavior.
54
- - `src/core.ts` parses and formats Copilot quota and session data.
55
- - `test/` contains Vitest tests.
56
-
57
- ## 🔎 Keywords
58
-
59
- `pi-package`, `pi-extension`, `github-copilot`, `usage`, `quota`
60
-
61
- ## 📄 License
62
-
63
- MIT. See [`LICENSE`](./LICENSE).
25
+ GitHub may change this endpoint.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marcosgilf/pi-usage",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Show GitHub Copilot plan and quota usage in Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -19,11 +19,18 @@
19
19
  "README.md",
20
20
  "LICENSE"
21
21
  ],
22
+ "exports": {
23
+ ".": "./src/index.ts",
24
+ "./events": "./src/events.ts"
25
+ },
22
26
  "pi": {
23
27
  "extensions": [
24
28
  "./src/index.ts"
25
29
  ]
26
30
  },
31
+ "dependencies": {
32
+ "@marcosgilf/pi-modal": "^0.1.0"
33
+ },
27
34
  "peerDependencies": {
28
35
  "@earendil-works/pi-coding-agent": "*",
29
36
  "@earendil-works/pi-tui": "*"
@@ -1,29 +1,17 @@
1
1
  import { execFile } from "node:child_process";
2
- import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
3
- import { type TuiMouseEvent, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
- import {
5
- type CopilotUsage,
6
- formatPlanUsage,
7
- formatProgressBar,
8
- formatTokens,
9
- formatUsageSummary,
10
- getUsageProgress,
11
- parseCopilotUsage,
12
- summarizeSession,
13
- writeClipboard,
14
- } from "./core.ts";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { type ModalContent, type ModalRow, showModal } from "@marcosgilf/pi-modal";
4
+ import { type CopilotUsage, formatPlanUsage, formatUsageSummary, getUsageProgress, parseCopilotUsage } from "./core.ts";
5
+ import { COPILOT_USAGE_STATUS_KEY, OPEN_COPILOT_USAGE_EVENT } from "./events.ts";
15
6
 
16
7
  const CACHE_MS = 5 * 60 * 1000;
17
- const FOOTER_STATUS_KEY = "copilot-usage";
18
8
 
19
9
  type UsageSnapshot = CopilotUsage & { fetchedAt: number };
20
- type FooterRow = { text: string; style?: "accent" | "success" | "warning"; progress?: number };
21
10
 
22
11
  let cachedUsage: UsageSnapshot | undefined;
23
12
  let lastError: string | undefined;
24
13
  let lastAttempt = 0;
25
14
  let pending: Promise<UsageSnapshot> | undefined;
26
- let requestFooterRender: (() => void) | undefined;
27
15
 
28
16
  function runGh(args: string[]): Promise<string> {
29
17
  return new Promise((resolve, reject) => {
@@ -71,12 +59,10 @@ async function refreshUsage(force = false): Promise<UsageSnapshot> {
71
59
  .then((usage) => {
72
60
  cachedUsage = { ...usage, fetchedAt: Date.now() };
73
61
  lastError = undefined;
74
- requestFooterRender?.();
75
62
  return cachedUsage;
76
63
  })
77
64
  .catch((error: unknown) => {
78
65
  lastError = error instanceof Error ? error.message : String(error);
79
- requestFooterRender?.();
80
66
  throw new Error(lastError);
81
67
  })
82
68
  .finally(() => {
@@ -85,147 +71,10 @@ async function refreshUsage(force = false): Promise<UsageSnapshot> {
85
71
  return pending;
86
72
  }
87
73
 
88
- function addCustomFooter(ctx: ExtensionContext): void {
89
- ctx.ui.setFooter((tui, theme, footerData) => {
90
- let disposed = false;
91
- const requestRender = () => {
92
- if (!disposed) tui.requestRender();
93
- };
94
- const unsubscribe = footerData.onBranchChange(requestRender);
95
- requestFooterRender = requestRender;
96
- let statsCache: { leafId: string | null; stats: ReturnType<typeof summarizeSession> } | undefined;
97
- let usageClickBounds: { left: number; right: number } | undefined;
98
- const getStats = () => {
99
- const leafId = ctx.sessionManager.getLeafId();
100
- if (statsCache?.leafId === leafId) return statsCache.stats;
101
- const stats = summarizeSession(ctx.sessionManager.getEntries());
102
- statsCache = { leafId, stats };
103
- return stats;
104
- };
105
-
106
- return {
107
- dispose() {
108
- disposed = true;
109
- unsubscribe();
110
- if (requestFooterRender === requestRender) requestFooterRender = undefined;
111
- },
112
- handleMouse(event: TuiMouseEvent) {
113
- if (
114
- event.button !== "left" ||
115
- event.y !== 1 ||
116
- !usageClickBounds ||
117
- event.x < usageClickBounds.left ||
118
- event.x >= usageClickBounds.right
119
- )
120
- return;
121
- if (event.type === "click") void showUsage(ctx);
122
- if (event.type === "press" || event.type === "release" || event.type === "click") {
123
- return { handled: true, render: event.type === "click" };
124
- }
125
- },
126
- invalidate() {},
127
- render(width: number) {
128
- const stats = getStats();
129
- const context = ctx.getContextUsage();
130
- const leftParts: string[] = [];
131
- const input = stats.input + stats.cacheRead + stats.cacheWrite;
132
- if (input) leftParts.push(`↑${formatTokens(input)}`);
133
- if (stats.output) leftParts.push(`↓${formatTokens(stats.output)}`);
134
- if (stats.cacheRead) leftParts.push(`R${formatTokens(stats.cacheRead)}`);
135
- if (stats.cacheWrite) leftParts.push(`W${formatTokens(stats.cacheWrite)}`);
136
- if (stats.cost) leftParts.push(`$${stats.cost.toFixed(3)}`);
137
- if (context) {
138
- const percent = context.percent === null ? "?" : `${context.percent.toFixed(1)}%`;
139
- leftParts.push(`${percent}/${formatTokens(context.contextWindow)}`);
140
- }
141
- let left = theme.fg("dim", leftParts.join(" "));
142
-
143
- const model = ctx.model;
144
- const modelName = model?.name || model?.id || "no-model";
145
- const thinking = model?.reasoning ? ` • ${ctx.thinkingLevel || "off"}` : "";
146
- let modelText = `${modelName}${thinking}`;
147
- if (footerData.getAvailableProviderCount() > 1 && model) modelText = `(${model.provider}) ${modelText}`;
148
- const usageText = formatPlanUsage(cachedUsage);
149
- const usageStyle =
150
- cachedUsage?.percentUsed !== undefined && cachedUsage.percentUsed >= 90 ? "warning" : "accent";
151
- const usageLabel = usageText ? theme.fg(usageStyle, usageText) : "";
152
- const right = usageLabel ? `${usageLabel} · ${modelText}` : modelText;
153
-
154
- const clippedRight = truncateToWidth(right, width, "");
155
- const clippedRightWidth = visibleWidth(clippedRight);
156
- const usageLabelWidth = visibleWidth(usageLabel);
157
- usageClickBounds =
158
- usageLabelWidth > 0
159
- ? {
160
- left: width - clippedRightWidth,
161
- right: width - clippedRightWidth + Math.min(usageLabelWidth, clippedRightWidth),
162
- }
163
- : undefined;
164
- const leftWidth = Math.max(0, width - clippedRightWidth - 2);
165
- left = leftWidth > 0 ? truncateToWidth(left, leftWidth, "...") : "";
166
- const padding = " ".repeat(Math.max(0, width - visibleWidth(left) - clippedRightWidth));
167
- const statsLine = `${left}${padding}${clippedRight}`;
168
-
169
- const home = process.env.HOME || "";
170
- let cwd = home && ctx.cwd.startsWith(`${home}/`) ? `~${ctx.cwd.slice(home.length)}` : ctx.cwd;
171
- const branch = footerData.getGitBranch();
172
- if (branch) cwd += ` (${branch})`;
173
- const sessionName = ctx.sessionManager.getSessionName();
174
- if (sessionName) cwd += ` • ${sessionName}`;
175
- const lines = [truncateToWidth(theme.fg("dim", cwd), width, theme.fg("dim", "...")), statsLine];
176
- const statuses = Array.from(footerData.getExtensionStatuses().entries())
177
- .filter(([key]) => key !== FOOTER_STATUS_KEY)
178
- .sort(([a], [b]) => a.localeCompare(b))
179
- .map(([, text]) => text);
180
- if (statuses.length) lines.push(truncateToWidth(statuses.join(" "), width, theme.fg("dim", "...")));
181
- return lines;
182
- },
183
- };
184
- });
185
- }
186
-
187
- function copyToClipboard(text: string): Promise<void> {
188
- if (process.platform !== "darwin") return Promise.reject(new Error("Clipboard copy requires macOS."));
189
- return writeClipboard(text);
190
- }
191
-
192
- function borderedRows(rows: FooterRow[], width: number, theme: Theme): string[] {
193
- if (width < 6)
194
- return rows.map(({ text, progress }) =>
195
- truncateToWidth(progress === undefined ? text : formatProgressBar(progress, width), width, "…"),
196
- );
197
- const contentWidth = width - 4;
198
- const edge = theme.fg("muted", "│");
199
- const top = theme.fg("muted", `╭${"─".repeat(contentWidth + 2)}╮`);
200
- const bottom = theme.fg("muted", `╰${"─".repeat(contentWidth + 2)}╯`);
201
- const content = rows.map(({ text, style, progress }) => {
202
- const value = progress === undefined ? text : formatProgressBar(progress, contentWidth);
203
- const styled = style === "accent" ? theme.fg(style, theme.bold(value)) : style ? theme.fg(style, value) : value;
204
- const clipped = truncateToWidth(styled, contentWidth, "…");
205
- const padding = " ".repeat(Math.max(0, contentWidth - visibleWidth(clipped)));
206
- return `${edge}${theme.bg("selectedBg", ` ${clipped}${padding} `)}${edge}`;
207
- });
208
- return [top, ...content, bottom];
209
- }
210
-
211
- function modalOverlayRows(rows: FooterRow[], width: number, height: number, theme: Theme): string[] {
212
- const panelWidth = Math.min(width, 74);
213
- const panel = borderedRows(rows, panelWidth, theme);
214
- const left = Math.floor((width - panelWidth) / 2);
215
- const top = Math.max(0, Math.floor((height - panel.length) / 2));
216
- const screen = Array.from({ length: height }, () => " ".repeat(width));
217
- panel.forEach((line, index) => {
218
- const row = top + index;
219
- if (row < height)
220
- screen[row] = `${" ".repeat(left)}${line}${" ".repeat(Math.max(0, width - left - visibleWidth(line)))}`;
221
- });
222
- return screen;
223
- }
224
-
225
- function buildUsageModal(usage: CopilotUsage): { rows: FooterRow[]; clipboardText: string } {
74
+ function buildCopilotUsageModal(usage: CopilotUsage): ModalContent {
226
75
  const [title, planText, quotaText, resetText] = formatUsageSummary(usage);
227
76
  const progress = getUsageProgress(usage);
228
- const rows: FooterRow[] = [
77
+ const rows: ModalRow[] = [
229
78
  { text: title, style: "accent" },
230
79
  { text: "" },
231
80
  { text: planText },
@@ -234,99 +83,90 @@ function buildUsageModal(usage: CopilotUsage): { rows: FooterRow[]; clipboardTex
234
83
  ];
235
84
  if (progress === undefined) rows.push({ text: "" });
236
85
  else rows.push({ text: "" }, { text: "", progress, style: progress >= 90 ? "warning" : "success" }, { text: "" });
237
- rows.push({ text: resetText }, { text: "" }, { text: "r refreshes · c copies plain text · Esc/Enter closes" });
86
+ rows.push({ text: resetText });
238
87
  return { rows, clipboardText: [title, planText, quotaText, resetText].join("\n") };
239
88
  }
240
89
 
241
- async function showUsage(ctx: ExtensionContext): Promise<void> {
90
+ async function showCopilotUsage(ctx: ExtensionContext, isCurrent: () => boolean): Promise<void> {
91
+ const unavailableMessage = "/copilot-usage requires Pi TUI mode.";
242
92
  if (ctx.mode !== "tui") {
243
- ctx.ui.notify("/copilot-usage requires Pi TUI mode.", "warning");
93
+ ctx.ui.notify(unavailableMessage, "warning");
244
94
  return;
245
95
  }
246
96
 
247
97
  try {
248
- let content = buildUsageModal(await refreshUsage(true));
249
- await ctx.ui.custom(
250
- (tui, theme, keybindings, done) => ({
251
- render(width: number) {
252
- // Mask transcript rows beneath overlay; TUI selection copies rendered screen cells.
253
- return modalOverlayRows(content.rows, width, tui.terminal.rows, theme);
254
- },
255
- invalidate() {},
256
- handleInput(data: string) {
257
- if (keybindings.matches(data, "app.interrupt") || keybindings.matches(data, "tui.input.submit")) {
258
- done(undefined);
259
- return;
260
- }
261
- const key = data.toLowerCase();
262
- if (key === "r") {
263
- content.rows[content.rows.length - 1] = { text: "Refreshing usage…" };
264
- tui.requestRender();
265
- void refreshUsage(true)
266
- .then((usage) => {
267
- content = buildUsageModal(usage);
268
- tui.requestRender();
269
- })
270
- .catch((error: unknown) => {
271
- const message = error instanceof Error ? error.message : String(error);
272
- content.rows[content.rows.length - 1] = { text: `Refresh failed: ${message}`, style: "warning" };
273
- tui.requestRender();
274
- });
275
- return;
276
- }
277
- if (key === "c") {
278
- void copyToClipboard(content.clipboardText)
279
- .then(() => {
280
- content.rows[content.rows.length - 1] = { text: "Copied plain text", style: "success" };
281
- tui.requestRender();
282
- })
283
- .catch((error: unknown) => {
284
- const message = error instanceof Error ? error.message : String(error);
285
- content.rows[content.rows.length - 1] = { text: `Copy failed: ${message}`, style: "warning" };
286
- tui.requestRender();
287
- });
288
- return;
289
- }
290
- tui.requestRender();
291
- },
292
- }),
98
+ const usage = await refreshUsage(true);
99
+ if (!isCurrent()) return;
100
+ const content = buildCopilotUsageModal(usage);
101
+ ctx.ui.setStatus(COPILOT_USAGE_STATUS_KEY, formatPlanUsage(usage));
102
+ await showModal(
103
+ ctx,
104
+ content,
293
105
  {
294
- overlay: true,
295
- overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%", margin: 0 },
106
+ refresh: async () => {
107
+ const usage = await refreshUsage(true);
108
+ if (!isCurrent()) throw new Error("Session ended during usage refresh.");
109
+ ctx.ui.setStatus(COPILOT_USAGE_STATUS_KEY, formatPlanUsage(usage));
110
+ return buildCopilotUsageModal(usage);
111
+ },
296
112
  },
113
+ unavailableMessage,
297
114
  );
298
115
  } catch (error) {
299
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
116
+ if (isCurrent()) ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
300
117
  }
301
118
  }
302
119
 
303
120
  export default function (pi: ExtensionAPI): void {
121
+ let currentContext: ExtensionContext | undefined;
122
+ let sessionGeneration = 0;
123
+ const isCurrent = (generation: number) => () => generation === sessionGeneration;
124
+
125
+ pi.events.on(OPEN_COPILOT_USAGE_EVENT, () => {
126
+ if (currentContext) void showCopilotUsage(currentContext, isCurrent(sessionGeneration));
127
+ });
128
+
304
129
  pi.on("session_start", (_event, ctx) => {
305
- if (ctx.mode !== "tui") return;
306
- ctx.ui.setStatus(FOOTER_STATUS_KEY, undefined);
307
- addCustomFooter(ctx);
308
- void refreshUsage().catch(() => {});
130
+ const generation = ++sessionGeneration;
131
+ if (ctx.mode !== "tui") {
132
+ currentContext = undefined;
133
+ return;
134
+ }
135
+ currentContext = ctx;
136
+ ctx.ui.setStatus(COPILOT_USAGE_STATUS_KEY, formatPlanUsage(cachedUsage) || undefined);
137
+ void refreshUsage()
138
+ .then((usage) => {
139
+ if (generation === sessionGeneration)
140
+ currentContext?.ui.setStatus(COPILOT_USAGE_STATUS_KEY, formatPlanUsage(usage));
141
+ })
142
+ .catch(() => {});
309
143
  });
310
144
 
311
145
  pi.on("agent_end", (_event, ctx) => {
312
- if (ctx.mode === "tui") void refreshUsage().catch(() => {});
146
+ if (ctx.mode !== "tui") return;
147
+ currentContext = ctx;
148
+ const generation = sessionGeneration;
149
+ void refreshUsage()
150
+ .then((usage) => {
151
+ if (generation === sessionGeneration)
152
+ currentContext?.ui.setStatus(COPILOT_USAGE_STATUS_KEY, formatPlanUsage(usage));
153
+ })
154
+ .catch(() => {});
313
155
  });
314
156
 
315
157
  pi.on("session_shutdown", (_event, ctx) => {
316
- if (ctx.mode === "tui") {
317
- ctx.ui.setStatus(FOOTER_STATUS_KEY, undefined);
318
- ctx.ui.setFooter(undefined);
319
- requestFooterRender = undefined;
320
- }
158
+ sessionGeneration++;
159
+ if (ctx.mode === "tui") ctx.ui.setStatus(COPILOT_USAGE_STATUS_KEY, undefined);
160
+ currentContext = undefined;
321
161
  });
322
162
 
323
163
  pi.registerCommand("copilot-usage", {
324
164
  description: "Show GitHub Copilot plan and quota usage",
325
- handler: (_args, ctx) => showUsage(ctx),
165
+ handler: (_args, ctx) => showCopilotUsage(ctx, isCurrent(sessionGeneration)),
326
166
  });
327
167
 
328
168
  pi.registerShortcut("super+shift+u", {
329
169
  description: "Show GitHub Copilot AI-credit usage",
330
- handler: (ctx) => showUsage(ctx),
170
+ handler: (ctx) => showCopilotUsage(ctx, isCurrent(sessionGeneration)),
331
171
  });
332
172
  }
package/src/core.ts CHANGED
@@ -1,20 +1,3 @@
1
- import { execFile } from "node:child_process";
2
-
3
- type Usage = {
4
- input?: number;
5
- output?: number;
6
- cacheRead?: number;
7
- cacheWrite?: number;
8
- cost?: { total?: number };
9
- };
10
-
11
- type UsageEntry = {
12
- type?: string;
13
- provider?: string;
14
- usage?: Usage;
15
- message?: { role?: string; provider?: string; usage?: Usage };
16
- };
17
-
18
1
  export type CopilotUsage = {
19
2
  plan: string;
20
3
  unit: "AI credits" | "premium requests";
@@ -26,14 +9,6 @@ export type CopilotUsage = {
26
9
  unlimited: boolean;
27
10
  };
28
11
 
29
- export type SessionUsage = {
30
- input: number;
31
- output: number;
32
- cacheRead: number;
33
- cacheWrite: number;
34
- cost: number;
35
- };
36
-
37
12
  const planNames: Record<string, string> = {
38
13
  business: "Copilot Business",
39
14
  enterprise: "Copilot Enterprise",
@@ -108,12 +83,6 @@ export function parseCopilotUsage(data: unknown): CopilotUsage {
108
83
  };
109
84
  }
110
85
 
111
- export function formatTokens(tokens: number): string {
112
- if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
113
- if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}K`;
114
- return Math.round(tokens).toLocaleString("en-US");
115
- }
116
-
117
86
  export function formatPlanUsage(usage?: CopilotUsage): string {
118
87
  if (!usage) return "";
119
88
  return usage.unlimited ? `${usage.plan} ∞` : `${usage.plan} ${usage.percentUsed ?? 0}%`;
@@ -137,46 +106,3 @@ export function formatUsageSummary(usage: CopilotUsage): [string, string, string
137
106
  export function getUsageProgress(usage: CopilotUsage): number | undefined {
138
107
  return usage.unlimited ? undefined : (usage.percentUsed ?? 0);
139
108
  }
140
-
141
- export function writeClipboard(text: string, command = "pbcopy"): Promise<void> {
142
- return new Promise((resolve, reject) => {
143
- const child = execFile(command, (error) => (error ? reject(error) : resolve()));
144
- child.stdin?.on("error", reject);
145
- child.stdin?.end(text);
146
- });
147
- }
148
-
149
- export function formatProgressBar(percent: number, width: number): string {
150
- const cells = Math.max(0, Math.floor(width));
151
- const value = Number.isFinite(percent) ? Math.min(100, Math.max(0, percent)) : 0;
152
- const filled = Math.round((value / 100) * cells);
153
- return `${"█".repeat(filled)}${"░".repeat(cells - filled)}`;
154
- }
155
-
156
- export function summarizeSession(entries: readonly unknown[], provider?: string): SessionUsage {
157
- const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
158
- const add = (usage?: Usage) => {
159
- if (!usage) return;
160
- for (const key of ["input", "output", "cacheRead", "cacheWrite"] as const) {
161
- const value = usage[key];
162
- if (typeof value === "number" && Number.isFinite(value) && value > 0) total[key] += value;
163
- }
164
- const cost = usage.cost?.total;
165
- if (typeof cost === "number" && Number.isFinite(cost) && cost > 0) total.cost += cost;
166
- };
167
-
168
- for (const rawEntry of entries) {
169
- if (!rawEntry || typeof rawEntry !== "object") continue;
170
- const entry = rawEntry as UsageEntry;
171
- if (
172
- entry.type === "message" &&
173
- entry.message?.role === "assistant" &&
174
- (!provider || entry.message.provider === provider)
175
- ) {
176
- add(entry.message.usage);
177
- } else if (entry.type === "usage" && (!provider || entry.provider === provider)) {
178
- add(entry.usage);
179
- }
180
- }
181
- return total;
182
- }
package/src/events.ts ADDED
@@ -0,0 +1,2 @@
1
+ export const COPILOT_USAGE_STATUS_KEY = "pi-usage";
2
+ export const OPEN_COPILOT_USAGE_EVENT = "pi-footer:open-copilot-usage";