@ogulcancelik/pi-minimal-footer 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 +48 -0
- package/assets/claude.png +0 -0
- package/assets/codex.png +0 -0
- package/index.ts +935 -0
- package/package.json +40 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Can Celik
|
|
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
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# pi-minimal-footer
|
|
2
|
+
|
|
3
|
+
Minimal footer for [pi](https://github.com/badlogic/pi-mono) that replaces the default footer with a compact two-line display: context gauge on top, subscription usage bars below.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **Context gauge** — working directory, git branch, model, thinking level, and context window usage with token counts
|
|
12
|
+
- **Subscription usage bars** — rolling window quotas with reset timers for supported providers
|
|
13
|
+
- **Auto-refresh** — fetches usage on startup and model switch, then every 5 minutes
|
|
14
|
+
- **Git integration** — branch name, dirty state, ahead/behind counts (supports jj)
|
|
15
|
+
|
|
16
|
+
## Supported providers
|
|
17
|
+
|
|
18
|
+
| Provider | What it shows |
|
|
19
|
+
|----------|---------------|
|
|
20
|
+
| Claude Max | 5h + weekly rolling windows |
|
|
21
|
+
| OpenAI Codex | Primary + secondary rolling windows |
|
|
22
|
+
| GitHub Copilot | Premium interactions + chat quotas |
|
|
23
|
+
| Google Gemini | Pro + Flash remaining quotas |
|
|
24
|
+
| MiniMax | 5h + weekly rolling windows (Token Plan / Coding Plan) |
|
|
25
|
+
| MiniMax CN | Same as MiniMax, China endpoint |
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pi install npm:@ogulcancelik/pi-minimal-footer
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## How it works
|
|
34
|
+
|
|
35
|
+
The footer reads context usage from the last assistant message's token counts (free — comes with every LLM response). Subscription usage is fetched from each provider's dedicated quota API using your existing auth tokens from `~/.pi/agent/auth.json` or environment variables.
|
|
36
|
+
|
|
37
|
+
Usage is fetched:
|
|
38
|
+
- Once on startup
|
|
39
|
+
- Immediately on model switch (Ctrl+P)
|
|
40
|
+
- Every 5 minutes after that
|
|
41
|
+
|
|
42
|
+
The footer adapts to narrow terminals by stacking lines vertically instead of the single-line wide layout.
|
|
43
|
+
|
|
44
|
+
## Notes
|
|
45
|
+
|
|
46
|
+
- Replaces the default pi footer entirely via `ctx.ui.setFooter()`
|
|
47
|
+
- Auth tokens are read from `~/.pi/agent/auth.json` (populated by `/login`) or standard env vars (`ANTHROPIC_API_KEY`, `MINIMAX_API_KEY`, etc.)
|
|
48
|
+
- Providers without auth simply don't show a usage bar — no errors
|
|
Binary file
|
package/assets/codex.png
ADDED
|
Binary file
|
package/index.ts
ADDED
|
@@ -0,0 +1,935 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context Gauge Extension
|
|
3
|
+
*
|
|
4
|
+
* Custom footer with context gauge + subscription usage bars.
|
|
5
|
+
* Auto-detects provider from current model and shows relevant usage.
|
|
6
|
+
*
|
|
7
|
+
* Supports: Claude Max, Codex, Copilot, Gemini
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
11
|
+
import { buildSessionContext } from "@mariozechner/pi-coding-agent";
|
|
12
|
+
import { visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
|
|
13
|
+
import { execSync } from "node:child_process";
|
|
14
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
|
|
18
|
+
// ============ Types ============
|
|
19
|
+
|
|
20
|
+
interface RateWindow {
|
|
21
|
+
label: string;
|
|
22
|
+
usedPercent: number;
|
|
23
|
+
resetsIn?: string; // human readable "2h38m"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface UsageSnapshot {
|
|
27
|
+
provider: string;
|
|
28
|
+
windows: RateWindow[];
|
|
29
|
+
error?: string;
|
|
30
|
+
fetchedAt: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface GitCache {
|
|
34
|
+
branch: string | null;
|
|
35
|
+
dirty: boolean;
|
|
36
|
+
ahead: number;
|
|
37
|
+
behind: number;
|
|
38
|
+
isJj: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ============ Usage Cache ============
|
|
42
|
+
|
|
43
|
+
const USAGE_REFRESH_INTERVAL = 5 * 60_000; // 5 minutes
|
|
44
|
+
const usageCache = new Map<string, UsageSnapshot>(); // keyed by provider
|
|
45
|
+
|
|
46
|
+
// ============ Identity Cache ============
|
|
47
|
+
|
|
48
|
+
let codexEmailPrefix: string | null | undefined; // undefined = not yet resolved
|
|
49
|
+
|
|
50
|
+
// ============ Git Cache ============
|
|
51
|
+
|
|
52
|
+
let gitCache: GitCache | null = null;
|
|
53
|
+
|
|
54
|
+
function refreshGitCache(): void {
|
|
55
|
+
gitCache = null;
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const gitRoot = execSync("git rev-parse --show-toplevel 2>/dev/null", {
|
|
59
|
+
encoding: "utf8",
|
|
60
|
+
timeout: 500,
|
|
61
|
+
}).trim();
|
|
62
|
+
|
|
63
|
+
if (!gitRoot) return;
|
|
64
|
+
|
|
65
|
+
const isJj = existsSync(`${gitRoot}/.jj`);
|
|
66
|
+
let branch: string | null = null;
|
|
67
|
+
|
|
68
|
+
if (isJj) {
|
|
69
|
+
try {
|
|
70
|
+
const jjBranch = execSync(
|
|
71
|
+
`jj log -r 'heads(ancestors(@) & bookmarks())' --no-graph -T 'bookmarks.map(|b| b.name()).join(" ")' --limit 1 2>/dev/null`,
|
|
72
|
+
{ encoding: "utf8", timeout: 1000 }
|
|
73
|
+
).trim();
|
|
74
|
+
branch = jjBranch ? jjBranch.split(" ")[0] : null;
|
|
75
|
+
} catch {}
|
|
76
|
+
} else {
|
|
77
|
+
try {
|
|
78
|
+
const gitBranch = execSync("git rev-parse --abbrev-ref HEAD 2>/dev/null", {
|
|
79
|
+
encoding: "utf8",
|
|
80
|
+
timeout: 500,
|
|
81
|
+
}).trim();
|
|
82
|
+
branch = gitBranch && gitBranch !== "HEAD" ? gitBranch : null;
|
|
83
|
+
} catch {}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let dirty = false;
|
|
87
|
+
try {
|
|
88
|
+
const status = execSync("git status --porcelain 2>/dev/null", {
|
|
89
|
+
encoding: "utf8",
|
|
90
|
+
timeout: 500,
|
|
91
|
+
});
|
|
92
|
+
dirty = status.trim().length > 0;
|
|
93
|
+
} catch {}
|
|
94
|
+
|
|
95
|
+
let ahead = 0;
|
|
96
|
+
let behind = 0;
|
|
97
|
+
try {
|
|
98
|
+
const counts = execSync("git rev-list --left-right --count HEAD...@{upstream} 2>/dev/null", {
|
|
99
|
+
encoding: "utf8",
|
|
100
|
+
timeout: 500,
|
|
101
|
+
}).trim();
|
|
102
|
+
const [a, b] = counts.split(/\s+/);
|
|
103
|
+
ahead = parseInt(a, 10) || 0;
|
|
104
|
+
behind = parseInt(b, 10) || 0;
|
|
105
|
+
} catch {}
|
|
106
|
+
|
|
107
|
+
gitCache = { branch, dirty, ahead, behind, isJj };
|
|
108
|
+
} catch {
|
|
109
|
+
gitCache = null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ============ JWT Helpers ============
|
|
114
|
+
|
|
115
|
+
function decodeJwtPayload(token: string): Record<string, any> | null {
|
|
116
|
+
try {
|
|
117
|
+
const parts = token.split(".");
|
|
118
|
+
if (parts.length < 2) return null;
|
|
119
|
+
let payload = parts[1];
|
|
120
|
+
// Add base64 padding
|
|
121
|
+
payload += "=".repeat((4 - (payload.length % 4)) % 4);
|
|
122
|
+
const decoded = Buffer.from(payload, "base64url").toString("utf-8");
|
|
123
|
+
return JSON.parse(decoded);
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function getEmailPrefixFromJwt(token: string): string | null {
|
|
130
|
+
const payload = decodeJwtPayload(token);
|
|
131
|
+
const email = payload?.["https://api.openai.com/profile"]?.email;
|
|
132
|
+
if (!email || typeof email !== "string") return null;
|
|
133
|
+
const prefix = email.split("@")[0];
|
|
134
|
+
return prefix || null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ============ Auth Loading ============
|
|
138
|
+
|
|
139
|
+
function loadAuthJson(): Record<string, any> {
|
|
140
|
+
const authPath = join(homedir(), ".pi", "agent", "auth.json");
|
|
141
|
+
try {
|
|
142
|
+
if (existsSync(authPath)) {
|
|
143
|
+
return JSON.parse(readFileSync(authPath, "utf-8"));
|
|
144
|
+
}
|
|
145
|
+
} catch {}
|
|
146
|
+
return {};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function resolveAuthValue(value: unknown): string | undefined {
|
|
150
|
+
if (typeof value !== "string") return undefined;
|
|
151
|
+
const trimmed = value.trim();
|
|
152
|
+
if (!trimmed) return undefined;
|
|
153
|
+
|
|
154
|
+
if (trimmed.startsWith("!")) {
|
|
155
|
+
try {
|
|
156
|
+
const output = execSync(trimmed.slice(1), {
|
|
157
|
+
encoding: "utf-8",
|
|
158
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
159
|
+
timeout: 2000,
|
|
160
|
+
}).trim();
|
|
161
|
+
return output || undefined;
|
|
162
|
+
} catch {
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (/^[A-Z][A-Z0-9_]*$/.test(trimmed) && process.env[trimmed]) {
|
|
168
|
+
return process.env[trimmed];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return trimmed;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function getApiKey(providerKey: string, envVar: string): string | undefined {
|
|
175
|
+
if (process.env[envVar]) return process.env[envVar];
|
|
176
|
+
|
|
177
|
+
const auth = loadAuthJson();
|
|
178
|
+
const entry = auth[providerKey];
|
|
179
|
+
if (!entry) return undefined;
|
|
180
|
+
|
|
181
|
+
if (typeof entry === "string") {
|
|
182
|
+
return resolveAuthValue(entry);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return resolveAuthValue(entry.key ?? entry.access ?? entry.refresh);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function getClaudeToken(): string | undefined {
|
|
189
|
+
const auth = loadAuthJson();
|
|
190
|
+
if (auth.anthropic?.access) return auth.anthropic.access;
|
|
191
|
+
|
|
192
|
+
// Fallback: Claude CLI keychain (macOS)
|
|
193
|
+
try {
|
|
194
|
+
const keychainData = execSync(
|
|
195
|
+
'security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null',
|
|
196
|
+
{ encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
|
|
197
|
+
).trim();
|
|
198
|
+
if (keychainData) {
|
|
199
|
+
const parsed = JSON.parse(keychainData);
|
|
200
|
+
if (parsed.claudeAiOauth?.accessToken) {
|
|
201
|
+
return parsed.claudeAiOauth.accessToken;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
} catch {}
|
|
205
|
+
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function getCopilotToken(): string | undefined {
|
|
210
|
+
const auth = loadAuthJson();
|
|
211
|
+
return auth["github-copilot"]?.refresh;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function getCodexToken(): { token: string; accountId?: string } | undefined {
|
|
215
|
+
const auth = loadAuthJson();
|
|
216
|
+
if (auth["openai-codex"]?.access) {
|
|
217
|
+
return { token: auth["openai-codex"].access, accountId: auth["openai-codex"]?.accountId };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Fallback: ~/.codex/auth.json
|
|
221
|
+
const codexPath = join(process.env.CODEX_HOME || join(homedir(), ".codex"), "auth.json");
|
|
222
|
+
try {
|
|
223
|
+
if (existsSync(codexPath)) {
|
|
224
|
+
const data = JSON.parse(readFileSync(codexPath, "utf-8"));
|
|
225
|
+
if (data.OPENAI_API_KEY) {
|
|
226
|
+
return { token: data.OPENAI_API_KEY };
|
|
227
|
+
}
|
|
228
|
+
if (data.tokens?.access_token) {
|
|
229
|
+
return { token: data.tokens.access_token, accountId: data.tokens.account_id };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
} catch {}
|
|
233
|
+
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function getGeminiToken(): string | undefined {
|
|
238
|
+
const auth = loadAuthJson();
|
|
239
|
+
if (auth["google-gemini-cli"]?.access) return auth["google-gemini-cli"].access;
|
|
240
|
+
|
|
241
|
+
// Fallback: ~/.gemini/oauth_creds.json
|
|
242
|
+
const geminiPath = join(homedir(), ".gemini", "oauth_creds.json");
|
|
243
|
+
try {
|
|
244
|
+
if (existsSync(geminiPath)) {
|
|
245
|
+
const data = JSON.parse(readFileSync(geminiPath, "utf-8"));
|
|
246
|
+
return data.access_token;
|
|
247
|
+
}
|
|
248
|
+
} catch {}
|
|
249
|
+
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function getMinimaxToken(provider: "minimax" | "minimax-cn"): string | undefined {
|
|
254
|
+
return provider === "minimax"
|
|
255
|
+
? getApiKey("minimax", "MINIMAX_API_KEY")
|
|
256
|
+
: getApiKey("minimax-cn", "MINIMAX_CN_API_KEY");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ============ Time Formatting ============
|
|
260
|
+
|
|
261
|
+
function formatResetTime(date: Date): string {
|
|
262
|
+
const diffMs = date.getTime() - Date.now();
|
|
263
|
+
if (diffMs < 0) return "now";
|
|
264
|
+
|
|
265
|
+
const diffMins = Math.floor(diffMs / 60000);
|
|
266
|
+
if (diffMins < 60) return `${diffMins}m`;
|
|
267
|
+
|
|
268
|
+
const hours = Math.floor(diffMins / 60);
|
|
269
|
+
const mins = diffMins % 60;
|
|
270
|
+
if (hours < 24) return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
|
|
271
|
+
|
|
272
|
+
const days = Math.floor(hours / 24);
|
|
273
|
+
const remainingHours = hours % 24;
|
|
274
|
+
return remainingHours > 0 ? `${days}d${remainingHours}h` : `${days}d`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Clamp a percentage to [0, 100]. Does NOT auto-normalize 0-1 fractions. */
|
|
278
|
+
function clampPercent(value: number): number {
|
|
279
|
+
if (!Number.isFinite(value)) return 0;
|
|
280
|
+
return Math.max(0, Math.min(100, value));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Normalize a value that might be 0-1 fraction OR 0-100 percent, then clamp. */
|
|
284
|
+
function normalizePercent(value: number): number {
|
|
285
|
+
if (!Number.isFinite(value)) return 0;
|
|
286
|
+
const normalized = value <= 1 && value >= 0 ? value * 100 : value;
|
|
287
|
+
return Math.max(0, Math.min(100, normalized));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function getWindowLabel(durationMs: number | undefined, fallback: string): string {
|
|
291
|
+
if (!durationMs || !Number.isFinite(durationMs) || durationMs <= 0) return fallback;
|
|
292
|
+
|
|
293
|
+
const hourMs = 60 * 60 * 1000;
|
|
294
|
+
const dayMs = 24 * hourMs;
|
|
295
|
+
const weekMs = 7 * dayMs;
|
|
296
|
+
|
|
297
|
+
// Check if duration is close to a standard window and use the fallback label.
|
|
298
|
+
// This preserves "5h" / "Week" / "Day" labels even when the actual
|
|
299
|
+
// rolling window start/end times don't align perfectly.
|
|
300
|
+
const isCloseToWeek = Math.abs(durationMs - weekMs) <= hourMs * 2;
|
|
301
|
+
const isCloseToDay = Math.abs(durationMs - dayMs) <= hourMs * 2;
|
|
302
|
+
const isCloseTo5h = Math.abs(durationMs - 5 * hourMs) <= hourMs * 2;
|
|
303
|
+
|
|
304
|
+
if (isCloseToWeek || fallback === "Week") return "Week";
|
|
305
|
+
if (isCloseToDay || fallback === "Day") return "Day";
|
|
306
|
+
if (isCloseTo5h || fallback === "5h") return fallback;
|
|
307
|
+
|
|
308
|
+
const hours = Math.round(durationMs / hourMs);
|
|
309
|
+
if (hours >= 1 && hours < 48) return `${hours}h`;
|
|
310
|
+
|
|
311
|
+
const days = Math.round(durationMs / dayMs);
|
|
312
|
+
if (days >= 1) return `${days}d`;
|
|
313
|
+
|
|
314
|
+
const mins = Math.max(1, Math.round(durationMs / 60000));
|
|
315
|
+
return `${mins}m`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs = 5000): Promise<Response> {
|
|
319
|
+
const controller = new AbortController();
|
|
320
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
324
|
+
} finally {
|
|
325
|
+
clearTimeout(timeout);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ============ Usage Fetchers ============
|
|
330
|
+
|
|
331
|
+
async function fetchClaudeUsage(): Promise<UsageSnapshot> {
|
|
332
|
+
const token = getClaudeToken();
|
|
333
|
+
if (!token) {
|
|
334
|
+
return { provider: "Claude", windows: [], error: "no-auth", fetchedAt: Date.now() };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
try {
|
|
338
|
+
const res = await fetchWithTimeout("https://api.anthropic.com/api/oauth/usage", {
|
|
339
|
+
headers: {
|
|
340
|
+
Authorization: `Bearer ${token}`,
|
|
341
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
if (!res.ok) {
|
|
346
|
+
return { provider: "Claude", windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const data = (await res.json()) as any;
|
|
350
|
+
const windows: RateWindow[] = [];
|
|
351
|
+
|
|
352
|
+
if (data.five_hour?.utilization !== undefined) {
|
|
353
|
+
windows.push({
|
|
354
|
+
label: "5h",
|
|
355
|
+
usedPercent: normalizePercent(data.five_hour.utilization),
|
|
356
|
+
resetsIn: data.five_hour.resets_at ? formatResetTime(new Date(data.five_hour.resets_at)) : undefined,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (data.seven_day?.utilization !== undefined) {
|
|
361
|
+
windows.push({
|
|
362
|
+
label: "Week",
|
|
363
|
+
usedPercent: normalizePercent(data.seven_day.utilization),
|
|
364
|
+
resetsIn: data.seven_day.resets_at ? formatResetTime(new Date(data.seven_day.resets_at)) : undefined,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return { provider: "Claude", windows, fetchedAt: Date.now() };
|
|
369
|
+
} catch (e) {
|
|
370
|
+
return { provider: "Claude", windows: [], error: String(e), fetchedAt: Date.now() };
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function fetchCopilotUsage(): Promise<UsageSnapshot> {
|
|
375
|
+
const token = getCopilotToken();
|
|
376
|
+
if (!token) {
|
|
377
|
+
return { provider: "Copilot", windows: [], error: "no-auth", fetchedAt: Date.now() };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
const res = await fetchWithTimeout("https://api.github.com/copilot_internal/user", {
|
|
382
|
+
headers: {
|
|
383
|
+
"Editor-Version": "vscode/1.96.2",
|
|
384
|
+
"User-Agent": "GitHubCopilotChat/0.26.7",
|
|
385
|
+
"X-Github-Api-Version": "2025-04-01",
|
|
386
|
+
Accept: "application/json",
|
|
387
|
+
Authorization: `token ${token}`,
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
if (!res.ok) {
|
|
392
|
+
return { provider: "Copilot", windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const data = (await res.json()) as any;
|
|
396
|
+
const windows: RateWindow[] = [];
|
|
397
|
+
|
|
398
|
+
const resetDate = data.quota_reset_date_utc ? new Date(data.quota_reset_date_utc) : undefined;
|
|
399
|
+
const resetsIn = resetDate ? formatResetTime(resetDate) : undefined;
|
|
400
|
+
|
|
401
|
+
if (data.quota_snapshots?.premium_interactions) {
|
|
402
|
+
const pi = data.quota_snapshots.premium_interactions;
|
|
403
|
+
const usedPercent = clampPercent(100 - (pi.percent_remaining || 0));
|
|
404
|
+
windows.push({ label: "Premium", usedPercent, resetsIn });
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (data.quota_snapshots?.chat && !data.quota_snapshots.chat.unlimited) {
|
|
408
|
+
const chat = data.quota_snapshots.chat;
|
|
409
|
+
windows.push({
|
|
410
|
+
label: "Chat",
|
|
411
|
+
usedPercent: clampPercent(100 - (chat.percent_remaining || 0)),
|
|
412
|
+
resetsIn,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return { provider: "Copilot", windows, fetchedAt: Date.now() };
|
|
417
|
+
} catch (e) {
|
|
418
|
+
return { provider: "Copilot", windows: [], error: String(e), fetchedAt: Date.now() };
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function fetchCodexUsage(): Promise<UsageSnapshot> {
|
|
423
|
+
const creds = getCodexToken();
|
|
424
|
+
if (!creds) {
|
|
425
|
+
return { provider: "Codex", windows: [], error: "no-auth", fetchedAt: Date.now() };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const providerLabel = "Codex";
|
|
429
|
+
|
|
430
|
+
try {
|
|
431
|
+
const headers: Record<string, string> = {
|
|
432
|
+
Authorization: `Bearer ${creds.token}`,
|
|
433
|
+
"User-Agent": "pi-agent",
|
|
434
|
+
Accept: "application/json",
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
if (creds.accountId) {
|
|
438
|
+
headers["ChatGPT-Account-Id"] = creds.accountId;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const res = await fetchWithTimeout("https://chatgpt.com/backend-api/wham/usage", {
|
|
442
|
+
method: "GET",
|
|
443
|
+
headers,
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
if (!res.ok) {
|
|
447
|
+
return { provider: providerLabel, windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const data = (await res.json()) as any;
|
|
451
|
+
const windows: RateWindow[] = [];
|
|
452
|
+
|
|
453
|
+
if (data.rate_limit?.primary_window) {
|
|
454
|
+
const pw = data.rate_limit.primary_window;
|
|
455
|
+
const resetDate = pw.reset_at ? new Date(pw.reset_at * 1000) : undefined;
|
|
456
|
+
const durationMs = typeof pw.limit_window_seconds === "number" ? pw.limit_window_seconds * 1000 : undefined;
|
|
457
|
+
windows.push({
|
|
458
|
+
label: getWindowLabel(durationMs, "5h"),
|
|
459
|
+
usedPercent: clampPercent(pw.used_percent || 0),
|
|
460
|
+
resetsIn: resetDate ? formatResetTime(resetDate) : undefined,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (data.rate_limit?.secondary_window) {
|
|
465
|
+
const sw = data.rate_limit.secondary_window;
|
|
466
|
+
const resetDate = sw.reset_at ? new Date(sw.reset_at * 1000) : undefined;
|
|
467
|
+
const durationMs = typeof sw.limit_window_seconds === "number" ? sw.limit_window_seconds * 1000 : undefined;
|
|
468
|
+
windows.push({
|
|
469
|
+
label: getWindowLabel(durationMs, "Week"),
|
|
470
|
+
usedPercent: clampPercent(sw.used_percent || 0),
|
|
471
|
+
resetsIn: resetDate ? formatResetTime(resetDate) : undefined,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return { provider: providerLabel, windows, fetchedAt: Date.now() };
|
|
476
|
+
} catch (e) {
|
|
477
|
+
return { provider: providerLabel, windows: [], error: String(e), fetchedAt: Date.now() };
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async function fetchGeminiUsage(): Promise<UsageSnapshot> {
|
|
482
|
+
const token = getGeminiToken();
|
|
483
|
+
if (!token) {
|
|
484
|
+
return { provider: "Gemini", windows: [], error: "no-auth", fetchedAt: Date.now() };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
try {
|
|
488
|
+
const res = await fetchWithTimeout("https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota", {
|
|
489
|
+
method: "POST",
|
|
490
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
491
|
+
body: "{}",
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
if (!res.ok) {
|
|
495
|
+
return { provider: "Gemini", windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const data = (await res.json()) as any;
|
|
499
|
+
const quotas: Record<string, number> = {};
|
|
500
|
+
|
|
501
|
+
for (const bucket of data.buckets || []) {
|
|
502
|
+
const model = bucket.modelId || "unknown";
|
|
503
|
+
const frac = bucket.remainingFraction ?? 1;
|
|
504
|
+
if (!quotas[model] || frac < quotas[model]) quotas[model] = frac;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const windows: RateWindow[] = [];
|
|
508
|
+
let proMin = 1,
|
|
509
|
+
flashMin = 1;
|
|
510
|
+
let hasProModel = false,
|
|
511
|
+
hasFlashModel = false;
|
|
512
|
+
|
|
513
|
+
for (const [model, frac] of Object.entries(quotas)) {
|
|
514
|
+
if (model.toLowerCase().includes("pro")) {
|
|
515
|
+
hasProModel = true;
|
|
516
|
+
if (frac < proMin) proMin = frac;
|
|
517
|
+
}
|
|
518
|
+
if (model.toLowerCase().includes("flash")) {
|
|
519
|
+
hasFlashModel = true;
|
|
520
|
+
if (frac < flashMin) flashMin = frac;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (hasProModel) windows.push({ label: "Pro", usedPercent: clampPercent((1 - proMin) * 100) });
|
|
525
|
+
if (hasFlashModel) windows.push({ label: "Flash", usedPercent: clampPercent((1 - flashMin) * 100) });
|
|
526
|
+
|
|
527
|
+
return { provider: "Gemini", windows, fetchedAt: Date.now() };
|
|
528
|
+
} catch (e) {
|
|
529
|
+
return { provider: "Gemini", windows: [], error: String(e), fetchedAt: Date.now() };
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async function fetchMinimaxUsage(provider: "minimax" | "minimax-cn"): Promise<UsageSnapshot> {
|
|
534
|
+
const token = getMinimaxToken(provider);
|
|
535
|
+
const providerLabel = provider === "minimax-cn" ? "MiniMax CN" : "MiniMax";
|
|
536
|
+
const endpoint =
|
|
537
|
+
provider === "minimax-cn"
|
|
538
|
+
? "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains"
|
|
539
|
+
: "https://api.minimax.io/v1/api/openplatform/coding_plan/remains";
|
|
540
|
+
|
|
541
|
+
if (!token) {
|
|
542
|
+
return { provider: providerLabel, windows: [], error: "no-auth", fetchedAt: Date.now() };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
try {
|
|
546
|
+
const res = await fetchWithTimeout(endpoint, {
|
|
547
|
+
method: "GET",
|
|
548
|
+
headers: {
|
|
549
|
+
Authorization: `Bearer ${token}`,
|
|
550
|
+
"Content-Type": "application/json",
|
|
551
|
+
},
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
if (!res.ok) {
|
|
555
|
+
return { provider: providerLabel, windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const data = (await res.json()) as any;
|
|
559
|
+
const baseResp = data?.base_resp;
|
|
560
|
+
if (baseResp?.status_code && baseResp.status_code !== 0) {
|
|
561
|
+
return {
|
|
562
|
+
provider: providerLabel,
|
|
563
|
+
windows: [],
|
|
564
|
+
error: baseResp.status_msg || `API ${baseResp.status_code}`,
|
|
565
|
+
fetchedAt: Date.now(),
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const remains = Array.isArray(data?.model_remains) ? data.model_remains : [];
|
|
570
|
+
const textBucket =
|
|
571
|
+
remains.find((entry: any) => typeof entry?.model_name === "string" && /^minimax-m/i.test(entry.model_name)) ||
|
|
572
|
+
remains.find((entry: any) => typeof entry?.model_name === "string" && /minimax/i.test(entry.model_name)) ||
|
|
573
|
+
remains[0];
|
|
574
|
+
|
|
575
|
+
if (!textBucket) {
|
|
576
|
+
return { provider: providerLabel, windows: [], error: "no-usage-data", fetchedAt: Date.now() };
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const windows: RateWindow[] = [];
|
|
580
|
+
|
|
581
|
+
const intervalTotal = Number(textBucket.current_interval_total_count) || 0;
|
|
582
|
+
const intervalRemaining = Number(textBucket.current_interval_usage_count) || 0;
|
|
583
|
+
if (intervalTotal > 0) {
|
|
584
|
+
// current_*_usage_count = remaining (confirmed against MiniMax website UI)
|
|
585
|
+
const used = intervalTotal - intervalRemaining;
|
|
586
|
+
const usedPercent = clampPercent((used / intervalTotal) * 100);
|
|
587
|
+
const resetDate = textBucket.end_time ? new Date(Number(textBucket.end_time)) : undefined;
|
|
588
|
+
const durationMs =
|
|
589
|
+
textBucket.start_time && textBucket.end_time
|
|
590
|
+
? Number(textBucket.end_time) - Number(textBucket.start_time)
|
|
591
|
+
: undefined;
|
|
592
|
+
windows.push({
|
|
593
|
+
label: getWindowLabel(durationMs, "5h"),
|
|
594
|
+
usedPercent,
|
|
595
|
+
resetsIn: resetDate ? formatResetTime(resetDate) : undefined,
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const weeklyTotal = Number(textBucket.current_weekly_total_count) || 0;
|
|
600
|
+
const weeklyRemaining = Number(textBucket.current_weekly_usage_count) || 0;
|
|
601
|
+
if (weeklyTotal > 0) {
|
|
602
|
+
// current_*_usage_count = remaining (confirmed against MiniMax website UI)
|
|
603
|
+
const used = weeklyTotal - weeklyRemaining;
|
|
604
|
+
const usedPercent = clampPercent((used / weeklyTotal) * 100);
|
|
605
|
+
const resetDate = textBucket.weekly_end_time ? new Date(Number(textBucket.weekly_end_time)) : undefined;
|
|
606
|
+
const durationMs =
|
|
607
|
+
textBucket.weekly_start_time && textBucket.weekly_end_time
|
|
608
|
+
? Number(textBucket.weekly_end_time) - Number(textBucket.weekly_start_time)
|
|
609
|
+
: undefined;
|
|
610
|
+
windows.push({
|
|
611
|
+
label: getWindowLabel(durationMs, "Week"),
|
|
612
|
+
usedPercent,
|
|
613
|
+
resetsIn: resetDate ? formatResetTime(resetDate) : undefined,
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return { provider: providerLabel, windows, fetchedAt: Date.now() };
|
|
618
|
+
} catch (e) {
|
|
619
|
+
return { provider: providerLabel, windows: [], error: String(e), fetchedAt: Date.now() };
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// ============ Provider Detection ============
|
|
624
|
+
|
|
625
|
+
// Map pi provider names to our internal usage provider keys
|
|
626
|
+
const PROVIDER_MAP: Record<string, string> = {
|
|
627
|
+
anthropic: "claude", // Claude Max subscription
|
|
628
|
+
"openai-codex": "codex", // Codex subscription
|
|
629
|
+
"github-copilot": "copilot", // Copilot subscription
|
|
630
|
+
"google-gemini-cli": "gemini", // Gemini CLI subscription
|
|
631
|
+
minimax: "minimax", // MiniMax Token Plan / Coding Plan
|
|
632
|
+
"minimax-cn": "minimax-cn", // MiniMax China plan
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
function detectProvider(modelProvider: string): string | null {
|
|
636
|
+
return PROVIDER_MAP[modelProvider] || null;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
async function fetchUsageForProvider(provider: string): Promise<UsageSnapshot> {
|
|
640
|
+
switch (provider) {
|
|
641
|
+
case "claude":
|
|
642
|
+
return fetchClaudeUsage();
|
|
643
|
+
case "codex":
|
|
644
|
+
return fetchCodexUsage();
|
|
645
|
+
case "copilot":
|
|
646
|
+
return fetchCopilotUsage();
|
|
647
|
+
case "gemini":
|
|
648
|
+
return fetchGeminiUsage();
|
|
649
|
+
case "minimax":
|
|
650
|
+
return fetchMinimaxUsage("minimax");
|
|
651
|
+
case "minimax-cn":
|
|
652
|
+
return fetchMinimaxUsage("minimax-cn");
|
|
653
|
+
default:
|
|
654
|
+
return { provider: "Unknown", windows: [], error: "unknown-provider", fetchedAt: Date.now() };
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// ============ Extension ============
|
|
659
|
+
|
|
660
|
+
export default function (pi: ExtensionAPI) {
|
|
661
|
+
const CTX_GAUGE_WIDTH = 12;
|
|
662
|
+
|
|
663
|
+
// Thin bar characters (same style for both context and usage)
|
|
664
|
+
const BAR_FILLED = "━";
|
|
665
|
+
const BAR_EMPTY = "─";
|
|
666
|
+
|
|
667
|
+
function formatTokenCount(tokens: number): string {
|
|
668
|
+
if (tokens >= 1_000_000) {
|
|
669
|
+
const m = tokens / 1_000_000;
|
|
670
|
+
return m % 1 === 0 ? `${m}M` : `${m.toFixed(1).replace(/\.0$/, "")}M`;
|
|
671
|
+
}
|
|
672
|
+
if (tokens >= 1_000) {
|
|
673
|
+
return `${Math.round(tokens / 1_000)}k`;
|
|
674
|
+
}
|
|
675
|
+
return `${tokens}`;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function renderContextGauge(percentage: number, theme: any, used?: number, total?: number): string {
|
|
679
|
+
const clamped = Math.max(0, Math.min(100, percentage));
|
|
680
|
+
const filled = Math.round((clamped / 100) * CTX_GAUGE_WIDTH);
|
|
681
|
+
const empty = CTX_GAUGE_WIDTH - filled;
|
|
682
|
+
|
|
683
|
+
let color: string;
|
|
684
|
+
if (clamped >= 90) color = "error";
|
|
685
|
+
else if (clamped >= 70) color = "warning";
|
|
686
|
+
else if (clamped >= 50) color = "accent";
|
|
687
|
+
else color = "success";
|
|
688
|
+
|
|
689
|
+
const bar = theme.fg(color, BAR_FILLED.repeat(filled)) + theme.fg("dim", BAR_EMPTY.repeat(empty));
|
|
690
|
+
const pct = `${Math.round(clamped)}%`;
|
|
691
|
+
const counts = used !== undefined && total ? ` ${formatTokenCount(used)}/${formatTokenCount(total)}` : "";
|
|
692
|
+
|
|
693
|
+
return theme.fg("dim", "ctx ") + bar + " " + theme.fg("dim", pct + counts);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function renderUsageBar(usedPercent: number, barWidth: number, theme: any): string {
|
|
697
|
+
const clamped = Math.max(0, Math.min(100, usedPercent));
|
|
698
|
+
const filled = Math.round((clamped / 100) * barWidth);
|
|
699
|
+
const empty = barWidth - filled;
|
|
700
|
+
|
|
701
|
+
let color: string;
|
|
702
|
+
if (clamped >= 92) color = "error";
|
|
703
|
+
else if (clamped >= 85) color = "warning";
|
|
704
|
+
else color = "success";
|
|
705
|
+
|
|
706
|
+
return theme.fg(color, BAR_FILLED.repeat(filled)) + theme.fg("dim", BAR_EMPTY.repeat(empty));
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function renderUsageLine(usage: UsageSnapshot, width: number, theme: any): string[] {
|
|
710
|
+
if (!usage.windows.length) return [];
|
|
711
|
+
|
|
712
|
+
const dim = (s: string) => theme.fg("dim", s);
|
|
713
|
+
const sep = " " + dim(">") + " ";
|
|
714
|
+
const USAGE_BAR_WIDTH = 10;
|
|
715
|
+
const NARROW_THRESHOLD = 60;
|
|
716
|
+
|
|
717
|
+
if (width < NARROW_THRESHOLD) {
|
|
718
|
+
// Narrow: one line per window
|
|
719
|
+
const lines: string[] = [];
|
|
720
|
+
for (const w of usage.windows) {
|
|
721
|
+
const bar = renderUsageBar(w.usedPercent, USAGE_BAR_WIDTH, theme);
|
|
722
|
+
const pct = dim(`${Math.round(w.usedPercent)}%`);
|
|
723
|
+
const timeStr = w.resetsIn ? " " + dim(w.resetsIn) : "";
|
|
724
|
+
lines.push(`${dim(w.label)} ${bar} ${pct}${timeStr}`);
|
|
725
|
+
}
|
|
726
|
+
return lines;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Wide: single line with > separators, fixed-width bars
|
|
730
|
+
// Format: "Codex > 5h ━━━━━━━━━━ 46% 2h38m > 7d ━━━━━━━━━━ 46% 2d1h"
|
|
731
|
+
const parts: string[] = [theme.fg("accent", usage.provider)];
|
|
732
|
+
|
|
733
|
+
for (const w of usage.windows) {
|
|
734
|
+
const bar = renderUsageBar(w.usedPercent, USAGE_BAR_WIDTH, theme);
|
|
735
|
+
const pct = dim(`${Math.round(w.usedPercent)}%`);
|
|
736
|
+
const timeStr = w.resetsIn ? " " + dim(w.resetsIn) : "";
|
|
737
|
+
parts.push(`${dim(w.label)} ${bar} ${pct}${timeStr}`);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
return [parts.join(sep)];
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function getThinkingLevel(ctx: any): string {
|
|
744
|
+
const entries = ctx.sessionManager.getEntries();
|
|
745
|
+
const leafId = ctx.sessionManager.getLeafId();
|
|
746
|
+
const context = buildSessionContext(entries, leafId);
|
|
747
|
+
return context.thinkingLevel || "off";
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function getContextInfo(ctx: any): { percentage: number; used: number; total: number } {
|
|
751
|
+
const model = ctx.model;
|
|
752
|
+
const contextWindow = model?.contextWindow ?? 0;
|
|
753
|
+
if (contextWindow === 0) return { percentage: 0, used: 0, total: 0 };
|
|
754
|
+
|
|
755
|
+
const entries = ctx.sessionManager.getEntries();
|
|
756
|
+
const leafId = ctx.sessionManager.getLeafId();
|
|
757
|
+
const context = buildSessionContext(entries, leafId);
|
|
758
|
+
const messages = context.messages;
|
|
759
|
+
|
|
760
|
+
const lastAssistant = messages
|
|
761
|
+
.slice()
|
|
762
|
+
.reverse()
|
|
763
|
+
.find((m: any) => m.role === "assistant" && m.stopReason !== "aborted");
|
|
764
|
+
|
|
765
|
+
if (!lastAssistant?.usage) return { percentage: 0, used: 0, total: contextWindow };
|
|
766
|
+
|
|
767
|
+
const usage = lastAssistant.usage;
|
|
768
|
+
const contextTokens = (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
|
|
769
|
+
|
|
770
|
+
return { percentage: (contextTokens / contextWindow) * 100, used: contextTokens, total: contextWindow };
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Track usage state for rendering
|
|
774
|
+
let latestUsage: UsageSnapshot | null = null;
|
|
775
|
+
let activeProvider: string | null = null; // internal provider key for the current model
|
|
776
|
+
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
|
777
|
+
|
|
778
|
+
// Store tui reference for triggering re-renders from event handlers
|
|
779
|
+
let tuiRef: { requestRender: () => void } | null = null;
|
|
780
|
+
|
|
781
|
+
/** Fetch usage for the active provider. Shows cached data immediately,
|
|
782
|
+
* then fetches fresh in the background. Discards results if provider
|
|
783
|
+
* changed while the fetch was in flight. */
|
|
784
|
+
function fetchUsage(modelProvider: string): void {
|
|
785
|
+
const provider = detectProvider(modelProvider);
|
|
786
|
+
if (!provider) return;
|
|
787
|
+
|
|
788
|
+
activeProvider = provider;
|
|
789
|
+
|
|
790
|
+
// Show cached data immediately if available
|
|
791
|
+
const cached = usageCache.get(provider);
|
|
792
|
+
if (cached && cached.windows.length > 0) {
|
|
793
|
+
latestUsage = cached;
|
|
794
|
+
tuiRef?.requestRender();
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Fetch fresh in background
|
|
798
|
+
fetchUsageForProvider(provider)
|
|
799
|
+
.then((u) => {
|
|
800
|
+
if (!u || activeProvider !== provider) return;
|
|
801
|
+
usageCache.set(provider, u);
|
|
802
|
+
latestUsage = u;
|
|
803
|
+
tuiRef?.requestRender();
|
|
804
|
+
})
|
|
805
|
+
.catch(() => {});
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** Start (or restart) the periodic refresh timer. */
|
|
809
|
+
function startRefreshTimer(): void {
|
|
810
|
+
if (refreshTimer) clearInterval(refreshTimer);
|
|
811
|
+
refreshTimer = setInterval(() => {
|
|
812
|
+
if (activeProvider) {
|
|
813
|
+
fetchUsageForProvider(activeProvider)
|
|
814
|
+
.then((u) => {
|
|
815
|
+
if (!u) return;
|
|
816
|
+
usageCache.set(activeProvider!, u);
|
|
817
|
+
latestUsage = u;
|
|
818
|
+
tuiRef?.requestRender();
|
|
819
|
+
})
|
|
820
|
+
.catch(() => {});
|
|
821
|
+
}
|
|
822
|
+
}, USAGE_REFRESH_INTERVAL);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function stopRefreshTimer(): void {
|
|
826
|
+
if (refreshTimer) {
|
|
827
|
+
clearInterval(refreshTimer);
|
|
828
|
+
refreshTimer = null;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
833
|
+
refreshGitCache();
|
|
834
|
+
|
|
835
|
+
if (!ctx.hasUI) return;
|
|
836
|
+
|
|
837
|
+
ctx.ui.setFooter((tui: any, theme: any, footerData: any) => {
|
|
838
|
+
// Store tui reference for event handlers — must be set before any async
|
|
839
|
+
// fetch completes so requestRender() works on first load.
|
|
840
|
+
tuiRef = tui;
|
|
841
|
+
|
|
842
|
+
const unsub = footerData.onBranchChange(() => {
|
|
843
|
+
refreshGitCache();
|
|
844
|
+
tui.requestRender();
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
return {
|
|
848
|
+
dispose: () => {
|
|
849
|
+
unsub();
|
|
850
|
+
tuiRef = null;
|
|
851
|
+
stopRefreshTimer();
|
|
852
|
+
},
|
|
853
|
+
invalidate() {},
|
|
854
|
+
render(width: number): string[] {
|
|
855
|
+
const { percentage, used: ctxUsed, total: ctxTotal } = getContextInfo(ctx);
|
|
856
|
+
|
|
857
|
+
// Build parts for status line
|
|
858
|
+
let pwd = process.cwd();
|
|
859
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
860
|
+
if (home && pwd.startsWith(home)) {
|
|
861
|
+
pwd = `~${pwd.slice(home.length)}`;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
let branchStr = "";
|
|
865
|
+
if (gitCache?.branch) {
|
|
866
|
+
const branchColor = gitCache.dirty ? "warning" : "success";
|
|
867
|
+
branchStr = theme.fg(branchColor, gitCache.branch);
|
|
868
|
+
if (gitCache.dirty) branchStr += theme.fg("warning", " *");
|
|
869
|
+
if (gitCache.ahead) branchStr += theme.fg("success", ` ↑${gitCache.ahead}`);
|
|
870
|
+
if (gitCache.behind) branchStr += theme.fg("error", ` ↓${gitCache.behind}`);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// Model + thinking
|
|
874
|
+
const modelName = ctx.model?.id?.split("/").pop() || "no-model";
|
|
875
|
+
let modelStr = theme.fg("muted", modelName);
|
|
876
|
+
if (ctx.model?.reasoning) {
|
|
877
|
+
const thinkingLevel = getThinkingLevel(ctx);
|
|
878
|
+
if (thinkingLevel !== "off") {
|
|
879
|
+
modelStr += " " + theme.fg("dim", ">") + " " + theme.fg("accent", thinkingLevel);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// Context gauge
|
|
884
|
+
const gauge = renderContextGauge(percentage, theme, ctxUsed, ctxTotal);
|
|
885
|
+
|
|
886
|
+
const sep = " " + theme.fg("dim", ">") + " ";
|
|
887
|
+
const NARROW_THRESHOLD = 60;
|
|
888
|
+
|
|
889
|
+
const lines: string[] = [];
|
|
890
|
+
|
|
891
|
+
if (width < NARROW_THRESHOLD) {
|
|
892
|
+
// Narrow: stack vertically
|
|
893
|
+
const pwdColored = theme.fg("accent", pwd);
|
|
894
|
+
if (branchStr) {
|
|
895
|
+
lines.push(truncateToWidth(pwdColored + sep + branchStr, width));
|
|
896
|
+
} else {
|
|
897
|
+
lines.push(truncateToWidth(pwdColored, width));
|
|
898
|
+
}
|
|
899
|
+
lines.push(truncateToWidth(modelStr, width));
|
|
900
|
+
lines.push(truncateToWidth(gauge, width));
|
|
901
|
+
} else {
|
|
902
|
+
// Wide: single line
|
|
903
|
+
const pwdColored = theme.fg("accent", pwd);
|
|
904
|
+
const branchPart = branchStr ? sep + branchStr : "";
|
|
905
|
+
const modelPart = sep + modelStr;
|
|
906
|
+
const gaugePart = sep + gauge;
|
|
907
|
+
lines.push(truncateToWidth(pwdColored + branchPart + modelPart + gaugePart, width));
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
if (latestUsage && latestUsage.windows.length > 0) {
|
|
911
|
+
const usageLines = renderUsageLine(latestUsage, width, theme);
|
|
912
|
+
for (const line of usageLines) {
|
|
913
|
+
lines.push(truncateToWidth(line, width));
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
return lines;
|
|
918
|
+
},
|
|
919
|
+
};
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
// Initial usage fetch — after setFooter so tuiRef is set
|
|
923
|
+
if (ctx.model?.provider) {
|
|
924
|
+
fetchUsage(ctx.model.provider);
|
|
925
|
+
startRefreshTimer();
|
|
926
|
+
}
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
// Refresh when model changes — fetch immediately, restart timer
|
|
930
|
+
pi.on("model_select", (event, _ctx) => {
|
|
931
|
+
if (!event.model?.provider) return;
|
|
932
|
+
fetchUsage(event.model.provider);
|
|
933
|
+
startRefreshTimer(); // reset the 5min countdown since we just fetched
|
|
934
|
+
});
|
|
935
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ogulcancelik/pi-minimal-footer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Minimal footer for pi with context gauge and subscription usage bars",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi-extension",
|
|
9
|
+
"footer",
|
|
10
|
+
"context",
|
|
11
|
+
"usage",
|
|
12
|
+
"coding-agent"
|
|
13
|
+
],
|
|
14
|
+
"author": "Can Celik",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/ogulcancelik/pi-extensions.git",
|
|
19
|
+
"directory": "packages/pi-minimal-footer"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/ogulcancelik/pi-extensions/issues"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/ogulcancelik/pi-extensions/tree/main/packages/pi-minimal-footer#readme",
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18.0.0"
|
|
27
|
+
},
|
|
28
|
+
"pi": {
|
|
29
|
+
"extensions": [
|
|
30
|
+
"./index.ts"
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"index.ts",
|
|
35
|
+
"assets/claude.png",
|
|
36
|
+
"assets/codex.png",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE"
|
|
39
|
+
]
|
|
40
|
+
}
|