pi-minimal-footer 0.3.0 → 0.3.2
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 +21 -1
- package/README.md +5 -11
- package/VISION.md +13 -13
- package/extensions/index.ts +39 -23
- package/extensions/lib/usage-limits.ts +45 -29
- package/extensions/lib/{quota-providers.ts → usage-providers.ts} +8 -8
- package/media/github-preview.png +0 -0
- package/media/preview.png +0 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.2] - 2026-10-06
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Rename provider quota terminology to subscription usage in documentation, code, and tests; behavior is unchanged
|
|
15
|
+
|
|
16
|
+
## [0.3.1] - 2026-10-06
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- Keep quota requests and timers stopped after another extension removes or replaces the footer, even when the model changes; explicit reinstallation restores polling
|
|
21
|
+
- Use all spare columns for responsive quota bars when the left group is absent
|
|
22
|
+
- Fall back to default settings when the JSON root is null, a scalar, or an array
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- Clarify renderer ownership and layout budgeting with rationale comments; isolate reset-label formatting and name shared bar-size limits
|
|
27
|
+
|
|
10
28
|
## [0.3.0] - 2026-10-06
|
|
11
29
|
|
|
12
30
|
### Added
|
|
@@ -129,7 +147,9 @@ First npm-ready release.
|
|
|
129
147
|
|
|
130
148
|
- Project scaffold — `extensions/index.ts` with basic footer structure, `package.json` with pi extension manifest, `README.md`, `LICENSE` (MIT)
|
|
131
149
|
|
|
132
|
-
[Unreleased]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.3.
|
|
150
|
+
[Unreleased]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.3.2...HEAD
|
|
151
|
+
[0.3.2]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.3.1...v0.3.2
|
|
152
|
+
[0.3.1]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.3.0...v0.3.1
|
|
133
153
|
[0.3.0]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.2.0...v0.3.0
|
|
134
154
|
[0.2.0]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.1.3...v0.2.0
|
|
135
155
|
[0.1.3]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.1.2...v0.1.3
|
package/README.md
CHANGED
|
@@ -9,16 +9,10 @@
|
|
|
9
9
|
|
|
10
10
|
An opinionated, compact one-line footer for [Pi](https://github.com/earendil-works/pi).
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
~/git/project main 🧠 Karpathy 🪽 Icarus model · 42/200k · ⣿⣿⣿⣤⠀⠀⠀⠀⠀⠀↻1d8h
|
|
14
|
-
```
|
|
15
|
-
|
|
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.
|
|
12
|
+
Path and branch on the left; extension statuses, model, context, and subscription usage bar on the right. Shrinks to fit narrow terminals. Interactive terminal UI only.
|
|
17
13
|
|
|
18
14
|

|
|
19
15
|
|
|
20
|
-
*Screenshot shows an earlier layout; the text preview reflects current source.*
|
|
21
|
-
|
|
22
16
|
## Install
|
|
23
17
|
|
|
24
18
|
Requires Node.js >=22.19.0.
|
|
@@ -59,15 +53,15 @@ Edit `minFooter` in Pi's agent `settings.json` (normally `~/.pi/agent/settings.j
|
|
|
59
53
|
- 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
54
|
- Context `42/200k` means tokens used / context window. `?` means Pi has not reported usage yet, including immediately after compaction.
|
|
61
55
|
|
|
62
|
-
## Reading
|
|
56
|
+
## Reading subscription usage
|
|
63
57
|
|
|
64
|
-
`⣿⣿⣿⣤⠀⠀⠀⠀⠀⠀↻1d8h` shows **
|
|
58
|
+
`⣿⣿⣿⣤⠀⠀⠀⠀⠀⠀↻1d8h` shows **subscription allowance used**, not context usage.
|
|
65
59
|
|
|
66
60
|
- 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
61
|
- 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
|
|
62
|
+
- Shows the shortest available usage window for the selected provider. Missing credentials, unsupported/local models, or absent limits hide the usage bar. Custom proxy endpoints are not polled.
|
|
69
63
|
|
|
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
|
|
64
|
+
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 usage. Provider availability varies; authenticated endpoints remain unverified live.
|
|
71
65
|
|
|
72
66
|
## Development & design
|
|
73
67
|
|
package/VISION.md
CHANGED
|
@@ -10,15 +10,15 @@ The [README](README.md) is the user manual. This document records current design
|
|
|
10
10
|
|
|
11
11
|
- Directory and git branch form the left group, separated by ``.
|
|
12
12
|
- Each extension status is its own `` tab. Preserve the supplied text; do not invent status labels, parentheses, or pipes.
|
|
13
|
-
- Model, context, and
|
|
13
|
+
- Model, context, and subscription usage form the rightmost group, separated by ` · `. Model identifies the running engine; context describes its usage; the usage bar stays anchored at the right edge.
|
|
14
14
|
- Powerline separators are dim. `powerlineSeparator: false` replaces them with a single space. Font availability cannot be detected reliably.
|
|
15
15
|
- Measure terminal columns with `visibleWidth`, not string length. Preserve ANSI styling and wide-character accounting when truncating.
|
|
16
16
|
|
|
17
17
|
### Space allocation
|
|
18
18
|
|
|
19
|
-
Reserve
|
|
19
|
+
Reserve the usage bar 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
20
|
|
|
21
|
-
Lay out other fields with a five-cell
|
|
21
|
+
Lay out other fields with a five-cell usage 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 the usage bar if the label cannot fit.
|
|
22
22
|
|
|
23
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
24
|
|
|
@@ -36,14 +36,14 @@ Five cells give 2.5% steps; ten give 1.25%. Fill rounds to the nearest eighth-ce
|
|
|
36
36
|
|
|
37
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
38
|
|
|
39
|
-
##
|
|
39
|
+
## Subscription usage contract
|
|
40
40
|
|
|
41
|
-
Display only the selected provider's shortest applicable window.
|
|
41
|
+
Display only the selected provider's shortest applicable window. Subscription usage and model context usage are separate measurements.
|
|
42
42
|
|
|
43
43
|
| Provider | Selection / caveat |
|
|
44
44
|
|---|---|
|
|
45
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
|
|
46
|
+
| Claude (`anthropic`, OAuth) | Five-hour window, or weekly fallback; ordinary API keys do not expose subscription usage |
|
|
47
47
|
| GitHub Copilot (`github-copilot`) | Most-used limited quota bucket; unlimited buckets hidden |
|
|
48
48
|
| Gemini CLI (`google-gemini-cli`) | Selected model, with Pro/Flash family fallback; needs a configured provider/model |
|
|
49
49
|
| MiniMax (`minimax`, `minimax-cn`) | Prefer general bucket, then active bucket, then first bucket; shortest interval/weekly window |
|
|
@@ -58,9 +58,9 @@ Reuse Pi's selected-provider credentials. Copilot uses the GitHub login token fr
|
|
|
58
58
|
- Otherwise fetch on startup/provider switch, then every four minutes without a fresh response update; also refresh at the reported reset time.
|
|
59
59
|
- Merge partial windows. A weekly-only signal must not replace a cached shorter window or postpone its refresh.
|
|
60
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
|
|
62
|
-
- Missing credentials and explicit empty/unlimited responses clear
|
|
63
|
-
- Invalid optional reset metadata must not discard otherwise valid usage. Zero usage is an empty bar, not an absent
|
|
61
|
+
- Provider switches clear usage immediately, cancel pending work, and invalidate late results. Gemini model switches also refresh model-specific usage.
|
|
62
|
+
- Missing credentials and explicit empty/unlimited responses clear usage. Temporary authentication, network, or parsing failures retain only the current provider's cached usage, 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 usage bar.
|
|
64
64
|
- Stop requests and timers on disable, disposal, and session shutdown.
|
|
65
65
|
|
|
66
66
|
### Verification limits
|
|
@@ -93,14 +93,14 @@ For a manual install, copy `extensions/*` into `~/.pi/agent/extensions/`.
|
|
|
93
93
|
### Code map
|
|
94
94
|
|
|
95
95
|
- `extensions/index.ts`: settings, state refresh, layout, footer lifecycle, and `/minfooter`.
|
|
96
|
-
- `extensions/lib/usage-limits.ts`:
|
|
97
|
-
- `extensions/lib/
|
|
98
|
-
- `tests/`: loader-based lifecycle/layout regression tests and
|
|
96
|
+
- `extensions/lib/usage-limits.ts`: usage refresh, cache, cancellation, and bar/reset rendering.
|
|
97
|
+
- `extensions/lib/usage-providers.ts`: authentication, endpoint selection, and response normalization.
|
|
98
|
+
- `tests/`: loader-based lifecycle/layout regression tests and usage parsing tests.
|
|
99
99
|
|
|
100
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
101
|
|
|
102
102
|
### Checking changes
|
|
103
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.
|
|
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. Subscription usage 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
105
|
|
|
106
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.
|
package/extensions/index.ts
CHANGED
|
@@ -17,7 +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
|
+
import { BASE_BAR_CELLS, MAX_BAR_CELLS, UsageLimits } from "./lib/usage-limits.js";
|
|
21
21
|
|
|
22
22
|
// ── Settings ──────────────────────────────────────────────────────────
|
|
23
23
|
|
|
@@ -52,7 +52,10 @@ function settingsPath(): string {
|
|
|
52
52
|
function readSettings(): Settings {
|
|
53
53
|
try {
|
|
54
54
|
if (!existsSync(settingsPath())) return {};
|
|
55
|
-
|
|
55
|
+
const settings: unknown = JSON.parse(readFileSync(settingsPath(), "utf-8"));
|
|
56
|
+
// Valid JSON can still be null or a scalar; a broken preference file must not prevent loading.
|
|
57
|
+
if (settings === null || typeof settings !== "object" || Array.isArray(settings)) return {};
|
|
58
|
+
return settings as Settings;
|
|
56
59
|
} catch {
|
|
57
60
|
return {};
|
|
58
61
|
}
|
|
@@ -65,7 +68,7 @@ function writeSettings(patch: Partial<Settings>): void {
|
|
|
65
68
|
const current = readSettings();
|
|
66
69
|
writeFileSync(path, JSON.stringify({ ...current, ...patch }, null, 2) + "\n");
|
|
67
70
|
} catch {
|
|
68
|
-
//
|
|
71
|
+
// A read-only settings file must not crash the extension.
|
|
69
72
|
}
|
|
70
73
|
}
|
|
71
74
|
|
|
@@ -126,23 +129,27 @@ function updateState(ctx: ExtensionContext, state: FooterState): void {
|
|
|
126
129
|
|
|
127
130
|
// ── Layout ────────────────────────────────────────────────────────────
|
|
128
131
|
//
|
|
129
|
-
// Keep
|
|
132
|
+
// Keep the usage bar and reset time together; shorten location/statuses before model/context.
|
|
130
133
|
// All segment measurements use visible widths, including ANSI and wide characters.
|
|
131
134
|
|
|
132
135
|
const MODEL_GAP = " · ";
|
|
133
|
-
const
|
|
136
|
+
const USAGE_GAP = " · ";
|
|
134
137
|
const LOCATION_GAP_WIDTH = 3;
|
|
135
138
|
const MIN_TEXT_WIDTH = 4;
|
|
136
139
|
const MIN_STATUS_WIDTH = 12;
|
|
137
140
|
|
|
138
|
-
function buildLine(
|
|
139
|
-
|
|
141
|
+
function buildLine(
|
|
142
|
+
width: number, path: string, statuses: string, branch: string, model: string, context: string,
|
|
143
|
+
usage: (available: number, maxCells?: number) => string | null,
|
|
144
|
+
statusSeparator: string, locationSeparator: string,
|
|
145
|
+
): string {
|
|
140
146
|
if (width <= 0) return "";
|
|
141
147
|
let core = [model, context].filter(Boolean).join(MODEL_GAP);
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
const
|
|
145
|
-
|
|
148
|
+
// Budget the compact bar first; its expanded size must not drive truncation decisions.
|
|
149
|
+
const usageBar = usage(width) ?? "";
|
|
150
|
+
const usageGapWidth = usageBar && core ? USAGE_GAP.length : 0;
|
|
151
|
+
const coreBudget = Math.max(0, width - visibleWidth(usageBar) - usageGapWidth);
|
|
152
|
+
if (usageBar && coreBudget < MIN_TEXT_WIDTH) {
|
|
146
153
|
core = "";
|
|
147
154
|
} else if (visibleWidth(core) > coreBudget) {
|
|
148
155
|
const modelGapWidth = model && context ? MODEL_GAP.length : 0;
|
|
@@ -153,7 +160,7 @@ function buildLine(width: number, path: string, statuses: string, branch: string
|
|
|
153
160
|
core = truncateToWidth(context || model, coreBudget, "...");
|
|
154
161
|
}
|
|
155
162
|
}
|
|
156
|
-
const protectedRight = [core,
|
|
163
|
+
const protectedRight = [core, usageBar].filter(Boolean).join(USAGE_GAP);
|
|
157
164
|
const branchReservation = branch ? visibleWidth(branch) + LOCATION_GAP_WIDTH : 0;
|
|
158
165
|
const statusBudget = width - visibleWidth(protectedRight) - branchReservation - visibleWidth(statusSeparator);
|
|
159
166
|
let fittedStatuses = "";
|
|
@@ -164,17 +171,19 @@ function buildLine(width: number, path: string, statuses: string, branch: string
|
|
|
164
171
|
}
|
|
165
172
|
}
|
|
166
173
|
let right = [fittedStatuses, protectedRight].filter(Boolean).join(statusSeparator);
|
|
167
|
-
const
|
|
168
|
-
const leftBudget = Math.max(0, width - visibleWidth(right) -
|
|
174
|
+
const reservedLocationGapWidth = right ? LOCATION_GAP_WIDTH : 0;
|
|
175
|
+
const leftBudget = Math.max(0, width - visibleWidth(right) - reservedLocationGapWidth);
|
|
169
176
|
const fittedBranch = visibleWidth(branch) <= leftBudget ? branch : "";
|
|
170
177
|
const pathGapWidth = fittedBranch && path ? visibleWidth(locationSeparator) : 0;
|
|
171
178
|
const pathBudget = leftBudget - visibleWidth(fittedBranch) - pathGapWidth;
|
|
172
179
|
const fittedPath = pathBudget >= MIN_TEXT_WIDTH ? truncateToWidth(path, pathBudget, "...") : "";
|
|
173
180
|
const left = [fittedPath, fittedBranch].filter(Boolean).join(locationSeparator);
|
|
174
|
-
if (
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
const
|
|
181
|
+
if (usageBar) {
|
|
182
|
+
// A dropped left group needs no divider; those columns belong to the usage bar instead.
|
|
183
|
+
const interGroupGapWidth = left && right ? LOCATION_GAP_WIDTH : 0;
|
|
184
|
+
const spareWidth = Math.max(0, width - visibleWidth(left) - visibleWidth(right) - interGroupGapWidth);
|
|
185
|
+
const expandedUsageBar = usage(visibleWidth(usageBar) + spareWidth, MAX_BAR_CELLS) ?? usageBar;
|
|
186
|
+
const expandedCore = [core, expandedUsageBar].filter(Boolean).join(USAGE_GAP);
|
|
178
187
|
right = [fittedStatuses, expandedCore].filter(Boolean).join(statusSeparator);
|
|
179
188
|
}
|
|
180
189
|
return left + " ".repeat(Math.max(0, width - visibleWidth(left) - visibleWidth(right))) + right;
|
|
@@ -190,6 +199,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
190
199
|
let disposeFooter: (() => void) | null = null;
|
|
191
200
|
const usageLimits = new UsageLimits(() => requestRender?.());
|
|
192
201
|
|
|
202
|
+
function selectUsage(ctx: ExtensionContext): void {
|
|
203
|
+
// The preference may remain enabled after another extension replaces our footer.
|
|
204
|
+
// Poll only while we own a live renderer, or model changes can resurrect hidden requests.
|
|
205
|
+
usageLimits.select(ctx, enabled && disposeFooter !== null);
|
|
206
|
+
}
|
|
207
|
+
|
|
193
208
|
function install(ctx: ExtensionContext): void {
|
|
194
209
|
config = readConfig();
|
|
195
210
|
enabled = config.enabled !== false;
|
|
@@ -210,6 +225,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
210
225
|
if (disposed) return;
|
|
211
226
|
disposed = true;
|
|
212
227
|
unsub();
|
|
228
|
+
// A superseded renderer must not stop its replacement's usage polling.
|
|
213
229
|
if (requestRender === request) {
|
|
214
230
|
requestRender = null;
|
|
215
231
|
usageLimits.stop();
|
|
@@ -220,7 +236,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
220
236
|
|
|
221
237
|
return {
|
|
222
238
|
render(width: number): string[] {
|
|
223
|
-
const
|
|
239
|
+
const statuses = config.showSkills
|
|
224
240
|
? [...footerData.getExtensionStatuses().values()].filter((s) => s.trim())
|
|
225
241
|
: [];
|
|
226
242
|
const branch = config.showGitBranch ? footerData.getGitBranch() : null;
|
|
@@ -228,11 +244,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
228
244
|
const line = buildLine(
|
|
229
245
|
width,
|
|
230
246
|
config.showPath ? theme.fg("dim", abbreviateHome(state.cwd, homedir())) : "",
|
|
231
|
-
|
|
247
|
+
statuses.length ? statusSeparator + statuses.map((status) => theme.fg("dim", status)).join(statusSeparator) : "",
|
|
232
248
|
branch ? theme.fg("dim", ` ${branch}`) : "",
|
|
233
249
|
config.showModel ? theme.bold(state.model) : "",
|
|
234
250
|
config.showContext ? theme.fg("dim", theme.bold(state.context)) : "",
|
|
235
|
-
(available, maxCells =
|
|
251
|
+
(available, maxCells = BASE_BAR_CELLS) => usageLimits.line(available, theme, maxCells),
|
|
236
252
|
statusSeparator,
|
|
237
253
|
config.powerlineSeparator ? theme.fg("dim", " ") : " ",
|
|
238
254
|
);
|
|
@@ -242,7 +258,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
242
258
|
dispose,
|
|
243
259
|
};
|
|
244
260
|
});
|
|
245
|
-
|
|
261
|
+
selectUsage(ctx);
|
|
246
262
|
}
|
|
247
263
|
|
|
248
264
|
/** Cheap refresh: update plain state and request one render. */
|
|
@@ -260,7 +276,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
260
276
|
});
|
|
261
277
|
|
|
262
278
|
pi.on("model_select", async (_event, ctx) => {
|
|
263
|
-
|
|
279
|
+
selectUsage(ctx);
|
|
264
280
|
refresh(ctx);
|
|
265
281
|
});
|
|
266
282
|
|
|
@@ -2,10 +2,33 @@
|
|
|
2
2
|
// Copyright (c) 2025 Can Celik. MIT license: ./LICENSE.
|
|
3
3
|
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
|
|
5
|
-
import { parseUsageWindows, record, shortest, supportedOrigin, usageRequest, windowFrom } from "./
|
|
6
|
-
import type {
|
|
5
|
+
import { parseUsageWindows, record, shortest, supportedOrigin, usageRequest, windowFrom } from "./usage-providers.js";
|
|
6
|
+
import type { UsageWindow } from "./usage-providers.js";
|
|
7
7
|
|
|
8
8
|
const REFRESH_MS = 4 * 60_000;
|
|
9
|
+
const REQUEST_TIMEOUT_MS = 5000;
|
|
10
|
+
const HOUR_MS = 60 * 60_000;
|
|
11
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
12
|
+
const CELL_STEPS = 8;
|
|
13
|
+
const PARTIAL_CELL_FILLS = ["", "⡀", "⣀", "⣄", "⣤", "⣦", "⣶", "⣷"];
|
|
14
|
+
|
|
15
|
+
// Start compact so extra precision never steals space from the other footer fields.
|
|
16
|
+
export const BASE_BAR_CELLS = 5;
|
|
17
|
+
export const MAX_BAR_CELLS = 10;
|
|
18
|
+
|
|
19
|
+
function formatResetLabel(resetAt: number | null, now: number): string {
|
|
20
|
+
if (resetAt === null) return "";
|
|
21
|
+
const remaining = resetAt - now;
|
|
22
|
+
// Clock time loses the day for distant resets; minutes add noise at that scale.
|
|
23
|
+
if (remaining > 10 * DAY_MS) return `↻${Math.floor(remaining / DAY_MS)}d`;
|
|
24
|
+
if (remaining > DAY_MS) {
|
|
25
|
+
const days = Math.floor(remaining / DAY_MS);
|
|
26
|
+
const hours = Math.floor(remaining % DAY_MS / HOUR_MS);
|
|
27
|
+
return `↻${days}d${hours}h`;
|
|
28
|
+
}
|
|
29
|
+
const date = new Date(resetAt);
|
|
30
|
+
return `↻${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
|
|
31
|
+
}
|
|
9
32
|
|
|
10
33
|
function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
11
34
|
return new Promise((resolve, reject) => {
|
|
@@ -15,7 +38,7 @@ function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
|
15
38
|
};
|
|
16
39
|
signal.addEventListener("abort", abort, { once: true });
|
|
17
40
|
if (signal.aborted) abort();
|
|
18
|
-
//
|
|
41
|
+
// Credential and body promises may ignore abort; observe late failures to avoid unhandled rejections.
|
|
19
42
|
promise.then((value) => {
|
|
20
43
|
signal.removeEventListener("abort", abort);
|
|
21
44
|
resolve(value);
|
|
@@ -32,15 +55,15 @@ function passiveNumber(value: unknown): number {
|
|
|
32
55
|
return NaN;
|
|
33
56
|
}
|
|
34
57
|
|
|
35
|
-
function passiveWindow(used: unknown, seconds: number | undefined, reset: unknown, cached:
|
|
58
|
+
function passiveWindow(used: unknown, seconds: number | undefined, reset: unknown, cached: UsageWindow | undefined): UsageWindow | null {
|
|
36
59
|
const window = windowFrom(used, seconds === undefined ? cached?.seconds : seconds, reset);
|
|
37
60
|
// Omitted metadata belongs to the same window only while its duration agrees.
|
|
38
61
|
if (window && reset === undefined && window.seconds === cached?.seconds) window.resetAt = cached.resetAt;
|
|
39
62
|
return window;
|
|
40
63
|
}
|
|
41
64
|
|
|
42
|
-
function fromHeaders(provider: string, headers: Record<string, string>, cached: Record<string,
|
|
43
|
-
const windows: Record<string,
|
|
65
|
+
function fromHeaders(provider: string, headers: Record<string, string>, cached: Record<string, UsageWindow>): Record<string, UsageWindow> {
|
|
66
|
+
const windows: Record<string, UsageWindow> = {};
|
|
44
67
|
let definitions: readonly (readonly [string, string, number?])[];
|
|
45
68
|
if (provider === "openai-codex") {
|
|
46
69
|
definitions = [["primary", "primary"], ["secondary", "secondary"]];
|
|
@@ -65,14 +88,14 @@ function fromHeaders(provider: string, headers: Record<string, string>, cached:
|
|
|
65
88
|
return windows;
|
|
66
89
|
}
|
|
67
90
|
|
|
68
|
-
function fromStream(data: unknown, cached: Record<string,
|
|
91
|
+
function fromStream(data: unknown, cached: Record<string, UsageWindow>): Record<string, UsageWindow> {
|
|
69
92
|
const event = record(data);
|
|
70
93
|
if (event.type !== "codex.rate_limits") return {};
|
|
71
94
|
// Other metered pools may be specific to a different model.
|
|
72
95
|
const pool = event.metered_limit_name ?? event.limit_name;
|
|
73
96
|
if (pool !== undefined && pool !== "codex") return {};
|
|
74
97
|
const limits = record(event.rate_limits);
|
|
75
|
-
const windows: Record<string,
|
|
98
|
+
const windows: Record<string, UsageWindow> = {};
|
|
76
99
|
for (const key of ["primary", "secondary"]) {
|
|
77
100
|
const w = record(limits[key]);
|
|
78
101
|
const window = passiveWindow(passiveNumber(w.used_percent),
|
|
@@ -83,12 +106,12 @@ function fromStream(data: unknown, cached: Record<string, QuotaWindow>): Record<
|
|
|
83
106
|
return windows;
|
|
84
107
|
}
|
|
85
108
|
|
|
86
|
-
/** Owns
|
|
109
|
+
/** Owns usage requests and timers for the currently displayed footer. */
|
|
87
110
|
export class UsageLimits {
|
|
88
111
|
private provider: string | null = null;
|
|
89
112
|
private selection: string | null = null;
|
|
90
113
|
private ctx: ExtensionContext | null = null;
|
|
91
|
-
private windows: Record<string,
|
|
114
|
+
private windows: Record<string, UsageWindow> = {};
|
|
92
115
|
private updatedAt: Record<string, number> = {};
|
|
93
116
|
private stale = false;
|
|
94
117
|
private attemptedAt = 0;
|
|
@@ -141,12 +164,12 @@ export class UsageLimits {
|
|
|
141
164
|
this.request = null;
|
|
142
165
|
}
|
|
143
166
|
|
|
144
|
-
private selectedWindow(): [string,
|
|
167
|
+
private selectedWindow(): [string, UsageWindow] | null {
|
|
145
168
|
const window = shortest(Object.values(this.windows));
|
|
146
169
|
return window ? Object.entries(this.windows).find(([, value]) => value === window)! : null;
|
|
147
170
|
}
|
|
148
171
|
|
|
149
|
-
private accept(windows: Record<string,
|
|
172
|
+
private accept(windows: Record<string, UsageWindow>): void {
|
|
150
173
|
if (!this.ctx || !Object.keys(windows).length) return;
|
|
151
174
|
Object.assign(this.windows, windows);
|
|
152
175
|
for (const key of Object.keys(windows)) this.updatedAt[key] = Date.now();
|
|
@@ -179,9 +202,10 @@ export class UsageLimits {
|
|
|
179
202
|
const provider = this.provider!;
|
|
180
203
|
const modelId = model.id;
|
|
181
204
|
const controller = new AbortController();
|
|
205
|
+
// Identity checks below keep late responses from restoring a replaced provider's usage.
|
|
182
206
|
this.request = controller;
|
|
183
207
|
this.attemptedAt = Date.now();
|
|
184
|
-
const timeout = setTimeout(() => controller.abort(),
|
|
208
|
+
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
185
209
|
timeout.unref();
|
|
186
210
|
try {
|
|
187
211
|
const request = await abortable(usageRequest(ctx, model), controller.signal);
|
|
@@ -208,27 +232,19 @@ export class UsageLimits {
|
|
|
208
232
|
}
|
|
209
233
|
}
|
|
210
234
|
|
|
211
|
-
line(width: number, theme: Theme, maxCells =
|
|
235
|
+
line(width: number, theme: Theme, maxCells = MAX_BAR_CELLS): string | null {
|
|
212
236
|
const window = this.selectedWindow()?.[1];
|
|
213
237
|
if (!this.ctx || !window) return null;
|
|
214
238
|
const now = Date.now();
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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));
|
|
239
|
+
const resetLabel = formatResetLabel(window.resetAt, now);
|
|
240
|
+
if (width < (resetLabel.length || 1)) return null;
|
|
241
|
+
const cells = Math.min(maxCells, Math.max(0, width - resetLabel.length));
|
|
242
|
+
const steps = Math.round(window.used / 100 * cells * CELL_STEPS);
|
|
243
|
+
const filled = "⣿".repeat(Math.floor(steps / CELL_STEPS)) + PARTIAL_CELL_FILLS[steps % CELL_STEPS];
|
|
244
|
+
const empty = "⠀".repeat(cells - Math.ceil(steps / CELL_STEPS));
|
|
229
245
|
const stale = this.stale || (window.resetAt !== null && now >= window.resetAt);
|
|
230
246
|
const color = stale ? "dim" : window.used >= 92 ? "error" : window.used >= 85 ? "warning" : "success";
|
|
231
247
|
const bar = cells ? theme.fg(color, filled) + theme.fg("dim", empty) : "";
|
|
232
|
-
return bar + theme.fg("dim",
|
|
248
|
+
return bar + theme.fg("dim", resetLabel);
|
|
233
249
|
}
|
|
234
250
|
}
|
|
@@ -6,7 +6,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
|
6
6
|
import { readFileSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
|
|
9
|
-
export interface
|
|
9
|
+
export interface UsageWindow {
|
|
10
10
|
used: number;
|
|
11
11
|
seconds: number | null;
|
|
12
12
|
resetAt: number | null;
|
|
@@ -31,15 +31,15 @@ function resetSeconds(value: unknown): number | undefined {
|
|
|
31
31
|
return seconds !== undefined && seconds > 0 && Number.isFinite(new Date(seconds * 1000).getTime()) ? seconds : undefined;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
export function windowFrom(used: unknown, seconds: unknown, reset: unknown):
|
|
34
|
+
export function windowFrom(used: unknown, seconds: unknown, reset: unknown): UsageWindow | null {
|
|
35
35
|
if (typeof used !== "number" || !Number.isFinite(used) || used < 0 || used > 100) return null;
|
|
36
36
|
if (seconds != null && (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0)) return null;
|
|
37
37
|
const validReset = typeof reset === "number" && reset > 0 && Number.isFinite(new Date(reset * 1000).getTime());
|
|
38
38
|
return { used, seconds: typeof seconds === "number" ? seconds : null, resetAt: validReset ? reset * 1000 : null };
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
export function shortest(windows: (
|
|
42
|
-
return windows.filter((w): w is
|
|
41
|
+
export function shortest(windows: (UsageWindow | null)[]): UsageWindow | null {
|
|
42
|
+
return windows.filter((w): w is UsageWindow => w !== null)
|
|
43
43
|
.sort((a, b) => (a.seconds ?? Infinity) - (b.seconds ?? Infinity) || b.used - a.used)[0] ?? null;
|
|
44
44
|
}
|
|
45
45
|
|
|
@@ -122,13 +122,13 @@ function relativeReset(value: unknown): number | undefined {
|
|
|
122
122
|
return delay !== undefined && delay >= 0 ? resetSeconds(Date.now() / 1000 + delay) : undefined;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
export function parseUsageWindows(provider: string, model: string, payload: unknown): Record<string,
|
|
125
|
+
export function parseUsageWindows(provider: string, model: string, payload: unknown): Record<string, UsageWindow> | null {
|
|
126
126
|
if (!ENDPOINTS[provider]) return null;
|
|
127
|
-
const windows: Record<string,
|
|
127
|
+
const windows: Record<string, UsageWindow> = {};
|
|
128
128
|
let recognized = false;
|
|
129
129
|
let malformed = !isObject(payload);
|
|
130
130
|
const data = record(payload);
|
|
131
|
-
const add = (key: string, value: unknown, parse: (w: Record<string, unknown>) =>
|
|
131
|
+
const add = (key: string, value: unknown, parse: (w: Record<string, unknown>) => UsageWindow | null) => {
|
|
132
132
|
if (value === undefined) return;
|
|
133
133
|
recognized = true;
|
|
134
134
|
if (value === null) return;
|
|
@@ -259,6 +259,6 @@ export function parseUsageWindows(provider: string, model: string, payload: unkn
|
|
|
259
259
|
return finish();
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
-
export function parseUsage(provider: string, model: string, payload: unknown):
|
|
262
|
+
export function parseUsage(provider: string, model: string, payload: unknown): UsageWindow | null {
|
|
263
263
|
return shortest(Object.values(parseUsageWindows(provider, model, payload) ?? {}));
|
|
264
264
|
}
|
package/media/github-preview.png
CHANGED
|
Binary file
|
package/media/preview.png
CHANGED
|
Binary file
|
package/package.json
CHANGED