pi-minimal-footer 0.1.3 → 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/CHANGELOG.md CHANGED
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-10-05
11
+
12
+ ### Added
13
+
14
+ - Usage limits for the selected provider: OpenAI Codex, Claude OAuth, GitHub Copilot, Gemini CLI, MiniMax (global/CN), Kimi Coding, and OpenCode Go
15
+ - Show the shortest available quota window, including weekly/monthly when no shorter quota exists; omit reset time when the provider does not report it
16
+ - Compact five-cell Braille quota bar with nine bottom-up fill states (`⠀`, `⡀`, `⣀`, `⣄`, `⣤`, `⣦`, `⣶`, `⣷`, `⣿`), 2.5% steps, and green/amber/red usage colors
17
+ - Absolute local reset time marked with `↻` in 24-hour format, e.g. `⣿⣶⠀⠀⠀ ↻16:40`, without provider/window labels or numeric percentages
18
+ - Passive Codex/Claude quota updates from provider responses, with four-minute usage-endpoint fallback and reset-time refresh using Pi's existing credentials
19
+ - Immediate quota clearing and request/timer cancellation on provider switch; ignore late results and dim only the current provider's cached quota after temporary refresh failures
20
+ - Hide usage for local/unsupported models, missing credentials, or absent applicable limits; stop quota work on footer teardown, disable, and session shutdown
21
+ - README examples and documentation for the complete usage feature, its shading, reset time, refresh behavior, and edge cases
22
+ - Honorable mention to Can Celik (@ogulcancelik): provider usage-fetching and quota-parsing logic copied and adapted from [@ogulcancelik/pi-minimal-footer](https://pi.dev/packages/@ogulcancelik/pi-minimal-footer), with upstream MIT attribution retained
23
+
24
+ ### Changed
25
+
26
+ - Group directory and git branch on the left; keep extension statuses, model, context, and quota on the right in one line, with extra spacing before quota
27
+ - Shorten location and statuses on narrow terminals, shrink quota before truncating model/context, and preserve the reset time
28
+ - Simplify quota header parsing and footer width calculations; remove the unused duplicate Claude header parser
29
+
30
+ ### Fixed
31
+
32
+ - Release stalled quota authentication, fetch, and response-body waits after five seconds so scheduled polling can recover; ignore late results after timeout or provider switch
33
+ - Merge partial Codex/Claude quota signals without replacing a cached shorter window or postponing its four-minute refresh when only longer windows update
34
+ - Preserve dim cached usage after malformed responses, while explicit empty/unlimited quotas clear it; retain valid usage when optional reset metadata is invalid
35
+ - Accept Kimi used-only quotas and week/month durations, and derive Codex reset times from relative endpoint delays when an absolute timestamp is unavailable
36
+ - Clear cached quota when Pi reports missing credentials; retain dim cached usage for temporary authentication failures and recover after credentials are restored
37
+
10
38
  ## [0.1.3] - 2026-10-05
11
39
 
12
40
  ### Added
@@ -86,7 +114,8 @@ First npm-ready release.
86
114
 
87
115
  - Project scaffold — `extensions/index.ts` with basic footer structure, `package.json` with pi extension manifest, `README.md`, `LICENSE` (MIT)
88
116
 
89
- [Unreleased]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.1.3...HEAD
117
+ [Unreleased]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.2.0...HEAD
118
+ [0.2.0]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.1.3...v0.2.0
90
119
  [0.1.3]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.1.2...v0.1.3
91
120
  [0.1.2]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.1.1...v0.1.2
92
121
  [0.1.1]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.1.0...v0.1.1
package/README.md CHANGED
@@ -15,10 +15,10 @@
15
15
  </a>
16
16
  </p>
17
17
 
18
- A clean, compact one-line footer for [Pi](https://github.com/earendil-works/pi).
18
+ An opinionated, clean, compact footer for [Pi](https://github.com/earendil-works/pi).
19
19
 
20
20
  ```
21
- ~/path/to/dir (status1 | status2)  main sonnet 12/128k
21
+ ~/git/pi-minimal-footer  main (icarus | goal running) gpt-5.6-sol 12/128k ⣿⣶⠀⠀⠀ ↻16:40
22
22
  ```
23
23
 
24
24
  ![pi-minimal-footer screenshot](media/github-preview.png)
@@ -52,11 +52,78 @@ Requires Node.js >=22.19.0. Run `npm ci`, `npm run check`, and `npm test`. Pi lo
52
52
  - **Git branch** — current branch name
53
53
  - **Model** — active model ID
54
54
  - **Context usage** — tokens used / context window (e.g., `12/128k`). After compaction, Pi reports usage as unknown (`?`) until a subsequent model response.
55
+ - **Provider quota** — the right end shows the selected provider's shortest available quota as a Braille bar and local reset time in 24-hour `HH:mm` format, when reported.
55
56
 
56
- On narrow terminals, the footer shrinks or drops the path, then drops extension statuses and the git branch before truncating model/context. The remaining segments stay right-aligned.
57
+ The directory and git branch stay together on the left. Extension statuses, model, context usage, and quota stay on the right, with extra spacing before the quota. Statuses and location are muted; the model is bold.
58
+
59
+ On narrow terminals, the directory and statuses shorten or disappear, then the branch disappears. The quota bar shrinks before model/context is shortened, preserving the reset time. The footer always uses one line.
57
60
 
58
61
  The footer appears only in Pi's interactive terminal UI, not in print, JSON, or RPC modes.
59
62
 
63
+ ### Provider usage limits
64
+
65
+ The footer displays only the selected provider's shortest available quota window, typically five hours. If only a weekly or monthly quota is available, it displays that quota. This measures account quota used, separately from the model's context usage (`12/128k`).
66
+
67
+ | Provider | Quota selection |
68
+ |---|---|
69
+ | OpenAI Codex (`openai-codex`) | Shortest primary/secondary window |
70
+ | Claude (`anthropic`, OAuth) | Five-hour window, or weekly if that is all that is reported |
71
+ | GitHub Copilot (`github-copilot`) | Limited premium/chat quota; unlimited buckets are hidden |
72
+ | Gemini CLI (`google-gemini-cli`) | Selected model's quota, with Pro/Flash family fallback |
73
+ | MiniMax (`minimax`, `minimax-cn`) | General text bucket's shortest interval/weekly window |
74
+ | Kimi Coding (`kimi-coding`) | Shortest reported window, or weekly fallback |
75
+ | OpenCode Go (`opencode-go`) | Shortest rolling/weekly/monthly window |
76
+
77
+ The adapters reuse the selected provider's authentication from Pi. Claude subscription quota requires OAuth; ordinary Claude API keys do not expose it. Copilot's quota endpoint uses the GitHub login token from Pi's configured agent directory or `COPILOT_GITHUB_TOKEN`. Gemini CLI needs a configured provider/model; Pi 1.0.3 does not include that provider in its built-in catalog.
78
+
79
+ The usage segment contains only a five-cell Braille bar and its reset time:
80
+
81
+ ```text
82
+ ⣿⣶⠀⠀⠀ ↻16:40
83
+ ```
84
+
85
+ This example shows 35% used. `↻16:40` means the quota resets at 16:40 in your local timezone: an absolute 24-hour time, not a countdown. No provider name, window label, percentage, or date is displayed.
86
+
87
+ If the provider does not report a reset time, only the bar is shown:
88
+
89
+ ```text
90
+ ⣿⣶⠀⠀⠀
91
+ ```
92
+
93
+ Each cell uses comic-style dot shading, starting from the bottom:
94
+
95
+ | Cell | Fill |
96
+ |---|---|
97
+ | `⠀` | Empty |
98
+ | `⡀` | One-eighth |
99
+ | `⣀` | Quarter |
100
+ | `⣄` | Three-eighths |
101
+ | `⣤` | Half |
102
+ | `⣦` | Five-eighths |
103
+ | `⣶` | Three-quarter |
104
+ | `⣷` | Seven-eighths |
105
+ | `⣿` | Full |
106
+
107
+ Each step adds one dot from the bottom upward. Five cells provide 2.5% steps, rounded to the nearest step; only the last partially filled cell uses an intermediate state. Filled cells are green below 85%, amber from 85%, and red from 92%. On narrow terminals the bar shrinks to preserve the reset time, so its steps become coarser.
108
+
109
+ Usage refreshes from Codex response headers/stream events and Claude quota response headers when available. Otherwise, the footer checks the selected provider's usage endpoint on startup or provider switch, then every four minutes without a fresh response update. It also checks when a reported reset time arrives. These checks make no model requests.
110
+
111
+ Partial response updates are merged with cached windows. A weekly-only update keeps the cached five-hour quota visible and does not postpone its refresh. Authentication, fetching, and response parsing share a five-second timeout; a stalled lookup or body read releases the request and allows the next scheduled attempt. Late results cannot restore a previous provider's quota.
112
+
113
+ Codex endpoint responses can supply either an absolute reset timestamp or a relative reset delay. Kimi quotas accept either used or remaining counts, including week/month durations. Invalid optional reset metadata leaves the bar visible without a time.
114
+
115
+ Switching providers immediately clears the previous quota, cancels its requests and timers, and ignores late results. Switching back fetches fresh data. Gemini model switches also refresh the model-specific quota. A previous provider's bar never remains visible while the new provider loads.
116
+
117
+ Local models, unsupported providers, and sessions without a selected model show no usage segment and make no usage requests. Missing credentials or a successful usage response without an applicable quota hide the segment, including any previously cached bar. Zero usage still displays an empty bar, with its reset time when available.
118
+
119
+ If a refresh fails temporarily or returns malformed quota data, the current provider's last known bar is dimmed until fresh data arrives; without cached data, the segment stays hidden. Explicit empty or unlimited quota responses clear the cached bar. Requests and timers also stop when the footer is disabled, disposed, or the session shuts down. Custom proxy endpoints are not polled. Live quota availability depends on each provider's endpoint; unavailable endpoints stay silent.
120
+
121
+ The adapters have automated parsing and lifecycle coverage, but authenticated provider endpoints have not been tested live. In particular, OpenCode Go's bearer-token response format and Copilot's public quota endpoint and enterprise compatibility remain unverified. The comparison with [mtrojnar/pi-usage](https://github.com/mtrojnar/pi-usage) informed the timeout, validation, and partial-update handling; its OpenCode implementation uses an authenticated dashboard instead.
122
+
123
+ ### Credits
124
+
125
+ Honorable mention and thanks to **Can Celik (@ogulcancelik)**: the provider usage-fetching and quota-parsing logic was **copied and adapted from [@ogulcancelik/pi-minimal-footer](https://pi.dev/packages/@ogulcancelik/pi-minimal-footer)**. This footer keeps its minimal layout, five-cell bottom-up Braille display, and four-minute refresh behavior. The upstream MIT copyright and license are retained in [extensions/lib/LICENSE](extensions/lib/LICENSE).
126
+
60
127
  ## Commands
61
128
 
62
129
  | Command | Description |
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Minimal footer — replaces pi's default footer with a clean status line:
3
3
  *
4
- * ~/path/to/dir (skill1 | skill2)  main sonnet 12/128k
4
+ * ~/path/to/dir  main (status1 | status2) sonnet 12/128k ⣿⣶⠀⠀⠀ ↻16:40
5
5
  *
6
6
  * Settings are persisted in the agent directory (usually ~/.pi/agent)
7
7
  * settings.json under "minFooter".
@@ -17,6 +17,7 @@ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
17
17
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
18
18
  import { join, dirname, sep } from "node:path";
19
19
  import { homedir } from "node:os";
20
+ import { UsageLimits } from "./lib/usage-limits.js";
20
21
 
21
22
  // ── Settings ──────────────────────────────────────────────────────────
22
23
 
@@ -123,32 +124,60 @@ function updateState(ctx: ExtensionContext, state: FooterState): void {
123
124
 
124
125
  // ── Layout ────────────────────────────────────────────────────────────
125
126
  //
126
- // Priority when space runs out:
127
- // 1. drop/truncate the path first (truncate, then drop entirely)
128
- // 2. drop extension statuses
129
- // 3. drop the git branch
130
- // 4. retain model/context as long as possible
131
- // 5. last resort: truncate the remaining model/context to fit
132
- //
133
- // Every returned line is guaranteed to fit `width` (visible-width safe).
134
-
135
- function buildLine(width: number, path: string, statuses: string, branch: string, model: string, context: string): string {
127
+ // Keep quota and reset time together; shorten location/statuses before model/context.
128
+ // All segment measurements use visible widths, including ANSI and wide characters.
129
+
130
+ const SEGMENT_GAP = " ";
131
+ const QUOTA_GAP = " ";
132
+ const LOCATION_GAP_WIDTH = 3;
133
+ const FULL_QUOTA_WIDTH = 12;
134
+ const COMPACT_QUOTA_WIDTH = 11;
135
+ const MIN_TEXT_WIDTH = 4;
136
+ const MIN_STATUS_WIDTH = 12;
137
+
138
+ function buildLine(width: number, path: string, statuses: string, branch: string, model: string, context: string,
139
+ usage: (available: number) => string | null): string {
136
140
  if (width <= 0) return "";
137
- const core = [model, context].filter(Boolean).join(" ");
138
- const right = [statuses, branch, core].filter(Boolean).join(" ");
139
- const rightWidth = visibleWidth(right);
140
-
141
- if (rightWidth < width) {
142
- const left = path ? truncateToWidth(path, width - rightWidth - 1, "...", true) : " ".repeat(width - rightWidth - 1);
143
- return `${left} ${right}`;
141
+ let core = [model, context].filter(Boolean).join(SEGMENT_GAP);
142
+ let quota = usage(Math.min(FULL_QUOTA_WIDTH, width)) ?? "";
143
+ const availableQuotaWidth = width - visibleWidth(core) - QUOTA_GAP.length;
144
+ if (quota && visibleWidth(quota) > availableQuotaWidth) {
145
+ const compactWidth = Math.min(width, Math.max(COMPACT_QUOTA_WIDTH, availableQuotaWidth));
146
+ quota = usage(compactWidth) ?? "";
144
147
  }
145
-
146
- // Drop the path, then extension statuses, then git; protect model/context until last.
147
- let remaining = right;
148
- if (rightWidth > width) remaining = [branch, core].filter(Boolean).join(" ");
149
- if (visibleWidth(remaining) > width) remaining = core;
150
- remaining = truncateToWidth(remaining, width, "...");
151
- return " ".repeat(width - visibleWidth(remaining)) + remaining;
148
+ const quotaGapWidth = quota && core ? QUOTA_GAP.length : 0;
149
+ const coreBudget = Math.max(0, width - visibleWidth(quota) - quotaGapWidth);
150
+ if (quota && coreBudget < MIN_TEXT_WIDTH) {
151
+ core = "";
152
+ quota = usage(Math.min(FULL_QUOTA_WIDTH, width)) ?? "";
153
+ } else if (visibleWidth(core) > coreBudget) {
154
+ const modelGapWidth = model && context ? SEGMENT_GAP.length : 0;
155
+ const modelBudget = coreBudget - visibleWidth(context) - modelGapWidth;
156
+ if (modelBudget > 0) {
157
+ core = [truncateToWidth(model, modelBudget, "..."), context].filter(Boolean).join(SEGMENT_GAP);
158
+ } else {
159
+ core = truncateToWidth(context || model, coreBudget, "...");
160
+ }
161
+ }
162
+ const protectedRight = [core, quota].filter(Boolean).join(QUOTA_GAP);
163
+ const branchReservation = branch ? visibleWidth(branch) + LOCATION_GAP_WIDTH : 0;
164
+ const statusBudget = width - visibleWidth(protectedRight) - branchReservation - SEGMENT_GAP.length;
165
+ let fittedStatuses = "";
166
+ if (statusBudget >= MIN_STATUS_WIDTH) {
167
+ fittedStatuses = statuses;
168
+ if (visibleWidth(statuses) > statusBudget) {
169
+ fittedStatuses = truncateToWidth(statuses, statusBudget - 1, "...") + ")";
170
+ }
171
+ }
172
+ const right = [fittedStatuses, protectedRight].filter(Boolean).join(SEGMENT_GAP);
173
+ const locationGapWidth = right ? LOCATION_GAP_WIDTH : 0;
174
+ const leftBudget = Math.max(0, width - visibleWidth(right) - locationGapWidth);
175
+ const fittedBranch = visibleWidth(branch) <= leftBudget ? branch : "";
176
+ const pathGapWidth = fittedBranch && path ? SEGMENT_GAP.length : 0;
177
+ const pathBudget = leftBudget - visibleWidth(fittedBranch) - pathGapWidth;
178
+ const fittedPath = pathBudget >= MIN_TEXT_WIDTH ? truncateToWidth(path, pathBudget, "...") : "";
179
+ const left = [fittedPath, fittedBranch].filter(Boolean).join(SEGMENT_GAP);
180
+ return left + " ".repeat(Math.max(0, width - visibleWidth(left) - visibleWidth(right))) + right;
152
181
  }
153
182
 
154
183
  // ── Extension ─────────────────────────────────────────────────────────
@@ -159,13 +188,15 @@ export default function (pi: ExtensionAPI) {
159
188
  const state: FooterState = { cwd: process.cwd(), model: "no-model", context: "?" };
160
189
  let requestRender: (() => void) | null = null;
161
190
  let disposeFooter: (() => void) | null = null;
191
+ const usageLimits = new UsageLimits(() => requestRender?.());
162
192
 
163
193
  function install(ctx: ExtensionContext): void {
164
194
  config = readConfig();
165
195
  enabled = config.enabled !== false;
166
- if (ctx.mode !== "tui") return;
196
+ if (ctx.mode !== "tui") { usageLimits.stop(); return; }
167
197
 
168
198
  if (!enabled) {
199
+ usageLimits.stop();
169
200
  if (disposeFooter) ctx.ui.setFooter(undefined);
170
201
  return;
171
202
  }
@@ -179,7 +210,10 @@ export default function (pi: ExtensionAPI) {
179
210
  if (disposed) return;
180
211
  disposed = true;
181
212
  unsub();
182
- if (requestRender === request) requestRender = null;
213
+ if (requestRender === request) {
214
+ requestRender = null;
215
+ usageLimits.stop();
216
+ }
183
217
  if (disposeFooter === dispose) disposeFooter = null;
184
218
  };
185
219
  disposeFooter = dispose;
@@ -192,18 +226,20 @@ export default function (pi: ExtensionAPI) {
192
226
  const branch = config.showGitBranch ? footerData.getGitBranch() : null;
193
227
  const line = buildLine(
194
228
  width,
195
- config.showPath ? abbreviateHome(state.cwd, homedir()) : "",
196
- skills.length ? `(${skills.join(" | ")})` : "",
197
- branch ? ` ${branch}` : "",
229
+ config.showPath ? theme.fg("dim", abbreviateHome(state.cwd, homedir())) : "",
230
+ skills.length ? theme.fg("dim", `(${skills.join(" | ")})`) : "",
231
+ branch ? theme.fg("dim", ` ${branch}`) : "",
198
232
  config.showModel ? theme.bold(state.model) : "",
199
- config.showContext ? theme.bold(state.context) : "",
233
+ config.showContext ? theme.fg("dim", theme.bold(state.context)) : "",
234
+ (available) => usageLimits.line(available, theme),
200
235
  );
201
- return [theme.fg("dim", line)];
236
+ return [line];
202
237
  },
203
238
  invalidate(): void {},
204
239
  dispose,
205
240
  };
206
241
  });
242
+ usageLimits.select(ctx, enabled);
207
243
  }
208
244
 
209
245
  /** Cheap refresh: update plain state and request one render. */
@@ -215,14 +251,24 @@ export default function (pi: ExtensionAPI) {
215
251
  // ── Lifecycle events (passive; no footer re-install) ────────────────
216
252
 
217
253
  pi.on("session_start", async (_event, ctx) => {
254
+ usageLimits.stop();
218
255
  refresh(ctx);
219
256
  install(ctx);
220
257
  });
221
258
 
222
259
  pi.on("model_select", async (_event, ctx) => {
260
+ usageLimits.select(ctx, enabled);
223
261
  refresh(ctx);
224
262
  });
225
263
 
264
+ pi.on("after_provider_response", (event, ctx) => {
265
+ if (ctx.model) usageLimits.headers(ctx.model.provider, event.headers);
266
+ });
267
+
268
+ pi.on("provider_stream_event", (event) => {
269
+ usageLimits.stream(event.provider, event.model, event.data);
270
+ });
271
+
226
272
  pi.on("turn_end", async (_event, ctx) => {
227
273
  refresh(ctx);
228
274
  });
@@ -240,6 +286,7 @@ export default function (pi: ExtensionAPI) {
240
286
  });
241
287
 
242
288
  pi.on("session_shutdown", async () => {
289
+ usageLimits.stop();
243
290
  disposeFooter?.();
244
291
  });
245
292
 
@@ -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.
@@ -0,0 +1,264 @@
1
+ // Provider endpoint and parsing logic copied and adapted from:
2
+ // https://pi.dev/packages/@ogulcancelik/pi-minimal-footer
3
+ // Copyright (c) 2025 Can Celik. MIT license: ./LICENSE.
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import { readFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+
9
+ export interface QuotaWindow {
10
+ used: number;
11
+ seconds: number | null;
12
+ resetAt: number | null;
13
+ }
14
+
15
+ export function record(value: unknown): Record<string, unknown> {
16
+ return value !== null && typeof value === "object" ? value as Record<string, unknown> : {};
17
+ }
18
+
19
+ function number(value: unknown): number | undefined {
20
+ if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
21
+ if (typeof value === "string" && value.trim()) {
22
+ const n = Number(value);
23
+ return Number.isFinite(n) ? n : undefined;
24
+ }
25
+ return undefined;
26
+ }
27
+
28
+ function resetSeconds(value: unknown): number | undefined {
29
+ const n = typeof value === "string" && !Number.isFinite(Number(value)) ? Date.parse(value) / 1000 : number(value);
30
+ const seconds = n === undefined ? undefined : n > 100_000_000_000 ? n / 1000 : n;
31
+ return seconds !== undefined && seconds > 0 && Number.isFinite(new Date(seconds * 1000).getTime()) ? seconds : undefined;
32
+ }
33
+
34
+ export function windowFrom(used: unknown, seconds: unknown, reset: unknown): QuotaWindow | null {
35
+ if (typeof used !== "number" || !Number.isFinite(used) || used < 0 || used > 100) return null;
36
+ if (seconds != null && (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0)) return null;
37
+ const validReset = typeof reset === "number" && reset > 0 && Number.isFinite(new Date(reset * 1000).getTime());
38
+ return { used, seconds: typeof seconds === "number" ? seconds : null, resetAt: validReset ? reset * 1000 : null };
39
+ }
40
+
41
+ export function shortest(windows: (QuotaWindow | null)[]): QuotaWindow | null {
42
+ return windows.filter((w): w is QuotaWindow => w !== null)
43
+ .sort((a, b) => (a.seconds ?? Infinity) - (b.seconds ?? Infinity) || b.used - a.used)[0] ?? null;
44
+ }
45
+
46
+ const ENDPOINTS: Record<string, string> = {
47
+ "openai-codex": "https://chatgpt.com/backend-api/wham/usage",
48
+ anthropic: "https://api.anthropic.com/api/oauth/usage",
49
+ "github-copilot": "https://api.github.com/copilot_internal/user",
50
+ "google-gemini-cli": "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota",
51
+ minimax: "https://api.minimax.io/v1/token_plan/remains",
52
+ "minimax-cn": "https://api.minimaxi.com/v1/token_plan/remains",
53
+ "kimi-coding": "https://api.kimi.com/coding/v1/usages",
54
+ "opencode-go": "https://opencode.ai/zen/go/v1/usage",
55
+ };
56
+
57
+ export function supportedOrigin(provider: string, baseUrl: string | undefined): boolean {
58
+ if (!ENDPOINTS[provider] || !baseUrl) return false;
59
+ try {
60
+ const url = new URL(baseUrl);
61
+ if (provider === "github-copilot") return url.protocol === "https:" && !url.port &&
62
+ (url.hostname === "githubcopilot.com" || url.hostname.endsWith(".githubcopilot.com"));
63
+ return url.origin === new URL(ENDPOINTS[provider]).origin;
64
+ } catch { return false; }
65
+ }
66
+
67
+ function storedOAuth(provider: string): Record<string, unknown> {
68
+ try {
69
+ const entry = record(record(JSON.parse(readFileSync(join(getAgentDir(), "auth.json"), "utf8")))[provider]);
70
+ return entry.type === "oauth" ? entry : {};
71
+ } catch { return {}; }
72
+ }
73
+
74
+ export async function usageRequest(ctx: ExtensionContext, model: NonNullable<ExtensionContext["model"]>): Promise<{ url: string; init: RequestInit } | null> {
75
+ const provider = model.provider;
76
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
77
+ if (!auth.ok) {
78
+ // Pi exposes missing credentials as a message, alongside transient OAuth errors.
79
+ if (auth.error === `No API key found for "${provider}"`) return null;
80
+ throw new Error(auth.error);
81
+ }
82
+ if (!supportedOrigin(provider, auth.baseUrl ?? model.baseUrl)) return null;
83
+ const authorization = Object.entries(auth.headers ?? {}).find(([name]) => name.toLowerCase() === "authorization")?.[1];
84
+ let token = typeof authorization === "string" ? /^Bearer\s+(.+)$/i.exec(authorization)?.[1] ?? auth.apiKey : auth.apiKey;
85
+ const headers: Record<string, string> = { Accept: "application/json" };
86
+ if (provider === "openai-codex") {
87
+ try {
88
+ const payload = record(JSON.parse(Buffer.from(token?.split(".")[1] ?? "", "base64url").toString("utf8")));
89
+ const id = record(payload["https://api.openai.com/auth"]).chatgpt_account_id;
90
+ if (typeof id !== "string" || !id) return null;
91
+ headers["ChatGPT-Account-Id"] = id;
92
+ } catch { return null; }
93
+ } else if (provider === "anthropic") {
94
+ if (!ctx.modelRegistry.isUsingOAuth?.(model) && !token?.startsWith("sk-ant-oat")) return null;
95
+ headers["anthropic-beta"] = "oauth-2025-04-20";
96
+ } else if (provider === "github-copilot") {
97
+ // Quota API needs the GitHub login token, not the exchanged inference token.
98
+ const refresh = storedOAuth(provider).refresh;
99
+ token = auth.env?.COPILOT_GITHUB_TOKEN ?? process.env.COPILOT_GITHUB_TOKEN ?? (typeof refresh === "string" ? refresh : undefined);
100
+ Object.assign(headers, { "Editor-Version": "vscode/1.96.2", "User-Agent": "GitHubCopilotChat/0.26.7", "X-Github-Api-Version": "2025-04-01" });
101
+ } else if (provider === "google-gemini-cli" && token?.startsWith("{")) {
102
+ const credentials = record(JSON.parse(token));
103
+ token = typeof credentials.token === "string" ? credentials.token : typeof credentials.accessToken === "string" ? credentials.accessToken : undefined;
104
+ }
105
+ if (!token) return null;
106
+ headers.Authorization = `${provider === "github-copilot" ? "token" : "Bearer"} ${token}`;
107
+ const init: RequestInit = { headers };
108
+ if (provider === "google-gemini-cli") {
109
+ init.method = "POST";
110
+ init.body = "{}";
111
+ headers["Content-Type"] = "application/json";
112
+ }
113
+ return { url: ENDPOINTS[provider], init };
114
+ }
115
+
116
+ function isObject(value: unknown): value is Record<string, unknown> {
117
+ return value !== null && typeof value === "object" && !Array.isArray(value);
118
+ }
119
+
120
+ function relativeReset(value: unknown): number | undefined {
121
+ const delay = number(value);
122
+ return delay !== undefined && delay >= 0 ? resetSeconds(Date.now() / 1000 + delay) : undefined;
123
+ }
124
+
125
+ export function parseUsageWindows(provider: string, model: string, payload: unknown): Record<string, QuotaWindow> | null {
126
+ if (!ENDPOINTS[provider]) return null;
127
+ const windows: Record<string, QuotaWindow> = {};
128
+ let recognized = false;
129
+ let malformed = !isObject(payload);
130
+ const data = record(payload);
131
+ const add = (key: string, value: unknown, parse: (w: Record<string, unknown>) => QuotaWindow | null) => {
132
+ if (value === undefined) return;
133
+ recognized = true;
134
+ if (value === null) return;
135
+ const window = isObject(value) ? parse(value) : null;
136
+ if (window) windows[key] = window;
137
+ else malformed = true;
138
+ };
139
+ const finish = () => {
140
+ if (Object.keys(windows).length) return windows;
141
+ if (recognized && !malformed) return null;
142
+ throw new Error(`${provider} usage response malformed`);
143
+ };
144
+ if (provider === "openai-codex") {
145
+ if (Object.hasOwn(data, "rate_limit")) {
146
+ recognized = true;
147
+ if (data.rate_limit !== null && !isObject(data.rate_limit)) malformed = true;
148
+ const limits = record(data.rate_limit);
149
+ for (const [key, name] of [["primary", "primary_window"], ["secondary", "secondary_window"]]) {
150
+ add(key, limits[name], (w) => windowFrom(number(w.used_percent),
151
+ Object.hasOwn(w, "limit_window_seconds") ? number(w.limit_window_seconds) ?? NaN : undefined,
152
+ resetSeconds(w.reset_at) ?? relativeReset(w.reset_after_seconds)));
153
+ }
154
+ if (isObject(data.rate_limit) && Object.keys(limits).length &&
155
+ !Object.hasOwn(limits, "primary_window") && !Object.hasOwn(limits, "secondary_window")) malformed = true;
156
+ }
157
+ return finish();
158
+ }
159
+ if (provider === "anthropic") {
160
+ for (const [name, seconds] of [["five_hour", 18000], ["seven_day", 604800]] as const) {
161
+ add(name, data[name], (w) => windowFrom(number(w.utilization), seconds, resetSeconds(w.resets_at)));
162
+ }
163
+ return finish();
164
+ }
165
+ if (provider === "minimax" || provider === "minimax-cn") {
166
+ const status = number(record(data.base_resp).status_code);
167
+ if (status !== undefined && status !== 0) throw new Error("MiniMax usage unavailable");
168
+ if (Object.hasOwn(data, "model_remains")) {
169
+ recognized = true;
170
+ if (data.model_remains !== null && !Array.isArray(data.model_remains)) malformed = true;
171
+ }
172
+ const buckets = Array.isArray(data.model_remains) ? data.model_remains.map(record) : [];
173
+ const w = buckets.find((b) => b.model_name === "general" && number(b.current_interval_status) === 1)
174
+ ?? buckets.find((b) => b.model_name === "general") ?? buckets.find((b) => number(b.current_interval_status) === 1) ?? buckets[0];
175
+ if (w) {
176
+ let supplied = false;
177
+ for (const [key, prefix, startKey, endKey, fallback] of [
178
+ ["interval", "current_interval", "start_time", "end_time", 18000],
179
+ ["weekly", "current_weekly", "weekly_start_time", "weekly_end_time", 604800],
180
+ ] as const) {
181
+ if (!Object.hasOwn(w, `${prefix}_remaining_percent`)) continue;
182
+ supplied = true;
183
+ const remaining = number(w[`${prefix}_remaining_percent`]);
184
+ const start = resetSeconds(w[startKey]), end = resetSeconds(w[endKey]);
185
+ add(key, w, () => windowFrom(remaining === undefined ? undefined : 100 - remaining,
186
+ start !== undefined && end !== undefined ? end - start : fallback, end));
187
+ }
188
+ if (!supplied) malformed = true;
189
+ }
190
+ return finish();
191
+ }
192
+ if (provider === "kimi-coding") {
193
+ const usedPercent = (detail: Record<string, unknown>) => {
194
+ const limit = number(detail.limit);
195
+ const used = Object.hasOwn(detail, "used") ? number(detail.used) : undefined;
196
+ const remaining = number(detail.remaining);
197
+ const count = Object.hasOwn(detail, "used") ? used : limit !== undefined && remaining !== undefined ? limit - remaining : undefined;
198
+ return limit !== undefined && limit > 0 && count !== undefined && count >= 0 && count <= limit ? count / limit * 100 : undefined;
199
+ };
200
+ const units: Record<string, number> = { TIME_UNIT_SECOND: 1, TIME_UNIT_MINUTE: 60, TIME_UNIT_HOUR: 3600, TIME_UNIT_DAY: 86400, TIME_UNIT_WEEK: 604800, TIME_UNIT_MONTH: 2592000 };
201
+ if (Object.hasOwn(data, "limits")) {
202
+ recognized = true;
203
+ if (data.limits !== null && !Array.isArray(data.limits)) malformed = true;
204
+ }
205
+ for (const value of Array.isArray(data.limits) ? data.limits : []) {
206
+ if (!isObject(value)) {
207
+ malformed = true;
208
+ continue;
209
+ }
210
+ const w = record(value), time = record(w.window), detail = record(w.detail);
211
+ const duration = number(time.duration), unit = typeof time.timeUnit === "string" ? time.timeUnit : "";
212
+ const seconds = duration !== undefined && units[unit] ? duration * units[unit] : NaN;
213
+ add(`${duration}:${unit}`, value, () => windowFrom(usedPercent(detail), seconds, resetSeconds(detail.resetTime)));
214
+ }
215
+ add("weekly", data.usage, (w) => windowFrom(usedPercent(w), 604800, resetSeconds(w.resetTime)));
216
+ return finish();
217
+ }
218
+ if (provider === "opencode-go") {
219
+ for (const [name, seconds] of [["rollingUsage", 18000], ["weeklyUsage", 604800], ["monthlyUsage", 2592000]] as const) {
220
+ add(name, data[name], (w) => windowFrom(number(w.usagePercent), seconds, relativeReset(w.resetInSec)));
221
+ }
222
+ return finish();
223
+ }
224
+ if (provider === "github-copilot") {
225
+ if (Object.hasOwn(data, "quota_snapshots")) {
226
+ recognized = true;
227
+ if (data.quota_snapshots !== null && !isObject(data.quota_snapshots)) malformed = true;
228
+ }
229
+ for (const [name, value] of Object.entries(isObject(data.quota_snapshots) ? data.quota_snapshots : {})) {
230
+ if (record(value).unlimited === true) continue;
231
+ add(name, value, (w) => {
232
+ const remaining = number(w.percent_remaining);
233
+ return windowFrom(remaining === undefined ? undefined : 100 - remaining, 2592000, resetSeconds(data.quota_reset_date_utc));
234
+ });
235
+ }
236
+ return finish();
237
+ }
238
+ // Gemini only exposes quotas for the selected model, or a recognizable model family.
239
+ if (Object.hasOwn(data, "buckets")) {
240
+ recognized = true;
241
+ if (data.buckets !== null && !Array.isArray(data.buckets)) malformed = true;
242
+ }
243
+ const buckets = Array.isArray(data.buckets) ? data.buckets.map(record) : [];
244
+ const valid = buckets.filter((b) => {
245
+ const remaining = number(b.remainingFraction);
246
+ const ok = typeof b.modelId === "string" && b.modelId.length > 0 && remaining !== undefined && remaining >= 0 && remaining <= 1;
247
+ if (!ok) malformed = true;
248
+ return ok;
249
+ });
250
+ const hasExact = buckets.some((b) => b.modelId === model);
251
+ const family = model.toLowerCase().includes("flash") ? "flash" : model.toLowerCase().includes("pro") ? "pro" : null;
252
+ const selected = valid.filter((b) => hasExact ? b.modelId === model : family && String(b.modelId).toLowerCase().includes(family));
253
+ for (const b of selected) {
254
+ const identity = `${b.modelId}:${typeof b.tokenType === "string" ? b.tokenType : ""}`;
255
+ let key = identity, duplicate = 2;
256
+ while (Object.hasOwn(windows, key)) key = `${identity}:${duplicate++}`;
257
+ add(key, b, () => windowFrom((1 - number(b.remainingFraction)!) * 100, undefined, resetSeconds(b.resetTime)));
258
+ }
259
+ return finish();
260
+ }
261
+
262
+ export function parseUsage(provider: string, model: string, payload: unknown): QuotaWindow | null {
263
+ return shortest(Object.values(parseUsageWindows(provider, model, payload) ?? {}));
264
+ }
@@ -0,0 +1,225 @@
1
+ // Usage logic copied and adapted from https://pi.dev/packages/@ogulcancelik/pi-minimal-footer.
2
+ // Copyright (c) 2025 Can Celik. MIT license: ./LICENSE.
3
+ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
4
+
5
+ import { parseUsageWindows, record, shortest, supportedOrigin, usageRequest, windowFrom } from "./quota-providers.js";
6
+ import type { QuotaWindow } from "./quota-providers.js";
7
+
8
+ const REFRESH_MS = 4 * 60_000;
9
+
10
+ function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
11
+ return new Promise((resolve, reject) => {
12
+ const abort = () => {
13
+ signal.removeEventListener("abort", abort);
14
+ reject(new Error("Usage request aborted"));
15
+ };
16
+ signal.addEventListener("abort", abort, { once: true });
17
+ if (signal.aborted) abort();
18
+ // Observe late failures even after abort has settled the caller's await.
19
+ promise.then((value) => {
20
+ signal.removeEventListener("abort", abort);
21
+ resolve(value);
22
+ }, (error) => {
23
+ signal.removeEventListener("abort", abort);
24
+ reject(error);
25
+ });
26
+ });
27
+ }
28
+
29
+ function passiveNumber(value: unknown): number {
30
+ if (typeof value === "number") return value;
31
+ if (typeof value === "string" && value.trim()) return Number(value);
32
+ return NaN;
33
+ }
34
+
35
+ function passiveWindow(used: unknown, seconds: number | undefined, reset: unknown, cached: QuotaWindow | undefined): QuotaWindow | null {
36
+ const window = windowFrom(used, seconds === undefined ? cached?.seconds : seconds, reset);
37
+ // Omitted metadata belongs to the same window only while its duration agrees.
38
+ if (window && reset === undefined && window.seconds === cached?.seconds) window.resetAt = cached.resetAt;
39
+ return window;
40
+ }
41
+
42
+ function fromHeaders(provider: string, headers: Record<string, string>, cached: Record<string, QuotaWindow>): Record<string, QuotaWindow> {
43
+ const windows: Record<string, QuotaWindow> = {};
44
+ let definitions: readonly (readonly [string, string, number?])[];
45
+ if (provider === "openai-codex") {
46
+ definitions = [["primary", "primary"], ["secondary", "secondary"]];
47
+ } else if (provider === "anthropic") {
48
+ definitions = [["five_hour", "5h", 18000], ["seven_day", "7d", 604800]];
49
+ } else {
50
+ return windows;
51
+ }
52
+ const codex = provider === "openai-codex";
53
+ const usedSuffix = codex ? "used-percent" : "utilization";
54
+ const resetSuffix = codex ? "reset-at" : "reset";
55
+ for (const [key, name, defaultSeconds] of definitions) {
56
+ const prefix = codex ? `x-codex-${name}` : `anthropic-ratelimit-unified-${name}`;
57
+ const used = passiveNumber(headers[`${prefix}-${usedSuffix}`]) * (codex ? 1 : 100);
58
+ const durationKey = `${prefix}-window-minutes`;
59
+ const resetKey = `${prefix}-${resetSuffix}`;
60
+ const seconds = Object.hasOwn(headers, durationKey) ? passiveNumber(headers[durationKey]) * 60 : defaultSeconds;
61
+ const reset = Object.hasOwn(headers, resetKey) ? passiveNumber(headers[resetKey]) : undefined;
62
+ const window = passiveWindow(used, seconds, reset, cached[key]);
63
+ if (window) windows[key] = window;
64
+ }
65
+ return windows;
66
+ }
67
+
68
+ function fromStream(data: unknown, cached: Record<string, QuotaWindow>): Record<string, QuotaWindow> {
69
+ const event = record(data);
70
+ if (event.type !== "codex.rate_limits") return {};
71
+ // Other metered pools may be specific to a different model.
72
+ const pool = event.metered_limit_name ?? event.limit_name;
73
+ if (pool !== undefined && pool !== "codex") return {};
74
+ const limits = record(event.rate_limits);
75
+ const windows: Record<string, QuotaWindow> = {};
76
+ for (const key of ["primary", "secondary"]) {
77
+ const w = record(limits[key]);
78
+ const window = passiveWindow(passiveNumber(w.used_percent),
79
+ Object.hasOwn(w, "window_minutes") ? passiveNumber(w.window_minutes) * 60 : undefined,
80
+ Object.hasOwn(w, "reset_at") ? passiveNumber(w.reset_at) : undefined, cached[key]);
81
+ if (window) windows[key] = window;
82
+ }
83
+ return windows;
84
+ }
85
+
86
+ /** Owns quota requests and timers for the currently displayed footer. */
87
+ export class UsageLimits {
88
+ private provider: string | null = null;
89
+ private selection: string | null = null;
90
+ private ctx: ExtensionContext | null = null;
91
+ private windows: Record<string, QuotaWindow> = {};
92
+ private updatedAt: Record<string, number> = {};
93
+ private stale = false;
94
+ private attemptedAt = 0;
95
+ private timer: ReturnType<typeof setTimeout> | null = null;
96
+ private request: AbortController | null = null;
97
+
98
+ constructor(private readonly render: () => void) {}
99
+
100
+ stop(): void {
101
+ this.ctx = null;
102
+ this.provider = null;
103
+ this.selection = null;
104
+ this.windows = {};
105
+ this.updatedAt = {};
106
+ this.stale = false;
107
+ this.attemptedAt = 0;
108
+ this.cancelRequest();
109
+ if (this.timer) clearTimeout(this.timer);
110
+ this.timer = null;
111
+ }
112
+
113
+ select(ctx: ExtensionContext, enabled: boolean): void {
114
+ const model = ctx.model;
115
+ if (!enabled || ctx.mode !== "tui" || !model || !supportedOrigin(model.provider, model.baseUrl)) {
116
+ this.stop();
117
+ return;
118
+ }
119
+ const selection = model.provider + (model.provider === "google-gemini-cli" ? ":" + model.id : "");
120
+ if (this.selection === selection) { this.ctx = ctx; return; }
121
+ this.stop();
122
+ this.ctx = ctx;
123
+ this.provider = model.provider;
124
+ this.selection = selection;
125
+ void this.poll();
126
+ }
127
+
128
+ headers(provider: string, headers: Record<string, string>): void {
129
+ if (provider !== this.provider) return;
130
+ const lower = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
131
+ this.accept(fromHeaders(provider, lower, this.windows));
132
+ }
133
+
134
+ stream(provider: string, model: string, data: unknown): void {
135
+ if (provider !== "openai-codex" || provider !== this.provider || model !== this.ctx?.model?.id) return;
136
+ this.accept(fromStream(data, this.windows));
137
+ }
138
+
139
+ private cancelRequest(): void {
140
+ this.request?.abort();
141
+ this.request = null;
142
+ }
143
+
144
+ private selectedWindow(): [string, QuotaWindow] | null {
145
+ const window = shortest(Object.values(this.windows));
146
+ return window ? Object.entries(this.windows).find(([, value]) => value === window)! : null;
147
+ }
148
+
149
+ private accept(windows: Record<string, QuotaWindow>): void {
150
+ if (!this.ctx || !Object.keys(windows).length) return;
151
+ Object.assign(this.windows, windows);
152
+ for (const key of Object.keys(windows)) this.updatedAt[key] = Date.now();
153
+ const selected = this.selectedWindow()!;
154
+ if (Object.hasOwn(windows, selected[0])) {
155
+ // Only a response for the displayed window supersedes its background refresh.
156
+ this.cancelRequest();
157
+ this.stale = false;
158
+ }
159
+ this.schedule();
160
+ this.render();
161
+ }
162
+
163
+ private schedule(): void {
164
+ if (this.timer) clearTimeout(this.timer);
165
+ this.timer = null;
166
+ if (!this.ctx || this.request) return;
167
+ const selected = this.selectedWindow();
168
+ let next = Math.max(this.attemptedAt, selected ? this.updatedAt[selected[0]] : 0) + REFRESH_MS;
169
+ const resetAt = selected?.[1].resetAt;
170
+ if (resetAt != null && resetAt > this.attemptedAt) next = Math.min(next, resetAt);
171
+ this.timer = setTimeout(() => { this.timer = null; void this.poll(); }, Math.max(1, next - Date.now()));
172
+ this.timer.unref();
173
+ }
174
+
175
+ private async poll(): Promise<void> {
176
+ const ctx = this.ctx;
177
+ const model = ctx?.model;
178
+ if (!ctx || !model || this.request) return;
179
+ const provider = this.provider!;
180
+ const modelId = model.id;
181
+ const controller = new AbortController();
182
+ this.request = controller;
183
+ this.attemptedAt = Date.now();
184
+ const timeout = setTimeout(() => controller.abort(), 5000);
185
+ timeout.unref();
186
+ try {
187
+ const request = await abortable(usageRequest(ctx, model), controller.signal);
188
+ if (this.request !== controller) return;
189
+ if (controller.signal.aborted) throw new Error("Usage authentication timed out");
190
+ if (!request) { this.windows = {}; this.updatedAt = {}; return; }
191
+ const response = await abortable(fetch(request.url, { ...request.init, signal: controller.signal, redirect: "error" }), controller.signal);
192
+ if (!response.ok) { await abortable(Promise.resolve(response.body?.cancel()), controller.signal); throw new Error("Usage unavailable"); }
193
+ const windows = parseUsageWindows(provider, modelId, await abortable(response.json(), controller.signal));
194
+ if (this.request !== controller) return;
195
+ if (controller.signal.aborted) throw new Error("Usage request timed out");
196
+ this.windows = windows ?? {};
197
+ this.updatedAt = Object.fromEntries(Object.keys(this.windows).map((key) => [key, this.attemptedAt]));
198
+ this.stale = false;
199
+ } catch {
200
+ if (this.request === controller) this.stale = true;
201
+ } finally {
202
+ clearTimeout(timeout);
203
+ if (this.request === controller) {
204
+ this.request = null;
205
+ this.schedule();
206
+ this.render();
207
+ }
208
+ }
209
+ }
210
+
211
+ line(width: number, theme: Theme): string | null {
212
+ const window = this.selectedWindow()?.[1];
213
+ if (!this.ctx || !window || width < (window.resetAt === null ? 1 : 6)) return null;
214
+ const date = window.resetAt === null ? null : new Date(window.resetAt);
215
+ const time = date ? `↻${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}` : "";
216
+ const cells = Math.min(5, Math.max(0, width - (time ? 7 : 0)));
217
+ const steps = Math.round(window.used / 100 * cells * 8);
218
+ const filled = "⣿".repeat(Math.floor(steps / 8)) + ["", "⡀", "⣀", "⣄", "⣤", "⣦", "⣶", "⣷"][steps % 8];
219
+ const empty = "⠀".repeat(cells - Math.ceil(steps / 8));
220
+ const stale = this.stale || (window.resetAt !== null && Date.now() >= window.resetAt);
221
+ const color = stale ? "dim" : window.used >= 92 ? "error" : window.used >= 85 ? "warning" : "success";
222
+ const bar = cells ? theme.fg(color, filled) + theme.fg("dim", empty) + (time ? " " : "") : "";
223
+ return bar + theme.fg("dim", time);
224
+ }
225
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-minimal-footer",
3
- "version": "0.1.3",
4
- "description": "A minimal footer extension for pi — clean status line with path, skills, model, and context usage.",
3
+ "version": "0.2.0",
4
+ "description": "An opinionated minimal footer extension for Pi — clean status line with path, extension statuses, model, and context usage.",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "pi-extension",