@hk_net/pi-usage-bars 0.4.5 → 0.5.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 +19 -0
- package/README.md +13 -3
- package/docs/releasing.md +20 -0
- package/extensions/usage-bars/index.ts +112 -16
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## [0.5.0] - 2026-08-22
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added a `--usage` extension flag that prints one JSON line for the active provider and exits, providing a non-interactive Pi loader, credential, and endpoint smoke test.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Raised the minimum supported Pi version from 0.81.1 to 0.84.2; older Pi releases are no longer claimed or tested as compatible.
|
|
14
|
+
- Namespaced the footer status and inter-extension usage update event to avoid collisions with other Pi extensions. **Breaking for event consumers:** listeners using the previous undocumented `usage:update` event must switch to `@hk_net/pi-usage-bars:update`.
|
|
15
|
+
- Bounded the `/usage` provider viewport, expanded only the selected provider, and added the configured page-up/page-down navigation bindings for smaller terminals.
|
|
16
|
+
- Removed redundant active-model checks on every turn in favor of Pi's `session_start` and `model_select` lifecycle events.
|
|
17
|
+
- Cancelled an obsolete in-flight provider request immediately when the selected model provider changes, allowing the new provider to refresh without waiting for the old request timeout.
|
|
18
|
+
- Isolated and documented the current Pi OAuth source-label compatibility check. Pi 0.84.2 exposes `AuthResult.source` as a human-readable label but no public typed credential discriminator, so Claude subscription detection must currently recognize the `"OAuth"` label.
|
|
19
|
+
|
|
20
|
+
### Tests
|
|
21
|
+
|
|
22
|
+
- Added lifecycle coverage for shutdown and replacement-session cancellation, repeated-start interval cleanup, `/usage` closure cancellation, model changes during active polling, and model-selection event wiring.
|
|
23
|
+
|
|
5
24
|
## [0.4.5] - 2026-08-22
|
|
6
25
|
|
|
7
26
|
### Changed
|
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ Google Gemini CLI and Google Antigravity are not supported because Pi removed th
|
|
|
35
35
|
|
|
36
36
|
## Requirements
|
|
37
37
|
|
|
38
|
-
- Pi 0.
|
|
38
|
+
- Pi 0.84.2 or newer
|
|
39
39
|
- Node.js 22.19 or newer when using the npm-distributed Pi CLI
|
|
40
40
|
|
|
41
41
|
Authenticate providers through Pi's `/login` command. The extension resolves credentials through Pi's provider API; it does not read or write `auth.json` itself.
|
|
@@ -74,9 +74,19 @@ Usage refresh runs in interactive TUI sessions every two minutes. It does not ru
|
|
|
74
74
|
|
|
75
75
|
### `/usage`
|
|
76
76
|
|
|
77
|
-
Run `/usage` to open a searchable list of configured supported providers.
|
|
77
|
+
Run `/usage` to open a searchable list of configured supported providers. The selected provider's details are expanded; use the configured selection and page-navigation keybindings to browse the bounded list.
|
|
78
78
|
|
|
79
|
-
![/usage command]
|
|
79
|
+
![/usage command]
|
|
80
|
+
|
|
81
|
+
### `--usage`
|
|
82
|
+
|
|
83
|
+
Pass `--usage` to print one JSON line for the active model provider and exit without opening the usage UI. This is also useful as a loader and credential smoke test:
|
|
84
|
+
|
|
85
|
+
```powershell
|
|
86
|
+
pi --no-extensions -e C:\hk\code\pi-usage-bars\extensions\usage-bars\index.ts --usage
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The result has `status` set to `ok`, `unconfigured`, `unsupported`, or `error`. No credential is included in the output.(https://raw.githubusercontent.com/hknet/pi-usage-bars/main/assets/usage-command.png)
|
|
80
90
|
|
|
81
91
|
## Endpoint configuration
|
|
82
92
|
|
package/docs/releasing.md
CHANGED
|
@@ -22,6 +22,14 @@ cd /path/to/pi-usage-bars
|
|
|
22
22
|
scripts/release.sh X.Y.Z
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
From Windows PowerShell, run the Bash workflow through WSL (replace the WSL path with your checkout):
|
|
26
|
+
|
|
27
|
+
```powershell
|
|
28
|
+
wsl bash -lc 'cd /mnt/c/path/to/pi-usage-bars && ./scripts/release.sh X.Y.Z'
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The repository's `.gitattributes` keeps shell scripts on LF endings so they remain executable in WSL even when Git for Windows uses `core.autocrlf=true`.
|
|
32
|
+
|
|
25
33
|
This **default command only prepares the release**. It deliberately stops before npm login or publication, so the pushed release commit can be reviewed first. The launcher first executes an immutable temporary copy of itself, so editing the script while a release is running cannot corrupt that run.
|
|
26
34
|
|
|
27
35
|
Preparation:
|
|
@@ -39,6 +47,12 @@ After reviewing the prepared commit, stage it for browser approval:
|
|
|
39
47
|
scripts/release.sh X.Y.Z stage
|
|
40
48
|
```
|
|
41
49
|
|
|
50
|
+
From Windows PowerShell:
|
|
51
|
+
|
|
52
|
+
```powershell
|
|
53
|
+
wsl bash -lc 'cd /mnt/c/path/to/pi-usage-bars && ./scripts/release.sh X.Y.Z stage'
|
|
54
|
+
```
|
|
55
|
+
|
|
42
56
|
`stage` submits the package to npm's staged-publishing queue. It **does not publish live**, does not open a browser, and does not tag Git. If npm authentication is needed, run `npm login --auth-type=web` manually first; this only authenticates the CLI.
|
|
43
57
|
|
|
44
58
|
In [npmjs.com](https://www.npmjs.com), open **Staged Packages**, review the staged tarball, and click **Approve**. npm prompts for 2FA during approval. After npm shows the version as live, finalize the release:
|
|
@@ -47,6 +61,12 @@ In [npmjs.com](https://www.npmjs.com), open **Staged Packages**, review the stag
|
|
|
47
61
|
scripts/release.sh X.Y.Z finalize
|
|
48
62
|
```
|
|
49
63
|
|
|
64
|
+
From Windows PowerShell:
|
|
65
|
+
|
|
66
|
+
```powershell
|
|
67
|
+
wsl bash -lc 'cd /mnt/c/path/to/pi-usage-bars && ./scripts/release.sh X.Y.Z finalize'
|
|
68
|
+
```
|
|
69
|
+
|
|
50
70
|
`finalize` refuses to run until the exact version is live in the npm registry. It then verifies registry metadata and creates/pushes the annotated Git tag. It never stages or publishes a package.
|
|
51
71
|
|
|
52
72
|
Use `--yes` only for attended preparation:
|
|
@@ -39,7 +39,9 @@ import {
|
|
|
39
39
|
} from "./core";
|
|
40
40
|
|
|
41
41
|
const POLL_INTERVAL_MS = 2 * 60 * 1000;
|
|
42
|
-
const
|
|
42
|
+
const EXTENSION_ID = "@hk_net/pi-usage-bars";
|
|
43
|
+
const STATUS_KEY = EXTENSION_ID;
|
|
44
|
+
const USAGE_UPDATE_EVENT = `${EXTENSION_ID}:update`;
|
|
43
45
|
const PROVIDERS: readonly ProviderKey[] = [
|
|
44
46
|
"codex",
|
|
45
47
|
"claude",
|
|
@@ -123,6 +125,7 @@ class UsageSelectorComponent extends Container implements Focusable {
|
|
|
123
125
|
private allItems: SubscriptionItem[] = [];
|
|
124
126
|
private filteredItems: SubscriptionItem[] = [];
|
|
125
127
|
private selectedIndex = 0;
|
|
128
|
+
private viewportStart = 0;
|
|
126
129
|
private loading = true;
|
|
127
130
|
private hint: "loading" | "ready" | "error" = "loading";
|
|
128
131
|
private disposed = false;
|
|
@@ -215,6 +218,7 @@ class UsageSelectorComponent extends Container implements Focusable {
|
|
|
215
218
|
});
|
|
216
219
|
this.filteredItems = this.allItems;
|
|
217
220
|
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredItems.length - 1));
|
|
221
|
+
this.ensureSelectedVisible();
|
|
218
222
|
}
|
|
219
223
|
|
|
220
224
|
private filterItems(query: string): void {
|
|
@@ -224,6 +228,34 @@ class UsageSelectorComponent extends Container implements Focusable {
|
|
|
224
228
|
item.name.toLowerCase().includes(normalized) || item.provider.includes(normalized))
|
|
225
229
|
: this.allItems;
|
|
226
230
|
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredItems.length - 1));
|
|
231
|
+
this.viewportStart = 0;
|
|
232
|
+
this.ensureSelectedVisible();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private viewportSize(): number {
|
|
236
|
+
// Leave room for the frame, search input, hints, and expanded details for
|
|
237
|
+
// the selected provider. Keeping the provider list bounded avoids pushing
|
|
238
|
+
// the custom UI beyond short terminal viewports.
|
|
239
|
+
return Math.max(1, Math.min(8, this.tui.terminal.rows - 14));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private ensureSelectedVisible(): void {
|
|
243
|
+
const size = this.viewportSize();
|
|
244
|
+
if (this.selectedIndex < this.viewportStart) this.viewportStart = this.selectedIndex;
|
|
245
|
+
if (this.selectedIndex >= this.viewportStart + size) {
|
|
246
|
+
this.viewportStart = this.selectedIndex - size + 1;
|
|
247
|
+
}
|
|
248
|
+
this.viewportStart = Math.max(0, Math.min(
|
|
249
|
+
this.viewportStart,
|
|
250
|
+
Math.max(0, this.filteredItems.length - size),
|
|
251
|
+
));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private moveSelection(delta: number): void {
|
|
255
|
+
if (this.filteredItems.length === 0) return;
|
|
256
|
+
this.selectedIndex = Math.max(0, Math.min(this.filteredItems.length - 1, this.selectedIndex + delta));
|
|
257
|
+
this.ensureSelectedVisible();
|
|
258
|
+
this.refresh();
|
|
227
259
|
}
|
|
228
260
|
|
|
229
261
|
private renderBar(percent: number, width = 16): string {
|
|
@@ -239,6 +271,7 @@ class UsageSelectorComponent extends Container implements Focusable {
|
|
|
239
271
|
const activeBadge = item.isActive ? theme.fg("success", " ✓") : "";
|
|
240
272
|
const name = selected ? theme.fg("accent", theme.bold(item.name)) : item.name;
|
|
241
273
|
this.listContainer.addChild(new Text(`${pointer}${name}${activeBadge}`, 0, 0));
|
|
274
|
+
if (!selected) return;
|
|
242
275
|
|
|
243
276
|
const indent = " ";
|
|
244
277
|
if (item.data.error) {
|
|
@@ -323,7 +356,22 @@ class UsageSelectorComponent extends Container implements Focusable {
|
|
|
323
356
|
this.listContainer.addChild(new Text(this.theme.fg("muted", " No matching configured providers"), 0, 0));
|
|
324
357
|
return;
|
|
325
358
|
}
|
|
326
|
-
this.
|
|
359
|
+
this.ensureSelectedVisible();
|
|
360
|
+
const size = this.viewportSize();
|
|
361
|
+
const end = Math.min(this.filteredItems.length, this.viewportStart + size);
|
|
362
|
+
if (this.viewportStart > 0) {
|
|
363
|
+
this.listContainer.addChild(new Text(this.theme.fg("dim", ` ↑ ${this.viewportStart} more`), 0, 0));
|
|
364
|
+
}
|
|
365
|
+
for (let index = this.viewportStart; index < end; index += 1) {
|
|
366
|
+
this.renderItem(this.filteredItems[index]!, index === this.selectedIndex);
|
|
367
|
+
}
|
|
368
|
+
if (end < this.filteredItems.length) {
|
|
369
|
+
this.listContainer.addChild(new Text(
|
|
370
|
+
this.theme.fg("dim", ` ↓ ${this.filteredItems.length - end} more`),
|
|
371
|
+
0,
|
|
372
|
+
0,
|
|
373
|
+
));
|
|
374
|
+
}
|
|
327
375
|
}
|
|
328
376
|
|
|
329
377
|
private refresh(): void {
|
|
@@ -337,6 +385,7 @@ class UsageSelectorComponent extends Container implements Focusable {
|
|
|
337
385
|
this.selectedIndex = this.selectedIndex === 0
|
|
338
386
|
? this.filteredItems.length - 1
|
|
339
387
|
: this.selectedIndex - 1;
|
|
388
|
+
this.ensureSelectedVisible();
|
|
340
389
|
this.refresh();
|
|
341
390
|
}
|
|
342
391
|
return;
|
|
@@ -346,10 +395,19 @@ class UsageSelectorComponent extends Container implements Focusable {
|
|
|
346
395
|
this.selectedIndex = this.selectedIndex === this.filteredItems.length - 1
|
|
347
396
|
? 0
|
|
348
397
|
: this.selectedIndex + 1;
|
|
398
|
+
this.ensureSelectedVisible();
|
|
349
399
|
this.refresh();
|
|
350
400
|
}
|
|
351
401
|
return;
|
|
352
402
|
}
|
|
403
|
+
if (this.keybindings.matches(keyData, "tui.select.pageUp")) {
|
|
404
|
+
this.moveSelection(-this.viewportSize());
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (this.keybindings.matches(keyData, "tui.select.pageDown")) {
|
|
408
|
+
this.moveSelection(this.viewportSize());
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
353
411
|
if (
|
|
354
412
|
this.keybindings.matches(keyData, "tui.select.cancel") ||
|
|
355
413
|
this.keybindings.matches(keyData, "tui.select.confirm")
|
|
@@ -381,6 +439,12 @@ interface UsageState extends UsageByProvider {
|
|
|
381
439
|
}
|
|
382
440
|
|
|
383
441
|
export default function (pi: ExtensionAPI): void {
|
|
442
|
+
pi.registerFlag("usage", {
|
|
443
|
+
description: "Print one-line usage for the active provider and exit",
|
|
444
|
+
type: "boolean",
|
|
445
|
+
default: false,
|
|
446
|
+
});
|
|
447
|
+
|
|
384
448
|
const endpoints = resolveUsageEndpoints();
|
|
385
449
|
const state: UsageState = {
|
|
386
450
|
codex: null,
|
|
@@ -403,6 +467,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
403
467
|
let pollQueued = false;
|
|
404
468
|
let currentContext: ExtensionContext | undefined;
|
|
405
469
|
let sessionController: AbortController | undefined;
|
|
470
|
+
let providerPollController: AbortController | undefined;
|
|
406
471
|
|
|
407
472
|
const renderPercent = (theme: Theme, value: number) => {
|
|
408
473
|
const percent = clampPercent(value);
|
|
@@ -490,12 +555,20 @@ export default function (pi: ExtensionAPI): void {
|
|
|
490
555
|
const previous = state.activeProvider;
|
|
491
556
|
state.activeProvider = detectProvider(model);
|
|
492
557
|
if (previous !== state.activeProvider) {
|
|
558
|
+
providerPollController?.abort();
|
|
493
559
|
updateStatus();
|
|
494
560
|
return true;
|
|
495
561
|
}
|
|
496
562
|
return false;
|
|
497
563
|
}
|
|
498
564
|
|
|
565
|
+
function isClaudeSubscriptionAuth(source: string | undefined): boolean {
|
|
566
|
+
// Pi 0.84 exposes AuthResult.source as a human-readable label rather than a
|
|
567
|
+
// credential-type discriminator. Keep the compatibility assumption in one
|
|
568
|
+
// place until ModelRegistry exposes the resolved credential type directly.
|
|
569
|
+
return source === "OAuth";
|
|
570
|
+
}
|
|
571
|
+
|
|
499
572
|
async function resolveCredential(ctx: ExtensionContext, provider: ProviderKey): Promise<CredentialResolution> {
|
|
500
573
|
const providerId = providerToPiProviderId(provider);
|
|
501
574
|
if (!ctx.modelRegistry.getProvider(providerId)) return {};
|
|
@@ -504,7 +577,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
504
577
|
|
|
505
578
|
try {
|
|
506
579
|
const resolved = await ctx.modelRegistry.getProviderAuth(providerId);
|
|
507
|
-
if (provider === "claude" && resolved?.source
|
|
580
|
+
if (provider === "claude" && !isClaudeSubscriptionAuth(resolved?.source)) return {};
|
|
508
581
|
const token = resolved?.auth.apiKey;
|
|
509
582
|
if (token) return { token };
|
|
510
583
|
// Some OAuth flows (e.g. kimi-coding) expose the token only as a Bearer
|
|
@@ -563,18 +636,25 @@ export default function (pi: ExtensionAPI): void {
|
|
|
563
636
|
|
|
564
637
|
async function runPoll(): Promise<void> {
|
|
565
638
|
const ctx = currentContext;
|
|
566
|
-
const
|
|
639
|
+
const sessionSignal = sessionController?.signal;
|
|
567
640
|
const provider = state.activeProvider;
|
|
568
|
-
if (!ctx || !
|
|
641
|
+
if (!ctx || !sessionSignal || sessionSignal.aborted || ctx.mode !== "tui" || !provider) {
|
|
569
642
|
updateStatus();
|
|
570
643
|
return;
|
|
571
644
|
}
|
|
572
645
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
const
|
|
576
|
-
|
|
577
|
-
|
|
646
|
+
const controller = new AbortController();
|
|
647
|
+
providerPollController = controller;
|
|
648
|
+
const signal = AbortSignal.any([sessionSignal, controller.signal]);
|
|
649
|
+
try {
|
|
650
|
+
await fetchProvider(ctx, provider, signal);
|
|
651
|
+
if (signal.aborted) return;
|
|
652
|
+
const data = state[provider];
|
|
653
|
+
if (data && !data.error) pi.events.emit(USAGE_UPDATE_EVENT, { provider, ...data });
|
|
654
|
+
updateStatus();
|
|
655
|
+
} finally {
|
|
656
|
+
if (providerPollController === controller) providerPollController = undefined;
|
|
657
|
+
}
|
|
578
658
|
}
|
|
579
659
|
|
|
580
660
|
async function poll(): Promise<void> {
|
|
@@ -611,7 +691,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
611
691
|
return results;
|
|
612
692
|
}
|
|
613
693
|
|
|
614
|
-
pi.on("session_start", (_event, ctx) => {
|
|
694
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
615
695
|
currentContext = ctx;
|
|
616
696
|
sessionController?.abort();
|
|
617
697
|
sessionController = new AbortController();
|
|
@@ -619,6 +699,25 @@ export default function (pi: ExtensionAPI): void {
|
|
|
619
699
|
|
|
620
700
|
if (pollTimer) clearInterval(pollTimer);
|
|
621
701
|
pollTimer = undefined;
|
|
702
|
+
|
|
703
|
+
if (pi.getFlag("usage") === true) {
|
|
704
|
+
const provider = state.activeProvider;
|
|
705
|
+
if (!provider) {
|
|
706
|
+
console.log(JSON.stringify({ extension: EXTENSION_ID, status: "unsupported", provider: ctx.model?.provider }));
|
|
707
|
+
} else {
|
|
708
|
+
await fetchProvider(ctx, provider, sessionController.signal);
|
|
709
|
+
const data = state[provider];
|
|
710
|
+
console.log(JSON.stringify({
|
|
711
|
+
extension: EXTENSION_ID,
|
|
712
|
+
provider,
|
|
713
|
+
status: !data ? "unconfigured" : data.error ? "error" : "ok",
|
|
714
|
+
...(data ?? {}),
|
|
715
|
+
}));
|
|
716
|
+
}
|
|
717
|
+
ctx.shutdown();
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
|
|
622
721
|
if (ctx.mode !== "tui") return;
|
|
623
722
|
|
|
624
723
|
updateStatus();
|
|
@@ -627,6 +726,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
627
726
|
});
|
|
628
727
|
|
|
629
728
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
729
|
+
providerPollController?.abort();
|
|
730
|
+
providerPollController = undefined;
|
|
630
731
|
sessionController?.abort();
|
|
631
732
|
sessionController = undefined;
|
|
632
733
|
pollQueued = false;
|
|
@@ -636,11 +737,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
636
737
|
currentContext = undefined;
|
|
637
738
|
});
|
|
638
739
|
|
|
639
|
-
pi.on("turn_start", (_event, ctx) => {
|
|
640
|
-
currentContext = ctx;
|
|
641
|
-
if (updateProviderFrom(ctx.model)) void poll();
|
|
642
|
-
});
|
|
643
|
-
|
|
644
740
|
pi.on("model_select", (event, ctx) => {
|
|
645
741
|
currentContext = ctx;
|
|
646
742
|
updateProviderFrom(event.model);
|