pi-minimal-footer 0.1.2 → 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 +52 -1
- package/README.md +82 -7
- package/extensions/index.ts +167 -94
- package/extensions/lib/LICENSE +21 -0
- package/extensions/lib/quota-providers.ts +264 -0
- package/extensions/lib/usage-limits.ts +225 -0
- package/media/github-preview.png +0 -0
- package/media/preview.png +0 -0
- package/package.json +12 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,55 @@ 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
|
+
|
|
38
|
+
## [0.1.3] - 2026-10-05
|
|
39
|
+
|
|
40
|
+
### Added
|
|
41
|
+
|
|
42
|
+
- Strict TypeScript type checking for the Pi extension
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
|
|
46
|
+
- Refresh README and package-gallery screenshots with Pi 1.0.3 and the current minimal footer
|
|
47
|
+
- Refresh context after persisted turns, final agent settlement, compaction, and session-tree navigation without reinstalling the footer on each event
|
|
48
|
+
- Read `minFooter` from Pi's configured agent directory at session start or explicit toggle; retain unrelated settings
|
|
49
|
+
- On narrow terminals, shrink/drop the path, then extension statuses, then git branch before truncating model/context
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
|
|
53
|
+
- Preserve right alignment on narrow terminals after dropping extension statuses or git branch, including truncated model/context
|
|
54
|
+
- Avoid stale branch subscriptions and render references after footer teardown
|
|
55
|
+
- Abbreviate home only for the exact home path or its children
|
|
56
|
+
- Show the custom footer only in terminal UI mode
|
|
57
|
+
- Clarify that `showSkills` displays extension statuses, not installed skills
|
|
58
|
+
|
|
10
59
|
## [0.1.2] - 2026-09-01
|
|
11
60
|
|
|
12
61
|
### Changed
|
|
@@ -65,7 +114,9 @@ First npm-ready release.
|
|
|
65
114
|
|
|
66
115
|
- Project scaffold — `extensions/index.ts` with basic footer structure, `package.json` with pi extension manifest, `README.md`, `LICENSE` (MIT)
|
|
67
116
|
|
|
68
|
-
[Unreleased]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.
|
|
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
|
|
119
|
+
[0.1.3]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.1.2...v0.1.3
|
|
69
120
|
[0.1.2]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.1.1...v0.1.2
|
|
70
121
|
[0.1.1]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.1.0...v0.1.1
|
|
71
122
|
[0.1.0]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.0.0...v0.1.0
|
package/README.md
CHANGED
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
</a>
|
|
16
16
|
</p>
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
An opinionated, clean, compact footer for [Pi](https://github.com/earendil-works/pi).
|
|
19
19
|
|
|
20
20
|
```
|
|
21
|
-
~/
|
|
21
|
+
~/git/pi-minimal-footer main (icarus | goal running) gpt-5.6-sol 12/128k ⣿⣶⠀⠀⠀ ↻16:40
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|

|
|
@@ -41,13 +41,88 @@ cp -r extensions/* ~/.pi/agent/extensions/
|
|
|
41
41
|
ln -s "$PWD/extensions" ~/.pi/agent/extensions/minimal-footer
|
|
42
42
|
```
|
|
43
43
|
|
|
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
|
+
|
|
44
48
|
## Features
|
|
45
49
|
|
|
46
|
-
- **Working directory** —
|
|
47
|
-
- **
|
|
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)
|
|
48
52
|
- **Git branch** — current branch name
|
|
49
53
|
- **Model** — active model ID
|
|
50
|
-
- **Context usage** — tokens used / context window (e.g., `12/128k`)
|
|
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.
|
|
56
|
+
|
|
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.
|
|
60
|
+
|
|
61
|
+
The footer appears only in Pi's interactive terminal UI, not in print, JSON, or RPC modes.
|
|
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).
|
|
51
126
|
|
|
52
127
|
## Commands
|
|
53
128
|
|
|
@@ -57,11 +132,11 @@ ln -s "$PWD/extensions" ~/.pi/agent/extensions/minimal-footer
|
|
|
57
132
|
| `/minfooter on` | Enable |
|
|
58
133
|
| `/minfooter off` | Disable |
|
|
59
134
|
|
|
60
|
-
> The `/minfooter` command only toggles the `enabled` flag. To show or hide individual segments, edit
|
|
135
|
+
> The `/minfooter` command only toggles the `enabled` flag. To show or hide individual segments, edit the agent settings file directly.
|
|
61
136
|
|
|
62
137
|
## Configuration
|
|
63
138
|
|
|
64
|
-
Settings live in
|
|
139
|
+
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.
|
|
65
140
|
|
|
66
141
|
```json
|
|
67
142
|
{
|
package/extensions/index.ts
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Minimal footer — replaces pi's default footer with a clean status line:
|
|
3
3
|
*
|
|
4
|
-
* ~/path/to/dir
|
|
4
|
+
* ~/path/to/dir main (status1 | status2) sonnet 12/128k ⣿⣶⠀⠀⠀ ↻16:40
|
|
5
5
|
*
|
|
6
|
-
* Settings are persisted in ~/.pi/agent
|
|
6
|
+
* Settings are persisted in the agent directory (usually ~/.pi/agent)
|
|
7
|
+
* settings.json under "minFooter".
|
|
7
8
|
*
|
|
8
9
|
* Commands:
|
|
9
10
|
* /minfooter — toggle on/off
|
|
10
11
|
* /minfooter on|off — force state
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
|
-
import type { ExtensionAPI, ExtensionContext
|
|
14
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
14
16
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
15
17
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
16
|
-
import { join, dirname } from "node:path";
|
|
18
|
+
import { join, dirname, sep } from "node:path";
|
|
17
19
|
import { homedir } from "node:os";
|
|
20
|
+
import { UsageLimits } from "./lib/usage-limits.js";
|
|
18
21
|
|
|
19
22
|
// ── Settings ──────────────────────────────────────────────────────────
|
|
20
23
|
|
|
@@ -41,7 +44,7 @@ const DEFAULT_SETTINGS: FooterSettings = {
|
|
|
41
44
|
};
|
|
42
45
|
|
|
43
46
|
function settingsPath(): string {
|
|
44
|
-
return join(
|
|
47
|
+
return join(getAgentDir(), "settings.json");
|
|
45
48
|
}
|
|
46
49
|
|
|
47
50
|
function readSettings(): Settings {
|
|
@@ -64,11 +67,6 @@ function writeSettings(patch: Partial<Settings>): void {
|
|
|
64
67
|
}
|
|
65
68
|
}
|
|
66
69
|
|
|
67
|
-
function readEnabled(): boolean {
|
|
68
|
-
const s = readSettings();
|
|
69
|
-
return s.minFooter?.enabled !== false; // default to true
|
|
70
|
-
}
|
|
71
|
-
|
|
72
70
|
function readConfig(): FooterSettings {
|
|
73
71
|
const s = readSettings();
|
|
74
72
|
return { ...DEFAULT_SETTINGS, ...s.minFooter };
|
|
@@ -89,19 +87,22 @@ function formatSize(n: number | null | undefined): string {
|
|
|
89
87
|
return String(n);
|
|
90
88
|
}
|
|
91
89
|
|
|
90
|
+
/** Abbreviate only an exact home match or a boundary-prefixed child path. */
|
|
91
|
+
function abbreviateHome(p: string, home: string): string {
|
|
92
|
+
if (p === home) return "~";
|
|
93
|
+
if (p.startsWith(home + sep)) return "~" + p.slice(home.length);
|
|
94
|
+
return p;
|
|
95
|
+
}
|
|
96
|
+
|
|
92
97
|
// ── State ─────────────────────────────────────────────────────────────
|
|
93
98
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
context: "?",
|
|
100
|
-
branch: "",
|
|
101
|
-
footerData: null as ReadonlyFooterDataProvider | null,
|
|
102
|
-
};
|
|
99
|
+
interface FooterState {
|
|
100
|
+
cwd: string;
|
|
101
|
+
model: string;
|
|
102
|
+
context: string;
|
|
103
|
+
}
|
|
103
104
|
|
|
104
|
-
function updateState(ctx: ExtensionContext): void {
|
|
105
|
+
function updateState(ctx: ExtensionContext, state: FooterState): void {
|
|
105
106
|
state.cwd = ctx.cwd ?? process.cwd();
|
|
106
107
|
state.model = ctx.model?.id ?? "no-model";
|
|
107
108
|
const usage = ctx.getContextUsage();
|
|
@@ -121,101 +122,172 @@ function updateState(ctx: ExtensionContext): void {
|
|
|
121
122
|
}
|
|
122
123
|
}
|
|
123
124
|
|
|
124
|
-
// ──
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
125
|
+
// ── Layout ────────────────────────────────────────────────────────────
|
|
126
|
+
//
|
|
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 {
|
|
140
|
+
if (width <= 0) return "";
|
|
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) ?? "";
|
|
131
147
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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, "...");
|
|
143
160
|
}
|
|
144
161
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
if (config.showContext) {
|
|
156
|
-
segments.push(bold(state.context));
|
|
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
|
+
}
|
|
157
171
|
}
|
|
158
|
-
|
|
159
|
-
|
|
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;
|
|
160
181
|
}
|
|
161
182
|
|
|
162
183
|
// ── Extension ─────────────────────────────────────────────────────────
|
|
163
184
|
|
|
164
185
|
export default function (pi: ExtensionAPI) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const rightW = visibleWidth(right);
|
|
182
|
-
|
|
183
|
-
// If the right side (model + context) alone doesn't fit, drop the path and truncate it.
|
|
184
|
-
if (rightW >= width) {
|
|
185
|
-
return [theme.fg("dim", truncateToWidth(right, width, ""))];
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// Otherwise keep the right side intact and fit the path into the remaining
|
|
189
|
-
// space (padded to exactly fill it, keeping the right side right-aligned).
|
|
190
|
-
const leftPart = truncateToWidth(left, width - rightW - 1, "...", true);
|
|
191
|
-
return [theme.fg("dim", `${leftPart} ${right}`)];
|
|
192
|
-
},
|
|
193
|
-
invalidate(): void {},
|
|
194
|
-
dispose: unsub,
|
|
195
|
-
};
|
|
196
|
-
});
|
|
197
|
-
} else {
|
|
198
|
-
ctx.ui.setFooter(undefined);
|
|
186
|
+
let config = readConfig();
|
|
187
|
+
let enabled = config.enabled !== false;
|
|
188
|
+
const state: FooterState = { cwd: process.cwd(), model: "no-model", context: "?" };
|
|
189
|
+
let requestRender: (() => void) | null = null;
|
|
190
|
+
let disposeFooter: (() => void) | null = null;
|
|
191
|
+
const usageLimits = new UsageLimits(() => requestRender?.());
|
|
192
|
+
|
|
193
|
+
function install(ctx: ExtensionContext): void {
|
|
194
|
+
config = readConfig();
|
|
195
|
+
enabled = config.enabled !== false;
|
|
196
|
+
if (ctx.mode !== "tui") { usageLimits.stop(); return; }
|
|
197
|
+
|
|
198
|
+
if (!enabled) {
|
|
199
|
+
usageLimits.stop();
|
|
200
|
+
if (disposeFooter) ctx.ui.setFooter(undefined);
|
|
201
|
+
return;
|
|
199
202
|
}
|
|
203
|
+
|
|
204
|
+
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
205
|
+
const unsub = footerData.onBranchChange(() => tui.requestRender());
|
|
206
|
+
const request = () => tui.requestRender();
|
|
207
|
+
requestRender = request;
|
|
208
|
+
let disposed = false;
|
|
209
|
+
const dispose = () => {
|
|
210
|
+
if (disposed) return;
|
|
211
|
+
disposed = true;
|
|
212
|
+
unsub();
|
|
213
|
+
if (requestRender === request) {
|
|
214
|
+
requestRender = null;
|
|
215
|
+
usageLimits.stop();
|
|
216
|
+
}
|
|
217
|
+
if (disposeFooter === dispose) disposeFooter = null;
|
|
218
|
+
};
|
|
219
|
+
disposeFooter = dispose;
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
render(width: number): string[] {
|
|
223
|
+
const skills = config.showSkills
|
|
224
|
+
? [...footerData.getExtensionStatuses().values()].filter((s) => s.trim())
|
|
225
|
+
: [];
|
|
226
|
+
const branch = config.showGitBranch ? footerData.getGitBranch() : null;
|
|
227
|
+
const line = buildLine(
|
|
228
|
+
width,
|
|
229
|
+
config.showPath ? theme.fg("dim", abbreviateHome(state.cwd, homedir())) : "",
|
|
230
|
+
skills.length ? theme.fg("dim", `(${skills.join(" | ")})`) : "",
|
|
231
|
+
branch ? theme.fg("dim", ` ${branch}`) : "",
|
|
232
|
+
config.showModel ? theme.bold(state.model) : "",
|
|
233
|
+
config.showContext ? theme.fg("dim", theme.bold(state.context)) : "",
|
|
234
|
+
(available) => usageLimits.line(available, theme),
|
|
235
|
+
);
|
|
236
|
+
return [line];
|
|
237
|
+
},
|
|
238
|
+
invalidate(): void {},
|
|
239
|
+
dispose,
|
|
240
|
+
};
|
|
241
|
+
});
|
|
242
|
+
usageLimits.select(ctx, enabled);
|
|
200
243
|
}
|
|
201
244
|
|
|
202
|
-
|
|
245
|
+
/** Cheap refresh: update plain state and request one render. */
|
|
246
|
+
function refresh(ctx: ExtensionContext): void {
|
|
247
|
+
updateState(ctx, state);
|
|
248
|
+
requestRender?.();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── Lifecycle events (passive; no footer re-install) ────────────────
|
|
203
252
|
|
|
204
253
|
pi.on("session_start", async (_event, ctx) => {
|
|
205
|
-
|
|
206
|
-
|
|
254
|
+
usageLimits.stop();
|
|
255
|
+
refresh(ctx);
|
|
256
|
+
install(ctx);
|
|
207
257
|
});
|
|
208
258
|
|
|
209
259
|
pi.on("model_select", async (_event, ctx) => {
|
|
210
|
-
|
|
260
|
+
usageLimits.select(ctx, enabled);
|
|
261
|
+
refresh(ctx);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
pi.on("after_provider_response", (event, ctx) => {
|
|
265
|
+
if (ctx.model) usageLimits.headers(ctx.model.provider, event.headers);
|
|
211
266
|
});
|
|
212
267
|
|
|
213
|
-
pi.on("
|
|
214
|
-
|
|
268
|
+
pi.on("provider_stream_event", (event) => {
|
|
269
|
+
usageLimits.stream(event.provider, event.model, event.data);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
273
|
+
refresh(ctx);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
277
|
+
refresh(ctx);
|
|
215
278
|
});
|
|
216
279
|
|
|
217
280
|
pi.on("session_compact", async (_event, ctx) => {
|
|
218
|
-
|
|
281
|
+
refresh(ctx);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
285
|
+
refresh(ctx);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
pi.on("session_shutdown", async () => {
|
|
289
|
+
usageLimits.stop();
|
|
290
|
+
disposeFooter?.();
|
|
219
291
|
});
|
|
220
292
|
|
|
221
293
|
// ── Command ─────────────────────────────────────────────────────────
|
|
@@ -227,7 +299,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
227
299
|
else if (args === "off") enabled = false;
|
|
228
300
|
else enabled = !enabled;
|
|
229
301
|
writeEnabled(enabled);
|
|
230
|
-
|
|
302
|
+
refresh(ctx);
|
|
303
|
+
install(ctx);
|
|
231
304
|
ctx.ui.notify(`Minimal footer ${enabled ? "enabled" : "disabled"}`, "info");
|
|
232
305
|
},
|
|
233
306
|
});
|
|
@@ -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/media/github-preview.png
CHANGED
|
Binary file
|
package/media/preview.png
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-minimal-footer",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
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",
|
|
@@ -30,10 +30,20 @@
|
|
|
30
30
|
"type": "git",
|
|
31
31
|
"url": "git+https://github.com/Ryu-CZ/pi-minimal-footer.git"
|
|
32
32
|
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "node --test --test-concurrency=1 tests/*.test.mjs",
|
|
35
|
+
"check": "tsc --noEmit"
|
|
36
|
+
},
|
|
33
37
|
"peerDependencies": {
|
|
34
38
|
"@earendil-works/pi-coding-agent": "*",
|
|
35
39
|
"@earendil-works/pi-tui": "*"
|
|
36
40
|
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@earendil-works/pi-coding-agent": "1.0.3",
|
|
43
|
+
"@earendil-works/pi-tui": "1.0.3",
|
|
44
|
+
"@types/node": "22.20.5",
|
|
45
|
+
"typescript": "7.0.2"
|
|
46
|
+
},
|
|
37
47
|
"pi": {
|
|
38
48
|
"extensions": [
|
|
39
49
|
"./extensions"
|