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 +14 -1
- package/README.md +0 -2
- package/extensions/index.ts +30 -14
- package/extensions/lib/usage-limits.ts +34 -18
- 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,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.
|
|
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
|

|
|
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.
|
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
|
|
|
@@ -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(
|
|
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);
|
|
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
|
|
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
181
|
if (quota) {
|
|
175
|
-
|
|
176
|
-
const
|
|
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
|
|
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
|
|
|
@@ -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
|
-
//
|
|
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(),
|
|
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
|
}
|
package/media/github-preview.png
CHANGED
|
Binary file
|
package/media/preview.png
CHANGED
|
Binary file
|
package/package.json
CHANGED