@marcosgilf/pi-usage 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +62 -2
- package/package.json +32 -4
- package/src/copilot-usage.ts +332 -0
- package/src/core.ts +182 -0
- package/src/index.ts +1 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Marcos Gil
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,63 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 📊 pi-usage
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@marcosgilf/pi-usage) [](https://pi.dev) [](./LICENSE)
|
|
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:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
pi install npm:@marcosgilf/pi-usage
|
|
20
|
+
```
|
|
21
|
+
|
|
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`.
|
|
23
|
+
|
|
24
|
+
Pi extensions run with Pi's operating-system permissions. Review package source before installing.
|
|
25
|
+
|
|
26
|
+
## 🚀 Quick start
|
|
27
|
+
|
|
28
|
+
Install `gh`, sign in to GitHub, and grant the `user` OAuth scope if needed:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
gh auth refresh -h github.com -s user
|
|
32
|
+
```
|
|
33
|
+
|
|
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).
|
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marcosgilf/pi-usage",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Show GitHub Copilot plan and quota usage in Pi.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"private": false,
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package",
|
|
10
|
+
"pi-extension",
|
|
11
|
+
"pi",
|
|
12
|
+
"usage",
|
|
13
|
+
"quota",
|
|
14
|
+
"github-copilot",
|
|
15
|
+
"copilot"
|
|
16
|
+
],
|
|
17
|
+
"files": [
|
|
18
|
+
"src",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"pi": {
|
|
23
|
+
"extensions": [
|
|
24
|
+
"./src/index.ts"
|
|
25
|
+
]
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
29
|
+
"@earendil-works/pi-tui": "*"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
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";
|
|
15
|
+
|
|
16
|
+
const CACHE_MS = 5 * 60 * 1000;
|
|
17
|
+
const FOOTER_STATUS_KEY = "copilot-usage";
|
|
18
|
+
|
|
19
|
+
type UsageSnapshot = CopilotUsage & { fetchedAt: number };
|
|
20
|
+
type FooterRow = { text: string; style?: "accent" | "success" | "warning"; progress?: number };
|
|
21
|
+
|
|
22
|
+
let cachedUsage: UsageSnapshot | undefined;
|
|
23
|
+
let lastError: string | undefined;
|
|
24
|
+
let lastAttempt = 0;
|
|
25
|
+
let pending: Promise<UsageSnapshot> | undefined;
|
|
26
|
+
let requestFooterRender: (() => void) | undefined;
|
|
27
|
+
|
|
28
|
+
function runGh(args: string[]): Promise<string> {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
execFile(
|
|
31
|
+
"gh",
|
|
32
|
+
["api", "--hostname", "github.com", ...args],
|
|
33
|
+
{
|
|
34
|
+
encoding: "utf8",
|
|
35
|
+
timeout: 15_000,
|
|
36
|
+
maxBuffer: 1024 * 1024,
|
|
37
|
+
},
|
|
38
|
+
(error, stdout, stderr) => {
|
|
39
|
+
if (error) {
|
|
40
|
+
const message = String(stderr || error.message).trim();
|
|
41
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
42
|
+
reject(new Error("GitHub CLI (`gh`) not found. Install it and run `gh auth login -h github.com`."));
|
|
43
|
+
} else if (/needs the ["']user["'] scope/i.test(message)) {
|
|
44
|
+
reject(
|
|
45
|
+
new Error('Copilot usage needs the "user" OAuth scope. Run `gh auth refresh -h github.com -s user`.'),
|
|
46
|
+
);
|
|
47
|
+
} else if (/not logged in|authentication required/i.test(message)) {
|
|
48
|
+
reject(new Error("GitHub CLI is not logged in. Run `gh auth login -h github.com`."));
|
|
49
|
+
} else {
|
|
50
|
+
reject(new Error(`GitHub Copilot usage request failed: ${message}`));
|
|
51
|
+
}
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
resolve(stdout.trim());
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function fetchCopilotUsage(): Promise<CopilotUsage> {
|
|
61
|
+
return parseCopilotUsage(JSON.parse(await runGh(["copilot_internal/user"])) as unknown);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function refreshUsage(force = false): Promise<UsageSnapshot> {
|
|
65
|
+
if (pending) return pending;
|
|
66
|
+
if (!force && cachedUsage && Date.now() - cachedUsage.fetchedAt < CACHE_MS) return cachedUsage;
|
|
67
|
+
if (!force && lastError && Date.now() - lastAttempt < CACHE_MS) throw new Error(lastError);
|
|
68
|
+
|
|
69
|
+
lastAttempt = Date.now();
|
|
70
|
+
pending = fetchCopilotUsage()
|
|
71
|
+
.then((usage) => {
|
|
72
|
+
cachedUsage = { ...usage, fetchedAt: Date.now() };
|
|
73
|
+
lastError = undefined;
|
|
74
|
+
requestFooterRender?.();
|
|
75
|
+
return cachedUsage;
|
|
76
|
+
})
|
|
77
|
+
.catch((error: unknown) => {
|
|
78
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
79
|
+
requestFooterRender?.();
|
|
80
|
+
throw new Error(lastError);
|
|
81
|
+
})
|
|
82
|
+
.finally(() => {
|
|
83
|
+
pending = undefined;
|
|
84
|
+
});
|
|
85
|
+
return pending;
|
|
86
|
+
}
|
|
87
|
+
|
|
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 } {
|
|
226
|
+
const [title, planText, quotaText, resetText] = formatUsageSummary(usage);
|
|
227
|
+
const progress = getUsageProgress(usage);
|
|
228
|
+
const rows: FooterRow[] = [
|
|
229
|
+
{ text: title, style: "accent" },
|
|
230
|
+
{ text: "" },
|
|
231
|
+
{ text: planText },
|
|
232
|
+
{ text: "" },
|
|
233
|
+
{ text: quotaText },
|
|
234
|
+
];
|
|
235
|
+
if (progress === undefined) rows.push({ text: "" });
|
|
236
|
+
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" });
|
|
238
|
+
return { rows, clipboardText: [title, planText, quotaText, resetText].join("\n") };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function showUsage(ctx: ExtensionContext): Promise<void> {
|
|
242
|
+
if (ctx.mode !== "tui") {
|
|
243
|
+
ctx.ui.notify("/copilot-usage requires Pi TUI mode.", "warning");
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
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
|
+
}),
|
|
293
|
+
{
|
|
294
|
+
overlay: true,
|
|
295
|
+
overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%", margin: 0 },
|
|
296
|
+
},
|
|
297
|
+
);
|
|
298
|
+
} catch (error) {
|
|
299
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export default function (pi: ExtensionAPI): void {
|
|
304
|
+
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(() => {});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
pi.on("agent_end", (_event, ctx) => {
|
|
312
|
+
if (ctx.mode === "tui") void refreshUsage().catch(() => {});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
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
|
+
}
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
pi.registerCommand("copilot-usage", {
|
|
324
|
+
description: "Show GitHub Copilot plan and quota usage",
|
|
325
|
+
handler: (_args, ctx) => showUsage(ctx),
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
pi.registerShortcut("super+shift+u", {
|
|
329
|
+
description: "Show GitHub Copilot AI-credit usage",
|
|
330
|
+
handler: (ctx) => showUsage(ctx),
|
|
331
|
+
});
|
|
332
|
+
}
|
package/src/core.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
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
|
+
export type CopilotUsage = {
|
|
19
|
+
plan: string;
|
|
20
|
+
unit: "AI credits" | "premium requests";
|
|
21
|
+
used?: number;
|
|
22
|
+
limit?: number;
|
|
23
|
+
remaining?: number;
|
|
24
|
+
percentUsed?: number;
|
|
25
|
+
resetAt?: string;
|
|
26
|
+
unlimited: boolean;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type SessionUsage = {
|
|
30
|
+
input: number;
|
|
31
|
+
output: number;
|
|
32
|
+
cacheRead: number;
|
|
33
|
+
cacheWrite: number;
|
|
34
|
+
cost: number;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const planNames: Record<string, string> = {
|
|
38
|
+
business: "Copilot Business",
|
|
39
|
+
enterprise: "Copilot Enterprise",
|
|
40
|
+
free: "Copilot Free",
|
|
41
|
+
individual: "Copilot Individual",
|
|
42
|
+
pro: "Copilot Pro",
|
|
43
|
+
pro_plus: "Copilot Pro+",
|
|
44
|
+
max: "Copilot Max",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function numberOrUndefined(value: unknown): number | undefined {
|
|
48
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function parseCopilotUsage(data: unknown): CopilotUsage {
|
|
52
|
+
if (!data || typeof data !== "object") throw new Error("GitHub returned invalid Copilot usage data.");
|
|
53
|
+
const account = data as {
|
|
54
|
+
copilot_plan?: unknown;
|
|
55
|
+
token_based_billing?: unknown;
|
|
56
|
+
quota_reset_date_utc?: unknown;
|
|
57
|
+
quota_snapshots?: { premium_interactions?: unknown };
|
|
58
|
+
};
|
|
59
|
+
const quota = account.quota_snapshots?.premium_interactions;
|
|
60
|
+
if (!quota || typeof quota !== "object") throw new Error("GitHub returned no Copilot usage quota.");
|
|
61
|
+
|
|
62
|
+
const snapshot = quota as {
|
|
63
|
+
percent_remaining?: unknown;
|
|
64
|
+
credits_used?: unknown;
|
|
65
|
+
entitlement?: unknown;
|
|
66
|
+
quota_remaining?: unknown;
|
|
67
|
+
remaining?: unknown;
|
|
68
|
+
token_based_billing?: unknown;
|
|
69
|
+
unlimited?: unknown;
|
|
70
|
+
has_quota?: unknown;
|
|
71
|
+
};
|
|
72
|
+
const limit = numberOrUndefined(snapshot.entitlement);
|
|
73
|
+
const remaining = numberOrUndefined(snapshot.quota_remaining) ?? numberOrUndefined(snapshot.remaining);
|
|
74
|
+
const used =
|
|
75
|
+
numberOrUndefined(snapshot.credits_used) ??
|
|
76
|
+
(limit !== undefined && remaining !== undefined ? Math.max(0, limit - remaining) : undefined);
|
|
77
|
+
const percentRemaining = numberOrUndefined(snapshot.percent_remaining);
|
|
78
|
+
if (percentRemaining !== undefined && percentRemaining > 100)
|
|
79
|
+
throw new Error("GitHub returned an invalid Copilot quota percentage.");
|
|
80
|
+
const ratioPercent = used !== undefined && limit && limit > 0 ? Math.round((used / limit) * 100) : undefined;
|
|
81
|
+
const percentUsed =
|
|
82
|
+
snapshot.unlimited === true || snapshot.has_quota === false
|
|
83
|
+
? undefined
|
|
84
|
+
: ratioPercent !== undefined && ratioPercent > 100
|
|
85
|
+
? ratioPercent
|
|
86
|
+
: percentRemaining !== undefined
|
|
87
|
+
? Math.round(100 - percentRemaining)
|
|
88
|
+
: ratioPercent;
|
|
89
|
+
if (percentUsed === undefined && snapshot.unlimited !== true && snapshot.has_quota !== false) {
|
|
90
|
+
throw new Error("GitHub returned no Copilot usage percentage.");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const rawPlan = typeof account.copilot_plan === "string" ? account.copilot_plan : "unknown";
|
|
94
|
+
const planKey = rawPlan
|
|
95
|
+
.toLowerCase()
|
|
96
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
97
|
+
.replace(/^_|_$/g, "");
|
|
98
|
+
const tokenBased = snapshot.token_based_billing === true || account.token_based_billing === true;
|
|
99
|
+
return {
|
|
100
|
+
plan: planNames[planKey] ?? `Copilot ${rawPlan}`,
|
|
101
|
+
unit: tokenBased ? "AI credits" : "premium requests",
|
|
102
|
+
used,
|
|
103
|
+
limit,
|
|
104
|
+
remaining,
|
|
105
|
+
percentUsed,
|
|
106
|
+
resetAt: typeof account.quota_reset_date_utc === "string" ? account.quota_reset_date_utc : undefined,
|
|
107
|
+
unlimited: snapshot.unlimited === true || snapshot.has_quota === false,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
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
|
+
export function formatPlanUsage(usage?: CopilotUsage): string {
|
|
118
|
+
if (!usage) return "";
|
|
119
|
+
return usage.unlimited ? `${usage.plan} ∞` : `${usage.plan} ${usage.percentUsed ?? 0}%`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function formatCredits(value: number | undefined): string {
|
|
123
|
+
return value === undefined ? "—" : value.toLocaleString("en-US", { maximumFractionDigits: 1 });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function formatUsageSummary(usage: CopilotUsage): [string, string, string, string] {
|
|
127
|
+
const percent = usage.percentUsed ?? 0;
|
|
128
|
+
const quota = usage.unlimited
|
|
129
|
+
? `${usage.unit}: unlimited`
|
|
130
|
+
: `${usage.unit} (resets monthly): ${formatCredits(usage.used)} / ${formatCredits(usage.limit)} (${percent}% used)`;
|
|
131
|
+
const reset = usage.resetAt
|
|
132
|
+
? `Resets: ${new Date(usage.resetAt).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" })} UTC`
|
|
133
|
+
: "Resets monthly";
|
|
134
|
+
return ["Copilot usage and plan", `Plan: ${usage.plan}`, quota, reset];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function getUsageProgress(usage: CopilotUsage): number | undefined {
|
|
138
|
+
return usage.unlimited ? undefined : (usage.percentUsed ?? 0);
|
|
139
|
+
}
|
|
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/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./copilot-usage.ts";
|