pi-minimal-footer 0.1.3 → 0.3.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,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-10-06
11
+
12
+ ### Added
13
+
14
+ - `minFooter.powerlineSeparator` (default `true`); set to `false` for a single-space fallback when the terminal font lacks Powerline glyphs
15
+
16
+ ### Changed
17
+
18
+ - Keep README focused on installation and daily use; move design constraints, developer guidance, and provider details into `VISION.md`
19
+ - Separate directory and git branch with ``; render each extension status as its own `` tab, without footer-added parentheses or pipes
20
+ - Group model, context, and quota with spaced ` · ` separators
21
+ - Join the quota bar and reset label without an intervening space
22
+ - Show reset labels as local `↻HH:mm` through 24 hours, remaining whole days/hours above 24 hours (e.g., `↻1d8h`), and whole days above 10 days (e.g., `↻12d`)
23
+ - Grow the quota bar from five to ten cells using spare footer columns, improving steps from 2.5% to 1.25% without shortening other fields; retain the smaller fallback on very narrow terminals
24
+
25
+ ## [0.2.0] - 2026-10-05
26
+
27
+ ### Added
28
+
29
+ - Usage limits for the selected provider: OpenAI Codex, Claude OAuth, GitHub Copilot, Gemini CLI, MiniMax (global/CN), Kimi Coding, and OpenCode Go
30
+ - Show the shortest available quota window, including weekly/monthly when no shorter quota exists; omit reset time when the provider does not report it
31
+ - Compact five-cell Braille quota bar with nine bottom-up fill states (`⠀`, `⡀`, `⣀`, `⣄`, `⣤`, `⣦`, `⣶`, `⣷`, `⣿`), 2.5% steps, and green/amber/red usage colors
32
+ - Absolute local reset time marked with `↻` in 24-hour format, e.g. `⣿⣶⠀⠀⠀ ↻16:40`, without provider/window labels or numeric percentages
33
+ - Passive Codex/Claude quota updates from provider responses, with four-minute usage-endpoint fallback and reset-time refresh using Pi's existing credentials
34
+ - 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
35
+ - Hide usage for local/unsupported models, missing credentials, or absent applicable limits; stop quota work on footer teardown, disable, and session shutdown
36
+ - README examples and documentation for the complete usage feature, its shading, reset time, refresh behavior, and edge cases
37
+ - 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
38
+
39
+ ### Changed
40
+
41
+ - 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
42
+ - Shorten location and statuses on narrow terminals, shrink quota before truncating model/context, and preserve the reset time
43
+ - Simplify quota header parsing and footer width calculations; remove the unused duplicate Claude header parser
44
+
45
+ ### Fixed
46
+
47
+ - 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
48
+ - Merge partial Codex/Claude quota signals without replacing a cached shorter window or postponing its four-minute refresh when only longer windows update
49
+ - Preserve dim cached usage after malformed responses, while explicit empty/unlimited quotas clear it; retain valid usage when optional reset metadata is invalid
50
+ - Accept Kimi used-only quotas and week/month durations, and derive Codex reset times from relative endpoint delays when an absolute timestamp is unavailable
51
+ - Clear cached quota when Pi reports missing credentials; retain dim cached usage for temporary authentication failures and recover after credentials are restored
52
+
10
53
  ## [0.1.3] - 2026-10-05
11
54
 
12
55
  ### Added
@@ -86,7 +129,9 @@ First npm-ready release.
86
129
 
87
130
  - Project scaffold — `extensions/index.ts` with basic footer structure, `package.json` with pi extension manifest, `README.md`, `LICENSE` (MIT)
88
131
 
89
- [Unreleased]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.1.3...HEAD
132
+ [Unreleased]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.3.0...HEAD
133
+ [0.3.0]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.2.0...v0.3.0
134
+ [0.2.0]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.1.3...v0.2.0
90
135
  [0.1.3]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.1.2...v0.1.3
91
136
  [0.1.2]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.1.1...v0.1.2
92
137
  [0.1.1]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.1.0...v0.1.1
package/README.md CHANGED
@@ -1,75 +1,45 @@
1
1
  # pi-minimal-footer
2
2
 
3
3
  <p>
4
- <a href="https://www.npmjs.com/package/pi-minimal-footer">
5
- <img src="https://img.shields.io/npm/v/pi-minimal-footer" alt="npm version">
6
- </a>
7
- <a href="https://www.npmjs.com/package/pi-minimal-footer">
8
- <img src="https://img.shields.io/npm/dt/pi-minimal-footer" alt="npm downloads">
9
- </a>
10
- <a href="LICENSE">
11
- <img src="https://img.shields.io/npm/l/pi-minimal-footer" alt="license">
12
- </a>
13
- <a href="https://pi.dev/packages/pi-minimal-footer">
14
- <img src="https://img.shields.io/badge/pi-package-1a1a2e" alt="pi package">
15
- </a>
4
+ <a href="https://www.npmjs.com/package/pi-minimal-footer"><img src="https://img.shields.io/npm/v/pi-minimal-footer" alt="npm version"></a>
5
+ <a href="https://www.npmjs.com/package/pi-minimal-footer"><img src="https://img.shields.io/npm/dt/pi-minimal-footer" alt="npm downloads"></a>
6
+ <a href="LICENSE"><img src="https://img.shields.io/npm/l/pi-minimal-footer" alt="license"></a>
7
+ <a href="https://pi.dev/packages/pi-minimal-footer"><img src="https://img.shields.io/badge/pi-package-1a1a2e" alt="pi package"></a>
16
8
  </p>
17
9
 
18
- A clean, compact one-line footer for [Pi](https://github.com/earendil-works/pi).
10
+ An opinionated, compact one-line footer for [Pi](https://github.com/earendil-works/pi).
19
11
 
12
+ ```text
13
+ ~/git/project   main  🧠 Karpathy  🪽 Icarus  model · 42/200k · ⣿⣿⣿⣤⠀⠀⠀⠀⠀⠀↻1d8h
20
14
  ```
21
- ~/path/to/dir (status1 | status2)  main sonnet 12/128k
22
- ```
23
15
 
24
- ![pi-minimal-footer screenshot](media/github-preview.png)
16
+ Path and branch on the left; extension statuses, model, context, and provider quota on the right. Shrinks to fit narrow terminals. Interactive terminal UI only.
17
+
18
+ ![Footer preview: ~/git/project   main  🧠 Karpathy  🪽 Icarus  model · 42/200k · ⣿⣿⣿⣤⠀⠀⠀⠀⠀⠀↻1d8h](media/github-preview.png)
19
+
20
+ *Screenshot shows an earlier layout; the text preview reflects current source.*
25
21
 
26
22
  ## Install
27
23
 
28
- Available on the [Pi package gallery](https://pi.dev/packages/pi-minimal-footer).
24
+ Requires Node.js >=22.19.0.
29
25
 
30
26
  ```bash
31
- # From npm (recommended)
32
27
  pi install npm:pi-minimal-footer
33
-
34
- # From git
28
+ # Or install from source:
35
29
  pi install git:github.com/Ryu-CZ/pi-minimal-footer
36
-
37
- # Manual — copy into your extensions directory
38
- cp -r extensions/* ~/.pi/agent/extensions/
39
-
40
- # Development — symlink for live edits
41
- ln -s "$PWD/extensions" ~/.pi/agent/extensions/minimal-footer
42
30
  ```
43
31
 
44
- ## Development
45
-
46
- Requires Node.js >=22.19.0. Run `npm ci`, `npm run check`, and `npm test`. Pi loads TypeScript directly; no build is needed. For an isolated local preview, run `pi --no-extensions -e ./extensions/index.ts`. After source edits, use `/reload` in Pi. Tests use Pi 1.0.3 and make no provider requests.
47
-
48
- ## Features
49
-
50
- - **Working directory** — home abbreviated as `~`; paths outside home remain absolute
51
- - **Extension statuses** — text reported by extensions through Pi's status API (`showSkills` retains its existing setting name; it does not discover installed skills)
52
- - **Git branch** — current branch name
53
- - **Model** — active model ID
54
- - **Context usage** — tokens used / context window (e.g., `12/128k`). After compaction, Pi reports usage as unknown (`?`) until a subsequent model response.
55
-
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
-
58
- The footer appears only in Pi's interactive terminal UI, not in print, JSON, or RPC modes.
59
-
60
32
  ## Commands
61
33
 
62
- | Command | Description |
34
+ | Command | Action |
63
35
  |---|---|
64
- | `/minfooter` | Toggle the extension on/off |
36
+ | `/minfooter` | Toggle footer |
65
37
  | `/minfooter on` | Enable |
66
38
  | `/minfooter off` | Disable |
67
39
 
68
- > The `/minfooter` command only toggles the `enabled` flag. To show or hide individual segments, edit the agent settings file directly.
69
-
70
- ## Configuration
40
+ ## Settings
71
41
 
72
- Settings live in Pi's configured agent directory, in `settings.json` under the `minFooter` key (normally `~/.pi/agent/settings.json`). The footer reads settings on session start and when `/minfooter` is run; external edits do not take effect until one of those actions (there is no file watcher). Unrelated settings are preserved when toggling.
42
+ Edit `minFooter` in Pi's agent `settings.json` (normally `~/.pi/agent/settings.json`). All defaults are shown below. Apply edits with `/reload` or `/minfooter on`.
73
43
 
74
44
  ```json
75
45
  {
@@ -79,7 +49,30 @@ Settings live in Pi's configured agent directory, in `settings.json` under the `
79
49
  "showSkills": true,
80
50
  "showPath": true,
81
51
  "showModel": true,
82
- "showContext": true
52
+ "showContext": true,
53
+ "powerlineSeparator": true
83
54
  }
84
55
  }
85
56
  ```
57
+
58
+ - `showSkills` shows **extension status text**, not installed skills. The footer does not add the example statuses itself.
59
+ - Set `powerlineSeparator` to `false` for plain spaces instead of `` / `` if your font lacks those glyphs. The git icon `` also needs a compatible font; hide it with `showGitBranch: false` if needed.
60
+ - Context `42/200k` means tokens used / context window. `?` means Pi has not reported usage yet, including immediately after compaction.
61
+
62
+ ## Reading quota
63
+
64
+ `⣿⣿⣿⣤⠀⠀⠀⠀⠀⠀↻1d8h` shows **account quota used**, not context usage.
65
+
66
+ - Bar: 5–10 cells, expanding into spare space; green below 85%, amber from 85%, red from 92%. Dim means cached after a refresh failure. Very narrow terminals may show fewer cells or only the reset label.
67
+ - Reset: local `↻HH:mm` through 24 hours; whole days/hours above 24 hours (`↻1d8h`); whole days above 10 days (`↻12d`). Remaining durations round down. No reset reported means bar only.
68
+ - Shows the shortest available quota window for the selected provider. Missing credentials, unsupported/local models, or absent limits hide quota. Custom proxy endpoints are not polled.
69
+
70
+ Supported adapters: **OpenAI Codex, Claude OAuth, GitHub Copilot, Gemini CLI, MiniMax, Kimi Coding, and OpenCode Go**. Uses Pi's existing credentials; ordinary Claude API keys do not expose subscription quota. Provider availability varies; authenticated endpoints remain unverified live.
71
+
72
+ ## Development & design
73
+
74
+ See [VISION.md](VISION.md) for design constraints, local development, provider details, and verification limits. Release history: [CHANGELOG.md](CHANGELOG.md).
75
+
76
+ ## Credits
77
+
78
+ Provider usage-fetching and parsing logic **copied and adapted from [Can Celik (@ogulcancelik)'s pi-minimal-footer](https://pi.dev/packages/@ogulcancelik/pi-minimal-footer)**. Upstream MIT attribution is retained in [extensions/lib/LICENSE](extensions/lib/LICENSE).
package/VISION.md ADDED
@@ -0,0 +1,106 @@
1
+ # Vision & developer guide
2
+
3
+ ## Purpose
4
+
5
+ An opinionated footer that answers three questions at a glance: where am I, what is running, and how close am I to a limit? Keep one line, avoid redundant labels, and never exceed the terminal width.
6
+
7
+ The [README](README.md) is the user manual. This document records current design and contributor guidance—not a speculative roadmap.
8
+
9
+ ## Layout contract
10
+
11
+ - Directory and git branch form the left group, separated by ``.
12
+ - Each extension status is its own `` tab. Preserve the supplied text; do not invent status labels, parentheses, or pipes.
13
+ - Model, context, and quota form the rightmost group, separated by ` · `. Model identifies the running engine; context describes its usage; quota stays anchored at the right edge.
14
+ - Powerline separators are dim. `powerlineSeparator: false` replaces them with a single space. Font availability cannot be detected reliably.
15
+ - Measure terminal columns with `visibleWidth`, not string length. Preserve ANSI styling and wide-character accounting when truncating.
16
+
17
+ ### Space allocation
18
+
19
+ Reserve quota and model/context before allocating location and statuses. Statuses and location may shorten or disappear; on sufficiently narrow terminals, model/context can also shorten or disappear.
20
+
21
+ Lay out other fields with a five-cell quota bar first. Grow the bar only into leftover columns, up to ten cells—never truncate another field solely to enlarge it. At extreme widths, shrink below five cells, show only the reset label, or hide quota if the label cannot fit.
22
+
23
+ Five cells give 2.5% steps; ten give 1.25%. Fill rounds to the nearest eighth-cell step. Only the final partially filled cell uses an intermediate shade:
24
+
25
+ | Cell | Fill |
26
+ |---|---|
27
+ | `⠀` | Empty |
28
+ | `⡀` | One-eighth |
29
+ | `⣀` | Quarter |
30
+ | `⣄` | Three-eighths |
31
+ | `⣤` | Half |
32
+ | `⣦` | Five-eighths |
33
+ | `⣶` | Three-quarter |
34
+ | `⣷` | Seven-eighths |
35
+ | `⣿` | Full |
36
+
37
+ The bar and reset label have no intervening space. Reset formatting uses local clock time through 24 hours, remaining whole days/hours above 24 hours, and whole days above 10 days. Exactly 24 hours stays clock time; exactly 10 days is `↻10d0h`. Missing reset metadata leaves the bar visible without a label.
38
+
39
+ ## Quota contract
40
+
41
+ Display only the selected provider's shortest applicable window. Account quota and model context usage are separate measurements.
42
+
43
+ | Provider | Selection / caveat |
44
+ |---|---|
45
+ | OpenAI Codex (`openai-codex`) | Shortest primary/secondary window; supports absolute reset timestamps and relative reset delays |
46
+ | Claude (`anthropic`, OAuth) | Five-hour window, or weekly fallback; ordinary API keys do not expose subscription quota |
47
+ | GitHub Copilot (`github-copilot`) | Most-used limited quota bucket; unlimited buckets hidden |
48
+ | Gemini CLI (`google-gemini-cli`) | Selected model, with Pro/Flash family fallback; needs a configured provider/model |
49
+ | MiniMax (`minimax`, `minimax-cn`) | Prefer general bucket, then active bucket, then first bucket; shortest interval/weekly window |
50
+ | Kimi Coding (`kimi-coding`) | Shortest available window; accepts used or remaining counts and week/month durations |
51
+ | OpenCode Go (`opencode-go`) | Shortest rolling/weekly/monthly window |
52
+
53
+ Reuse Pi's selected-provider credentials. Copilot uses the GitHub login token from Pi's configured agent directory or `COPILOT_GITHUB_TOKEN`. Pi 1.0.3 does not include Gemini CLI in its built-in model catalog. Do not poll custom proxy endpoints or make model requests to obtain usage.
54
+
55
+ ### Refresh and failure behavior
56
+
57
+ - Consume passive Codex response headers/stream events and Claude quota headers when available.
58
+ - Otherwise fetch on startup/provider switch, then every four minutes without a fresh response update; also refresh at the reported reset time.
59
+ - Merge partial windows. A weekly-only signal must not replace a cached shorter window or postpone its refresh.
60
+ - Bound authentication, fetch, and response-body parsing by a shared five-second timeout. Release stalled work so polling can recover.
61
+ - Provider switches clear quota immediately, cancel pending work, and invalidate late results. Gemini model switches also refresh model-specific quota.
62
+ - Missing credentials and explicit empty/unlimited responses clear quota. Temporary authentication, network, or parsing failures retain only the current provider's cached quota, dimmed; without cache, hide it.
63
+ - Invalid optional reset metadata must not discard otherwise valid usage. Zero usage is an empty bar, not an absent quota.
64
+ - Stop requests and timers on disable, disposal, and session shutdown.
65
+
66
+ ### Verification limits
67
+
68
+ Parsing and lifecycle behavior have automated coverage. Authenticated provider endpoints have **not** been tested live. OpenCode Go's bearer-token response format and Copilot's public endpoint/enterprise compatibility remain unverified.
69
+
70
+ The comparison with [mtrojnar/pi-usage](https://github.com/mtrojnar/pi-usage) informed timeout, validation, and partial-update handling; its OpenCode implementation uses an authenticated dashboard rather than this adapter's bearer-token endpoint.
71
+
72
+ ## Local development
73
+
74
+ Requires Node.js >=22.19.0. Tests currently use Pi 1.0.3 and mock provider requests.
75
+
76
+ ```bash
77
+ npm ci
78
+ npm run check
79
+ npm test
80
+
81
+ # Isolated interactive preview; Pi loads TypeScript directly, no build needed:
82
+ pi --no-extensions -e ./extensions/index.ts
83
+ ```
84
+
85
+ Use `/reload` after edits. For a persistent development install:
86
+
87
+ ```bash
88
+ ln -s "$PWD/extensions" ~/.pi/agent/extensions/minimal-footer
89
+ ```
90
+
91
+ For a manual install, copy `extensions/*` into `~/.pi/agent/extensions/`.
92
+
93
+ ### Code map
94
+
95
+ - `extensions/index.ts`: settings, state refresh, layout, footer lifecycle, and `/minfooter`.
96
+ - `extensions/lib/usage-limits.ts`: quota refresh, cache, cancellation, and bar/reset rendering.
97
+ - `extensions/lib/quota-providers.ts`: authentication, endpoint selection, and response normalization.
98
+ - `tests/`: loader-based lifecycle/layout regression tests and quota parsing tests.
99
+
100
+ Settings live under `minFooter` in Pi's configured agent directory. Read them at session start or explicit toggle; ordinary refreshes use cached settings. Preserve unrelated settings on writes. Only install the footer in interactive terminal mode.
101
+
102
+ ### Checking changes
103
+
104
+ For behavior changes, run `npm run check` and `npm test`. Layout tests should cover narrow widths, ANSI text, wide Unicode, field priority, and both separator modes. Quota changes should cover provider switches, timeouts, stale cache, partial updates, and malformed/missing metadata as applicable. Preview in a real terminal for font-dependent appearance; automated width checks cannot prove glyph availability.
105
+
106
+ Keep release notes in [CHANGELOG.md](CHANGELOG.md), and keep the README focused on installation and daily use. Preserve [upstream MIT attribution](extensions/lib/LICENSE) when changing adapted provider code.
@@ -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
 
@@ -28,6 +29,7 @@ interface Settings {
28
29
  showPath?: boolean;
29
30
  showModel?: boolean;
30
31
  showContext?: boolean;
32
+ powerlineSeparator?: boolean;
31
33
  };
32
34
  }
33
35
 
@@ -40,6 +42,7 @@ const DEFAULT_SETTINGS: FooterSettings = {
40
42
  showPath: true,
41
43
  showModel: true,
42
44
  showContext: true,
45
+ powerlineSeparator: true,
43
46
  };
44
47
 
45
48
  function settingsPath(): string {
@@ -123,32 +126,58 @@ function updateState(ctx: ExtensionContext, state: FooterState): void {
123
126
 
124
127
  // ── Layout ────────────────────────────────────────────────────────────
125
128
  //
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).
129
+ // Keep quota and reset time together; shorten location/statuses before model/context.
130
+ // All segment measurements use visible widths, including ANSI and wide characters.
134
131
 
135
- function buildLine(width: number, path: string, statuses: string, branch: string, model: string, context: string): string {
136
- 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);
132
+ const MODEL_GAP = " · ";
133
+ const QUOTA_GAP = " · ";
134
+ const LOCATION_GAP_WIDTH = 3;
135
+ const MIN_TEXT_WIDTH = 4;
136
+ const MIN_STATUS_WIDTH = 12;
140
137
 
141
- if (rightWidth < width) {
142
- const left = path ? truncateToWidth(path, width - rightWidth - 1, "...", true) : " ".repeat(width - rightWidth - 1);
143
- return `${left} ${right}`;
138
+ function buildLine(width: number, path: string, statuses: string, branch: string, model: string, context: string,
139
+ usage: (available: number, maxCells?: number) => string | null, statusSeparator: string, locationSeparator: string): string {
140
+ if (width <= 0) return "";
141
+ let core = [model, context].filter(Boolean).join(MODEL_GAP);
142
+ const quota = usage(width) ?? "";
143
+ const quotaGapWidth = quota && core ? QUOTA_GAP.length : 0;
144
+ const coreBudget = Math.max(0, width - visibleWidth(quota) - quotaGapWidth);
145
+ if (quota && coreBudget < MIN_TEXT_WIDTH) {
146
+ core = "";
147
+ } else if (visibleWidth(core) > coreBudget) {
148
+ const modelGapWidth = model && context ? MODEL_GAP.length : 0;
149
+ const modelBudget = coreBudget - visibleWidth(context) - modelGapWidth;
150
+ if (modelBudget > 0) {
151
+ core = [truncateToWidth(model, modelBudget, "..."), context].filter(Boolean).join(MODEL_GAP);
152
+ } else {
153
+ core = truncateToWidth(context || model, coreBudget, "...");
154
+ }
144
155
  }
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;
156
+ const protectedRight = [core, quota].filter(Boolean).join(QUOTA_GAP);
157
+ const branchReservation = branch ? visibleWidth(branch) + LOCATION_GAP_WIDTH : 0;
158
+ const statusBudget = width - visibleWidth(protectedRight) - branchReservation - visibleWidth(statusSeparator);
159
+ let fittedStatuses = "";
160
+ if (statusBudget >= MIN_STATUS_WIDTH) {
161
+ fittedStatuses = statuses;
162
+ if (visibleWidth(statuses) > statusBudget) {
163
+ fittedStatuses = truncateToWidth(statuses, statusBudget, "...");
164
+ }
165
+ }
166
+ let right = [fittedStatuses, protectedRight].filter(Boolean).join(statusSeparator);
167
+ const locationGapWidth = right ? LOCATION_GAP_WIDTH : 0;
168
+ const leftBudget = Math.max(0, width - visibleWidth(right) - locationGapWidth);
169
+ const fittedBranch = visibleWidth(branch) <= leftBudget ? branch : "";
170
+ const pathGapWidth = fittedBranch && path ? visibleWidth(locationSeparator) : 0;
171
+ const pathBudget = leftBudget - visibleWidth(fittedBranch) - pathGapWidth;
172
+ const fittedPath = pathBudget >= MIN_TEXT_WIDTH ? truncateToWidth(path, pathBudget, "...") : "";
173
+ const left = [fittedPath, fittedBranch].filter(Boolean).join(locationSeparator);
174
+ if (quota) {
175
+ const spareWidth = Math.max(0, width - visibleWidth(left) - visibleWidth(right) - locationGapWidth);
176
+ const expandedQuota = usage(visibleWidth(quota) + spareWidth, 10) ?? quota;
177
+ const expandedCore = [core, expandedQuota].filter(Boolean).join(QUOTA_GAP);
178
+ right = [fittedStatuses, expandedCore].filter(Boolean).join(statusSeparator);
179
+ }
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;
@@ -190,20 +224,25 @@ export default function (pi: ExtensionAPI) {
190
224
  ? [...footerData.getExtensionStatuses().values()].filter((s) => s.trim())
191
225
  : [];
192
226
  const branch = config.showGitBranch ? footerData.getGitBranch() : null;
227
+ const statusSeparator = config.powerlineSeparator ? theme.fg("dim", "  ") : " ";
193
228
  const line = buildLine(
194
229
  width,
195
- config.showPath ? abbreviateHome(state.cwd, homedir()) : "",
196
- skills.length ? `(${skills.join(" | ")})` : "",
197
- branch ? ` ${branch}` : "",
230
+ config.showPath ? theme.fg("dim", abbreviateHome(state.cwd, homedir())) : "",
231
+ skills.length ? statusSeparator + skills.map((s) => theme.fg("dim", s)).join(statusSeparator) : "",
232
+ branch ? theme.fg("dim", ` ${branch}`) : "",
198
233
  config.showModel ? theme.bold(state.model) : "",
199
- config.showContext ? theme.bold(state.context) : "",
234
+ config.showContext ? theme.fg("dim", theme.bold(state.context)) : "",
235
+ (available, maxCells = 5) => usageLimits.line(available, theme, maxCells),
236
+ statusSeparator,
237
+ config.powerlineSeparator ? theme.fg("dim", "  ") : " ",
200
238
  );
201
- return [theme.fg("dim", line)];
239
+ return [line];
202
240
  },
203
241
  invalidate(): void {},
204
242
  dispose,
205
243
  };
206
244
  });
245
+ usageLimits.select(ctx, enabled);
207
246
  }
208
247
 
209
248
  /** Cheap refresh: update plain state and request one render. */
@@ -215,14 +254,24 @@ export default function (pi: ExtensionAPI) {
215
254
  // ── Lifecycle events (passive; no footer re-install) ────────────────
216
255
 
217
256
  pi.on("session_start", async (_event, ctx) => {
257
+ usageLimits.stop();
218
258
  refresh(ctx);
219
259
  install(ctx);
220
260
  });
221
261
 
222
262
  pi.on("model_select", async (_event, ctx) => {
263
+ usageLimits.select(ctx, enabled);
223
264
  refresh(ctx);
224
265
  });
225
266
 
267
+ pi.on("after_provider_response", (event, ctx) => {
268
+ if (ctx.model) usageLimits.headers(ctx.model.provider, event.headers);
269
+ });
270
+
271
+ pi.on("provider_stream_event", (event) => {
272
+ usageLimits.stream(event.provider, event.model, event.data);
273
+ });
274
+
226
275
  pi.on("turn_end", async (_event, ctx) => {
227
276
  refresh(ctx);
228
277
  });
@@ -240,6 +289,7 @@ export default function (pi: ExtensionAPI) {
240
289
  });
241
290
 
242
291
  pi.on("session_shutdown", async () => {
292
+ usageLimits.stop();
243
293
  disposeFooter?.();
244
294
  });
245
295
 
@@ -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,234 @@
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, maxCells = 10): string | null {
212
+ const window = this.selectedWindow()?.[1];
213
+ if (!this.ctx || !window) return null;
214
+ const now = Date.now();
215
+ const date = window.resetAt === null ? null : new Date(window.resetAt);
216
+ const remaining = window.resetAt === null ? 0 : window.resetAt - now;
217
+ const day = 24 * 60 * 60 * 1000;
218
+ let time = date ? `↻${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}` : "";
219
+ if (remaining > 10 * day) {
220
+ time = `↻${Math.floor(remaining / day)}d`;
221
+ } else if (remaining > day) {
222
+ time = `↻${Math.floor(remaining / day)}d${Math.floor(remaining % day / (60 * 60 * 1000))}h`;
223
+ }
224
+ if (width < (time ? time.length : 1)) return null;
225
+ const cells = Math.min(maxCells, Math.max(0, width - time.length));
226
+ const steps = Math.round(window.used / 100 * cells * 8);
227
+ const filled = "⣿".repeat(Math.floor(steps / 8)) + ["", "⡀", "⣀", "⣄", "⣤", "⣦", "⣶", "⣷"][steps % 8];
228
+ const empty = "⠀".repeat(cells - Math.ceil(steps / 8));
229
+ const stale = this.stale || (window.resetAt !== null && now >= window.resetAt);
230
+ const color = stale ? "dim" : window.used >= 92 ? "error" : window.used >= 85 ? "warning" : "success";
231
+ const bar = cells ? theme.fg(color, filled) + theme.fg("dim", empty) : "";
232
+ return bar + theme.fg("dim", time);
233
+ }
234
+ }
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.3.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",
@@ -20,6 +20,7 @@
20
20
  "extensions/",
21
21
  "LICENSE",
22
22
  "README.md",
23
+ "VISION.md",
23
24
  "package.json",
24
25
  "media/"
25
26
  ],