pi-minimal-footer 0.3.1 → 0.3.3

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,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.3] - 2026-10-07
11
+
12
+ ### Changed
13
+
14
+ - Add square brackets around the subscription usage bar so its beginning and end remain visible when empty
15
+ - Randomize subscription usage fallback refreshes by ±⅛ of the four-minute interval (3.5–4.5 minutes), preserving reset-time checks and deadlines across unrelated window updates
16
+ - Raise the subscription usage request timeout from five to nine seconds before aborting stalled authentication, fetch, or response-body waits
17
+
18
+ ## [0.3.2] - 2026-10-06
19
+
20
+ ### Changed
21
+
22
+ - Rename provider quota terminology to subscription usage in documentation, code, and tests; behavior is unchanged
23
+
10
24
  ## [0.3.1] - 2026-10-06
11
25
 
12
26
  ### Fixed
@@ -141,7 +155,9 @@ First npm-ready release.
141
155
 
142
156
  - Project scaffold — `extensions/index.ts` with basic footer structure, `package.json` with pi extension manifest, `README.md`, `LICENSE` (MIT)
143
157
 
144
- [Unreleased]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.3.1...HEAD
158
+ [Unreleased]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.3.3...HEAD
159
+ [0.3.3]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.3.2...v0.3.3
160
+ [0.3.2]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.3.1...v0.3.2
145
161
  [0.3.1]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.3.0...v0.3.1
146
162
  [0.3.0]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.2.0...v0.3.0
147
163
  [0.2.0]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.1.3...v0.2.0
package/README.md CHANGED
@@ -9,11 +9,7 @@
9
9
 
10
10
  An opinionated, compact one-line footer for [Pi](https://github.com/earendil-works/pi).
11
11
 
12
- ```text
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
  ![Footer preview: ~/git/project   main  🧠 Karpathy  🪽 Icarus  model · 42/200k · ⣿⣿⣿⣤⠀⠀⠀⠀⠀⠀↻1d8h](media/github-preview.png)
19
15
 
@@ -57,15 +53,15 @@ Edit `minFooter` in Pi's agent `settings.json` (normally `~/.pi/agent/settings.j
57
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.
58
54
  - Context `42/200k` means tokens used / context window. `?` means Pi has not reported usage yet, including immediately after compaction.
59
55
 
60
- ## Reading quota
56
+ ## Reading subscription usage
61
57
 
62
- `⣿⣿⣿⣤⠀⠀⠀⠀⠀⠀↻1d8h` shows **account quota used**, not context usage.
58
+ `⣿⣿⣿⣤⠀⠀⠀⠀⠀⠀↻1d8h` shows **subscription allowance used**, not context usage.
63
59
 
64
- - 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.
60
+ - Bar: shown as `[cells]` with 5–10 cells, expanding into spare space; green below 85%, amber from 85%, red from 92%. The brackets keep the bar’s beginning and end visible even when it is empty. Dim means cached after a refresh failure. Very narrow terminals may show fewer cells or only the reset label.
65
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.
66
- - 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.
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.
67
63
 
68
- 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.
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.
69
65
 
70
66
  ## Development & design
71
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 quota form the rightmost group, separated by ` · `. Model identifies the running engine; context describes its usage; quota stays anchored at the right edge.
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 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.
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 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.
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
- ## Quota contract
39
+ ## Subscription usage contract
40
40
 
41
- Display only the selected provider's shortest applicable window. Account quota and model context usage are separate measurements.
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 quota |
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 |
@@ -55,12 +55,12 @@ Reuse Pi's selected-provider credentials. Copilot uses the GitHub login token fr
55
55
  ### Refresh and failure behavior
56
56
 
57
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.
58
+ - Otherwise fetch on startup/provider switch, then roughly every four minutes without a fresh response update. Sample independent uniform ±⅛ jitter (3.5–4.5 minutes) for each attempt or fresh displayed-window update to spread fallback requests across sessions. Keep that deadline across unrelated window updates; a reported reset time still triggers an earlier refresh.
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 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.
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`: 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.
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. 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.
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.
@@ -129,11 +129,11 @@ function updateState(ctx: ExtensionContext, state: FooterState): void {
129
129
 
130
130
  // ── Layout ────────────────────────────────────────────────────────────
131
131
  //
132
- // Keep quota and reset time together; shorten location/statuses before model/context.
132
+ // Keep the usage bar and reset time together; shorten location/statuses before model/context.
133
133
  // All segment measurements use visible widths, including ANSI and wide characters.
134
134
 
135
135
  const MODEL_GAP = " · ";
136
- const QUOTA_GAP = " · ";
136
+ const USAGE_GAP = " · ";
137
137
  const LOCATION_GAP_WIDTH = 3;
138
138
  const MIN_TEXT_WIDTH = 4;
139
139
  const MIN_STATUS_WIDTH = 12;
@@ -146,10 +146,10 @@ function buildLine(
146
146
  if (width <= 0) return "";
147
147
  let core = [model, context].filter(Boolean).join(MODEL_GAP);
148
148
  // Budget the compact bar first; its expanded size must not drive truncation decisions.
149
- const quota = usage(width) ?? "";
150
- const quotaGapWidth = quota && core ? QUOTA_GAP.length : 0;
151
- const coreBudget = Math.max(0, width - visibleWidth(quota) - quotaGapWidth);
152
- if (quota && coreBudget < MIN_TEXT_WIDTH) {
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) {
153
153
  core = "";
154
154
  } else if (visibleWidth(core) > coreBudget) {
155
155
  const modelGapWidth = model && context ? MODEL_GAP.length : 0;
@@ -160,7 +160,7 @@ function buildLine(
160
160
  core = truncateToWidth(context || model, coreBudget, "...");
161
161
  }
162
162
  }
163
- const protectedRight = [core, quota].filter(Boolean).join(QUOTA_GAP);
163
+ const protectedRight = [core, usageBar].filter(Boolean).join(USAGE_GAP);
164
164
  const branchReservation = branch ? visibleWidth(branch) + LOCATION_GAP_WIDTH : 0;
165
165
  const statusBudget = width - visibleWidth(protectedRight) - branchReservation - visibleWidth(statusSeparator);
166
166
  let fittedStatuses = "";
@@ -178,12 +178,12 @@ function buildLine(
178
178
  const pathBudget = leftBudget - visibleWidth(fittedBranch) - pathGapWidth;
179
179
  const fittedPath = pathBudget >= MIN_TEXT_WIDTH ? truncateToWidth(path, pathBudget, "...") : "";
180
180
  const left = [fittedPath, fittedBranch].filter(Boolean).join(locationSeparator);
181
- if (quota) {
182
- // A dropped left group needs no divider; those columns belong to the quota bar instead.
181
+ if (usageBar) {
182
+ // A dropped left group needs no divider; those columns belong to the usage bar instead.
183
183
  const interGroupGapWidth = left && right ? LOCATION_GAP_WIDTH : 0;
184
184
  const spareWidth = Math.max(0, width - visibleWidth(left) - visibleWidth(right) - interGroupGapWidth);
185
- const expandedQuota = usage(visibleWidth(quota) + spareWidth, MAX_BAR_CELLS) ?? quota;
186
- const expandedCore = [core, expandedQuota].filter(Boolean).join(QUOTA_GAP);
185
+ const expandedUsageBar = usage(visibleWidth(usageBar) + spareWidth, MAX_BAR_CELLS) ?? usageBar;
186
+ const expandedCore = [core, expandedUsageBar].filter(Boolean).join(USAGE_GAP);
187
187
  right = [fittedStatuses, expandedCore].filter(Boolean).join(statusSeparator);
188
188
  }
189
189
  return left + " ".repeat(Math.max(0, width - visibleWidth(left) - visibleWidth(right))) + right;
@@ -225,7 +225,7 @@ export default function (pi: ExtensionAPI) {
225
225
  if (disposed) return;
226
226
  disposed = true;
227
227
  unsub();
228
- // A superseded renderer must not stop its replacement's quota polling.
228
+ // A superseded renderer must not stop its replacement's usage polling.
229
229
  if (requestRender === request) {
230
230
  requestRender = null;
231
231
  usageLimits.stop();
@@ -2,11 +2,12 @@
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 "./quota-providers.js";
6
- import type { QuotaWindow } from "./quota-providers.js";
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;
9
+ const REFRESH_JITTER_MS = REFRESH_MS / 8;
10
+ const REQUEST_TIMEOUT_MS = 9000;
10
11
  const HOUR_MS = 60 * 60_000;
11
12
  const DAY_MS = 24 * HOUR_MS;
12
13
  const CELL_STEPS = 8;
@@ -55,15 +56,15 @@ function passiveNumber(value: unknown): number {
55
56
  return NaN;
56
57
  }
57
58
 
58
- function passiveWindow(used: unknown, seconds: number | undefined, reset: unknown, cached: QuotaWindow | undefined): QuotaWindow | null {
59
+ function passiveWindow(used: unknown, seconds: number | undefined, reset: unknown, cached: UsageWindow | undefined): UsageWindow | null {
59
60
  const window = windowFrom(used, seconds === undefined ? cached?.seconds : seconds, reset);
60
61
  // Omitted metadata belongs to the same window only while its duration agrees.
61
62
  if (window && reset === undefined && window.seconds === cached?.seconds) window.resetAt = cached.resetAt;
62
63
  return window;
63
64
  }
64
65
 
65
- function fromHeaders(provider: string, headers: Record<string, string>, cached: Record<string, QuotaWindow>): Record<string, QuotaWindow> {
66
- const windows: Record<string, QuotaWindow> = {};
66
+ function fromHeaders(provider: string, headers: Record<string, string>, cached: Record<string, UsageWindow>): Record<string, UsageWindow> {
67
+ const windows: Record<string, UsageWindow> = {};
67
68
  let definitions: readonly (readonly [string, string, number?])[];
68
69
  if (provider === "openai-codex") {
69
70
  definitions = [["primary", "primary"], ["secondary", "secondary"]];
@@ -88,14 +89,14 @@ function fromHeaders(provider: string, headers: Record<string, string>, cached:
88
89
  return windows;
89
90
  }
90
91
 
91
- function fromStream(data: unknown, cached: Record<string, QuotaWindow>): Record<string, QuotaWindow> {
92
+ function fromStream(data: unknown, cached: Record<string, UsageWindow>): Record<string, UsageWindow> {
92
93
  const event = record(data);
93
94
  if (event.type !== "codex.rate_limits") return {};
94
95
  // Other metered pools may be specific to a different model.
95
96
  const pool = event.metered_limit_name ?? event.limit_name;
96
97
  if (pool !== undefined && pool !== "codex") return {};
97
98
  const limits = record(event.rate_limits);
98
- const windows: Record<string, QuotaWindow> = {};
99
+ const windows: Record<string, UsageWindow> = {};
99
100
  for (const key of ["primary", "secondary"]) {
100
101
  const w = record(limits[key]);
101
102
  const window = passiveWindow(passiveNumber(w.used_percent),
@@ -106,15 +107,16 @@ function fromStream(data: unknown, cached: Record<string, QuotaWindow>): Record<
106
107
  return windows;
107
108
  }
108
109
 
109
- /** Owns quota requests and timers for the currently displayed footer. */
110
+ /** Owns usage requests and timers for the currently displayed footer. */
110
111
  export class UsageLimits {
111
112
  private provider: string | null = null;
112
113
  private selection: string | null = null;
113
114
  private ctx: ExtensionContext | null = null;
114
- private windows: Record<string, QuotaWindow> = {};
115
+ private windows: Record<string, UsageWindow> = {};
115
116
  private updatedAt: Record<string, number> = {};
116
117
  private stale = false;
117
118
  private attemptedAt = 0;
119
+ private refreshDelay = REFRESH_MS;
118
120
  private timer: ReturnType<typeof setTimeout> | null = null;
119
121
  private request: AbortController | null = null;
120
122
 
@@ -164,12 +166,12 @@ export class UsageLimits {
164
166
  this.request = null;
165
167
  }
166
168
 
167
- private selectedWindow(): [string, QuotaWindow] | null {
169
+ private selectedWindow(): [string, UsageWindow] | null {
168
170
  const window = shortest(Object.values(this.windows));
169
171
  return window ? Object.entries(this.windows).find(([, value]) => value === window)! : null;
170
172
  }
171
173
 
172
- private accept(windows: Record<string, QuotaWindow>): void {
174
+ private accept(windows: Record<string, UsageWindow>): void {
173
175
  if (!this.ctx || !Object.keys(windows).length) return;
174
176
  Object.assign(this.windows, windows);
175
177
  for (const key of Object.keys(windows)) this.updatedAt[key] = Date.now();
@@ -178,17 +180,23 @@ export class UsageLimits {
178
180
  // Only a response for the displayed window supersedes its background refresh.
179
181
  this.cancelRequest();
180
182
  this.stale = false;
183
+ this.randomizeRefresh();
181
184
  }
182
185
  this.schedule();
183
186
  this.render();
184
187
  }
185
188
 
189
+ private randomizeRefresh(): void {
190
+ this.refreshDelay = REFRESH_MS + (Math.random() * 2 - 1) * REFRESH_JITTER_MS;
191
+ }
192
+
186
193
  private schedule(): void {
187
194
  if (this.timer) clearTimeout(this.timer);
188
195
  this.timer = null;
189
196
  if (!this.ctx || this.request) return;
190
197
  const selected = this.selectedWindow();
191
- let next = Math.max(this.attemptedAt, selected ? this.updatedAt[selected[0]] : 0) + REFRESH_MS;
198
+ // Keep the sampled deadline when unrelated windows reschedule the timer.
199
+ let next = Math.max(this.attemptedAt, selected ? this.updatedAt[selected[0]] : 0) + this.refreshDelay;
192
200
  const resetAt = selected?.[1].resetAt;
193
201
  if (resetAt != null && resetAt > this.attemptedAt) next = Math.min(next, resetAt);
194
202
  this.timer = setTimeout(() => { this.timer = null; void this.poll(); }, Math.max(1, next - Date.now()));
@@ -202,9 +210,10 @@ export class UsageLimits {
202
210
  const provider = this.provider!;
203
211
  const modelId = model.id;
204
212
  const controller = new AbortController();
205
- // Identity checks below keep late responses from restoring a replaced provider's quota.
213
+ // Identity checks below keep late responses from restoring a replaced provider's usage.
206
214
  this.request = controller;
207
215
  this.attemptedAt = Date.now();
216
+ this.randomizeRefresh();
208
217
  const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
209
218
  timeout.unref();
210
219
  try {
@@ -237,14 +246,15 @@ export class UsageLimits {
237
246
  if (!this.ctx || !window) return null;
238
247
  const now = Date.now();
239
248
  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));
249
+ // Reserve two columns for brackets so an empty bar still has clear edges.
250
+ if (width < (resetLabel.length || 1) + 3) return null;
251
+ const cells = Math.min(maxCells, Math.max(1, width - resetLabel.length - 2));
242
252
  const steps = Math.round(window.used / 100 * cells * CELL_STEPS);
243
253
  const filled = "⣿".repeat(Math.floor(steps / CELL_STEPS)) + PARTIAL_CELL_FILLS[steps % CELL_STEPS];
244
254
  const empty = "⠀".repeat(cells - Math.ceil(steps / CELL_STEPS));
245
255
  const stale = this.stale || (window.resetAt !== null && now >= window.resetAt);
246
256
  const color = stale ? "dim" : window.used >= 92 ? "error" : window.used >= 85 ? "warning" : "success";
247
257
  const bar = cells ? theme.fg(color, filled) + theme.fg("dim", empty) : "";
248
- return bar + theme.fg("dim", resetLabel);
258
+ return theme.fg("dim", "[") + bar + theme.fg("dim", "]") + theme.fg("dim", resetLabel);
249
259
  }
250
260
  }
@@ -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 QuotaWindow {
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): QuotaWindow | null {
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: (QuotaWindow | null)[]): QuotaWindow | null {
42
- return windows.filter((w): w is QuotaWindow => w !== null)
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, QuotaWindow> | null {
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, QuotaWindow> = {};
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>) => QuotaWindow | null) => {
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): QuotaWindow | null {
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-minimal-footer",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
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",