@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 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.81.1 or newer (tested with the current Pi 0.84.2 release)
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](https://raw.githubusercontent.com/hknet/pi-usage-bars/main/assets/usage-command.png)
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 STATUS_KEY = "usage-bars";
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.filteredItems.forEach((item, index) => this.renderItem(item, index === this.selectedIndex));
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 !== "OAuth") return {};
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 signal = sessionController?.signal;
639
+ const sessionSignal = sessionController?.signal;
567
640
  const provider = state.activeProvider;
568
- if (!ctx || !signal || signal.aborted || ctx.mode !== "tui" || !provider) {
641
+ if (!ctx || !sessionSignal || sessionSignal.aborted || ctx.mode !== "tui" || !provider) {
569
642
  updateStatus();
570
643
  return;
571
644
  }
572
645
 
573
- await fetchProvider(ctx, provider, signal);
574
- if (signal.aborted) return;
575
- const data = state[provider];
576
- if (data && !data.error) pi.events.emit("usage:update", { provider, ...data });
577
- updateStatus();
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hk_net/pi-usage-bars",
3
- "version": "0.4.5",
3
+ "version": "0.5.0",
4
4
  "description": "Quota, balance, and spend indicators for current Pi providers",
5
5
  "keywords": [
6
6
  "pi-package",