pi-minimal-footer 0.3.0 → 0.3.1

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,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.1] - 2026-10-06
11
+
12
+ ### Fixed
13
+
14
+ - Keep quota requests and timers stopped after another extension removes or replaces the footer, even when the model changes; explicit reinstallation restores polling
15
+ - Use all spare columns for responsive quota bars when the left group is absent
16
+ - Fall back to default settings when the JSON root is null, a scalar, or an array
17
+
18
+ ### Changed
19
+
20
+ - Clarify renderer ownership and layout budgeting with rationale comments; isolate reset-label formatting and name shared bar-size limits
21
+
10
22
  ## [0.3.0] - 2026-10-06
11
23
 
12
24
  ### Added
@@ -129,7 +141,8 @@ First npm-ready release.
129
141
 
130
142
  - Project scaffold — `extensions/index.ts` with basic footer structure, `package.json` with pi extension manifest, `README.md`, `LICENSE` (MIT)
131
143
 
132
- [Unreleased]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.3.0...HEAD
144
+ [Unreleased]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.3.1...HEAD
145
+ [0.3.1]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.3.0...v0.3.1
133
146
  [0.3.0]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.2.0...v0.3.0
134
147
  [0.2.0]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.1.3...v0.2.0
135
148
  [0.1.3]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.1.2...v0.1.3
package/README.md CHANGED
@@ -17,8 +17,6 @@ Path and branch on the left; extension statuses, model, context, and provider qu
17
17
 
18
18
  ![Footer preview: ~/git/project   main  🧠 Karpathy  🪽 Icarus  model · 42/200k · ⣿⣿⣿⣤⠀⠀⠀⠀⠀⠀↻1d8h](media/github-preview.png)
19
19
 
20
- *Screenshot shows an earlier layout; the text preview reflects current source.*
21
-
22
20
  ## Install
23
21
 
24
22
  Requires Node.js >=22.19.0.
@@ -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
- return JSON.parse(readFileSync(settingsPath(), "utf-8")) as Settings;
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
- // best-effort
71
+ // A read-only settings file must not crash the extension.
69
72
  }
70
73
  }
71
74
 
@@ -135,10 +138,14 @@ const LOCATION_GAP_WIDTH = 3;
135
138
  const MIN_TEXT_WIDTH = 4;
136
139
  const MIN_STATUS_WIDTH = 12;
137
140
 
138
- function buildLine(width: number, path: string, statuses: string, branch: string, model: string, context: string,
139
- usage: (available: number, maxCells?: number) => string | null, statusSeparator: string, locationSeparator: string): string {
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);
148
+ // Budget the compact bar first; its expanded size must not drive truncation decisions.
142
149
  const quota = usage(width) ?? "";
143
150
  const quotaGapWidth = quota && core ? QUOTA_GAP.length : 0;
144
151
  const coreBudget = Math.max(0, width - visibleWidth(quota) - quotaGapWidth);
@@ -164,16 +171,18 @@ 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 locationGapWidth = right ? LOCATION_GAP_WIDTH : 0;
168
- const leftBudget = Math.max(0, width - visibleWidth(right) - locationGapWidth);
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
181
  if (quota) {
175
- const spareWidth = Math.max(0, width - visibleWidth(left) - visibleWidth(right) - locationGapWidth);
176
- const expandedQuota = usage(visibleWidth(quota) + spareWidth, 10) ?? quota;
182
+ // A dropped left group needs no divider; those columns belong to the quota 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 expandedQuota = usage(visibleWidth(quota) + spareWidth, MAX_BAR_CELLS) ?? quota;
177
186
  const expandedCore = [core, expandedQuota].filter(Boolean).join(QUOTA_GAP);
178
187
  right = [fittedStatuses, expandedCore].filter(Boolean).join(statusSeparator);
179
188
  }
@@ -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 quota 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 skills = config.showSkills
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
- skills.length ? statusSeparator + skills.map((s) => theme.fg("dim", s)).join(statusSeparator) : "",
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 = 5) => usageLimits.line(available, theme, 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
- usageLimits.select(ctx, enabled);
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
- usageLimits.select(ctx, enabled);
279
+ selectUsage(ctx);
264
280
  refresh(ctx);
265
281
  });
266
282
 
@@ -6,6 +6,29 @@ import { parseUsageWindows, record, shortest, supportedOrigin, usageRequest, win
6
6
  import type { QuotaWindow } from "./quota-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
- // Observe late failures even after abort has settled the caller's await.
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);
@@ -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 quota.
182
206
  this.request = controller;
183
207
  this.attemptedAt = Date.now();
184
- const timeout = setTimeout(() => controller.abort(), 5000);
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 = 10): string | null {
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 date = window.resetAt === null ? null : new Date(window.resetAt);
216
- const remaining = window.resetAt === null ? 0 : window.resetAt - now;
217
- const day = 24 * 60 * 60 * 1000;
218
- let time = date ? `↻${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}` : "";
219
- if (remaining > 10 * day) {
220
- time = `↻${Math.floor(remaining / day)}d`;
221
- } else if (remaining > day) {
222
- time = `↻${Math.floor(remaining / day)}d${Math.floor(remaining % day / (60 * 60 * 1000))}h`;
223
- }
224
- if (width < (time ? time.length : 1)) return null;
225
- const cells = Math.min(maxCells, Math.max(0, width - time.length));
226
- const steps = Math.round(window.used / 100 * cells * 8);
227
- const filled = "⣿".repeat(Math.floor(steps / 8)) + ["", "⡀", "⣀", "⣄", "⣤", "⣦", "⣶", "⣷"][steps % 8];
228
- const empty = "⠀".repeat(cells - Math.ceil(steps / 8));
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", time);
248
+ return bar + theme.fg("dim", resetLabel);
233
249
  }
234
250
  }
Binary file
package/media/preview.png CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-minimal-footer",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
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",