pi-opencode-go-provider 1.0.25 → 1.1.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/README.md CHANGED
@@ -21,7 +21,7 @@ _Go-optimized endpoints for lower latency — 14+ models for [pi](https://github
21
21
  - **Cost Tracking** with per-model pricing for budget management
22
22
  - **Reasoning Models** with thinking level maps for proper effort control
23
23
  - **Prompt-cache session affinity** — sends `x-opencode-session` and `x-opencode-client` so OpenCode Go can pin a session to the same cache node
24
- - **Usage widget** — shows how much of the OpenCode Go 5h / 7d / 30d budgets you have spent, below the editor (footer status line outside the TUI)
24
+ - **Usage widget** — shows how much of the OpenCode Go 5h / 7d / 30d budgets you have spent, below the editor (footer status line outside the TUI). Each account carries its own budgets, so when [pi-multiprovider](https://github.com/monotykamary/pi-multiprovider) 0.8.0+ pools several `opencode-go` accounts the widget bills the session's active one and repaints on every switch or resume.
25
25
 
26
26
  ## Installation
27
27
 
@@ -139,6 +139,7 @@ so that trailing segment appears only if a response ever carries a
139
139
  /opencode-go-usage off # hide the widget (persisted)
140
140
  /opencode-go-usage on # show it again
141
141
  /opencode-go-usage debug # config, last fetch/error, endpoint
142
+ /opencode-go-usage glyphs auto|unicode|ascii # footer glyph set (persisted)
142
143
  ```
143
144
 
144
145
  Polling runs every 60 seconds, plus after every turn and whenever the selected
@@ -151,7 +152,8 @@ model changes. Settings are read from `~/.pi/agent/opencode-go-provider.json`:
151
152
  "refreshIntervalMs": 60000,
152
153
  "showOnlyOnProvider": true,
153
154
  "showResetTimes": true,
154
- "placement": "belowEditor"
155
+ "placement": "belowEditor",
156
+ "glyphs": "auto"
155
157
  }
156
158
  }
157
159
  ```
@@ -159,6 +161,19 @@ model changes. Settings are read from `~/.pi/agent/opencode-go-provider.json`:
159
161
  `placement` accepts `belowEditor` (default) or `aboveEditor`. Every key is
160
162
  optional; `/opencode-go-usage on|off` writes only `enabled`.
161
163
 
164
+ `glyphs` accepts `auto` (default), `unicode`, or `ascii`. Older mintty/Cygwin builds measure ambiguous-width codepoints (the `·` separators, the `↺` reset marker) with their own cell-width tables, which can shift the row and desync pi’s renderer. `auto` switches the widget to ASCII equivalents on those terminals and the widget never paints the terminal’s last column; an explicit `unicode` is clamped to ASCII for widget content there (the status line is not row-budgeted and keeps the choice).
165
+
166
+ ### Pooled accounts
167
+
168
+ When [pi-multiprovider](https://github.com/monotykamary/pi-multiprovider)
169
+ 0.8.0+ pools several `opencode-go` accounts, usage reads bill the session's
170
+ active account instead of Pi's default credential: every account has its own
171
+ 5h / 7d / 30d budgets. The widget also repaints when the account changes,
172
+ including when a resumed session restores the account last chosen with
173
+ `/switch-account`, rather than showing the previous account until the next
174
+ poll. Without pi-multiprovider nothing changes: the widget bills the key from
175
+ the resolution order above.
176
+
162
177
  ## Authentication
163
178
 
164
179
  The opencode-go API key can be configured in multiple ways. Credentials are resolved in this order:
package/config.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  import fs from "node:fs";
10
10
  import path from "node:path";
11
11
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
12
+ import { GLYPH_MODES, type GlyphMode } from "./glyphs.ts";
12
13
 
13
14
  export const PROVIDER_ID = "opencode-go";
14
15
  export const CONFIG_BASENAME = "opencode-go-provider.json";
@@ -26,6 +27,8 @@ export interface UsageConfig {
26
27
  showOnlyOnProvider: boolean;
27
28
  /** Include reset countdowns in the widget line. */
28
29
  showResetTimes: boolean;
30
+ /** Footer glyph set. "auto" degrades to ASCII on legacy terminals (mintty/Cygwin). */
31
+ glyphs: GlyphMode;
29
32
  placement: UsagePlacement;
30
33
  }
31
34
 
@@ -35,6 +38,7 @@ export const DEFAULT_USAGE_CONFIG: UsageConfig = {
35
38
  showOnlyOnProvider: true,
36
39
  showResetTimes: true,
37
40
  placement: "belowEditor",
41
+ glyphs: "auto",
38
42
  };
39
43
 
40
44
  export const MIN_USAGE_REFRESH_MS = 15_000;
@@ -90,6 +94,9 @@ export function readUsageConfig(env: NodeJS.ProcessEnv = process.env): UsageConf
90
94
  if (typeof file.refreshIntervalMs === "number") {
91
95
  config.refreshIntervalMs = clampRefreshInterval(file.refreshIntervalMs);
92
96
  }
97
+ if (GLYPH_MODES.includes(file.glyphs as GlyphMode)) {
98
+ config.glyphs = file.glyphs as GlyphMode;
99
+ }
93
100
  if (USAGE_PLACEMENTS.includes(file.placement as UsagePlacement)) {
94
101
  config.placement = file.placement as UsagePlacement;
95
102
  }
package/glyphs.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Footer glyph policy for the usage widget.
3
+ *
4
+ * Older mintty/Cygwin builds measure emoji and ambiguous-width codepoints with
5
+ * their own cell-width tables, which can disagree with pi's. The widget line is
6
+ * truncated to (now) width - 1 and never padded to the edge, but a glyph the
7
+ * terminal measures wider than expected still shifts everything after it and
8
+ * can desync pi's differential renderer. "auto" swaps the footer glyphs for
9
+ * ASCII on detected legacy terminals; "unicode"/"ascii" force a set.
10
+ */
11
+
12
+ export type GlyphMode = "auto" | "unicode" | "ascii";
13
+
14
+ export interface GlyphSet {
15
+ /** Separator between usage atoms. */
16
+ sep: string;
17
+ /** Reset-countdown prefix. */
18
+ reset: string;
19
+ /** Progress-bar fill / remainder (command output). */
20
+ barFilled: string;
21
+ barHollow: string;
22
+ /** Truncation marker. */
23
+ ellipsis: string;
24
+ }
25
+
26
+ export const UNICODE_GLYPHS: GlyphSet = { sep: "\u00b7", reset: "\u21ba", barFilled: "\u2588", barHollow: "\u2591", ellipsis: "\u2026" };
27
+ export const ASCII_GLYPHS: GlyphSet = { sep: "-", reset: "~", barFilled: "#", barHollow: "-", ellipsis: "..." };
28
+
29
+ export const GLYPH_MODES: GlyphMode[] = ["auto", "unicode", "ascii"];
30
+
31
+ /** True for terminals whose cell-width tables are known to disagree with the
32
+ * width math in format.ts (older mintty/Cygwin builds). */
33
+ export function detectLegacyTerminal(env: NodeJS.ProcessEnv = process.env): boolean {
34
+ const termProgram = env.TERM_PROGRAM ?? "";
35
+ const term = env.TERM ?? "";
36
+ return (
37
+ termProgram === "mintty" ||
38
+ termProgram === "cygwin" ||
39
+ termProgram === "msys" ||
40
+ term.startsWith("cygwin") ||
41
+ term.startsWith("msys")
42
+ );
43
+ }
44
+
45
+ export function resolveGlyphSet(mode: GlyphMode, env: NodeJS.ProcessEnv = process.env): GlyphSet {
46
+ if (mode === "unicode") return UNICODE_GLYPHS;
47
+ if (mode === "ascii") return ASCII_GLYPHS;
48
+ return detectLegacyTerminal(env) ? ASCII_GLYPHS : UNICODE_GLYPHS;
49
+ }
50
+
51
+ /** Widget-safe variant. The widget renders inside the editor's row budget, so
52
+ * an over-wide glyph there can desync pi's renderer; status fallback text is
53
+ * not row-budgeted and keeps an explicit choice. */
54
+ export function resolveWidgetGlyphSet(mode: GlyphMode, env: NodeJS.ProcessEnv = process.env): GlyphSet {
55
+ if (mode === "unicode" && detectLegacyTerminal(env)) return ASCII_GLYPHS;
56
+ return resolveGlyphSet(mode, env);
57
+ }
package/index.ts CHANGED
@@ -29,10 +29,17 @@ import {
29
29
  type ModelRegistry,
30
30
  type ThemeColor,
31
31
  } from "@earendil-works/pi-coding-agent";
32
- import { USAGE_WIDGET_KEY, readUsageConfig, writeUsageConfig } from "./config.ts";
32
+ import { USAGE_WIDGET_KEY, readUsageConfig, writeUsageConfig, type UsageConfig } from "./config.ts";
33
33
  import { sanitizeStatusText, truncateToWidth } from "./format.ts";
34
+ import { resolveGlyphSet, resolveWidgetGlyphSet, type GlyphSet } from "./glyphs.ts";
34
35
  import { usageSegments, type UsageSegment, type UsageSeverity } from "./usage.ts";
35
36
  import { UsageController } from "./usage-controller.ts";
37
+ import {
38
+ isMultiproviderService,
39
+ MULTIPROVIDER_SERVICE_EVENT,
40
+ setActiveMultiproviderService,
41
+ type MultiproviderService,
42
+ } from "./multiprovider.ts";
36
43
  import modelsData from "./models.json" with { type: "json" };
37
44
  import customModelsData from "./custom-models.json" with { type: "json" };
38
45
  import patchData from "./patch.json" with { type: "json" };
@@ -420,7 +427,36 @@ export default function (pi: ExtensionAPI) {
420
427
 
421
428
  let usageConfig = readUsageConfig();
422
429
  let usageWidgetInstalled = false;
430
+ let usageClampNotified = false;
423
431
  const usageController = new UsageController(() => usageConfig, updateUsageWidget);
432
+ let usageContext: ExtensionContext | undefined;
433
+
434
+ // Follow pi-multiprovider's active pooled account. Usage is per-account, so a
435
+ // switch — and a resume, which replays the account the session last switched
436
+ // to — must repaint the widget instead of waiting for the next poll. Without
437
+ // pi-multiprovider nothing here activates and resolution stays unchanged.
438
+ let multiproviderService: MultiproviderService | undefined;
439
+ let unsubscribeMultiprovider: (() => void) | undefined;
440
+ const refreshUsageForActiveAccount = (ctx: ExtensionContext | undefined): void => {
441
+ if (ctx === undefined) return;
442
+ void usageController.refresh(ctx, { force: true });
443
+ };
444
+ if (typeof pi.events?.on === "function") {
445
+ pi.events.on(MULTIPROVIDER_SERVICE_EVENT, (value: unknown) => {
446
+ if (!isMultiproviderService(value)) return;
447
+ if (value !== multiproviderService) {
448
+ unsubscribeMultiprovider?.();
449
+ multiproviderService = value;
450
+ setActiveMultiproviderService(value);
451
+ unsubscribeMultiprovider = value.onActiveAccountChanged(PROVIDER_ID, (event) => {
452
+ refreshUsageForActiveAccount(usageContext ?? event.ctx);
453
+ });
454
+ }
455
+ // The event re-fires at every session start with the same stable object,
456
+ // so this also catches a service that appeared mid-session.
457
+ refreshUsageForActiveAccount(usageContext);
458
+ });
459
+ }
424
460
 
425
461
  const USAGE_SEVERITY_COLORS: Record<UsageSeverity, ThemeColor> = {
426
462
  ok: "success",
@@ -429,17 +465,25 @@ export default function (pi: ExtensionAPI) {
429
465
  muted: "dim",
430
466
  };
431
467
 
432
- function usageSegmentsFor(ctx: ExtensionContext): UsageSegment[] | undefined {
468
+ function usageSegmentsFor(ctx: ExtensionContext, glyphs: GlyphSet): UsageSegment[] | undefined {
433
469
  const snapshot = usageController.snapshot;
434
470
  if (!snapshot || !usageConfig.enabled || !usageController.isEligible(ctx)) return undefined;
435
- const segments = usageSegments(snapshot, { showResetTimes: usageConfig.showResetTimes });
436
- if (usageController.isStale()) segments.push({ text: " · stale", severity: "warning" });
471
+ const segments = usageSegments(snapshot, { showResetTimes: usageConfig.showResetTimes, glyphs });
472
+ if (usageController.isStale()) segments.push({ text: ` ${glyphs.sep} stale`, severity: "warning" });
437
473
  return segments;
438
474
  }
439
475
 
440
476
  function updateUsageWidget(ctx: ExtensionContext): void {
441
477
  try {
442
- const segments = usageSegmentsFor(ctx);
478
+ // Legacy terminals measure the footer glyphs with their own tables; widget
479
+ // content clamps to ASCII there, the status fallback keeps the choice.
480
+ const glyphs = resolveGlyphSet(usageConfig.glyphs);
481
+ const widgetGlyphs = resolveWidgetGlyphSet(usageConfig.glyphs);
482
+ if (widgetGlyphs !== glyphs && !usageClampNotified) {
483
+ usageClampNotified = true;
484
+ ctx.ui.notify("OpenCode Go: widget glyphs stay ASCII on this terminal — unicode glyphs overflow legacy mintty/Cygwin cell widths. The status line is unaffected.", "info");
485
+ }
486
+ const segments = usageSegmentsFor(ctx, widgetGlyphs);
443
487
  if (!segments) {
444
488
  if (usageWidgetInstalled) {
445
489
  ctx.ui.setWidget(USAGE_WIDGET_KEY, undefined);
@@ -460,14 +504,18 @@ export default function (pi: ExtensionAPI) {
460
504
  const line = segments
461
505
  .map((segment) => theme.fg(USAGE_SEVERITY_COLORS[segment.severity], segment.text))
462
506
  .join("");
463
- return [truncateToWidth(line, width, theme.fg("dim", "\u2026"))];
507
+ // Budget width - 1: never paint the terminal’s last column (a
508
+ // pending wrap there desyncs pi’s renderer on legacy terminals).
509
+ const w = Math.max(1, width - 1);
510
+ return [truncateToWidth(line, w, theme.fg("dim", widgetGlyphs.ellipsis))];
464
511
  },
465
512
  }),
466
513
  { placement: usageConfig.placement },
467
514
  );
468
515
  } else {
469
516
  ctx.ui.setWidget(USAGE_WIDGET_KEY, undefined);
470
- ctx.ui.setStatus(USAGE_WIDGET_KEY, sanitizeStatusText(segments.map((s) => s.text).join("")));
517
+ const barSegments = usageSegmentsFor(ctx, glyphs) ?? segments;
518
+ ctx.ui.setStatus(USAGE_WIDGET_KEY, sanitizeStatusText(barSegments.map((s) => s.text).join("")));
471
519
  }
472
520
  usageWidgetInstalled = true;
473
521
  } catch {
@@ -479,6 +527,15 @@ export default function (pi: ExtensionAPI) {
479
527
  description: "Show, refresh, or toggle the OpenCode Go 5h / 7d / 30d usage widget",
480
528
  handler: async (args, ctx) => {
481
529
  const action = args.trim().toLowerCase();
530
+ if (action === "glyphs auto" || action === "glyphs unicode" || action === "glyphs ascii") {
531
+ const glyphs = action.slice("glyphs ".length) as UsageConfig["glyphs"];
532
+ usageConfig = { ...usageConfig, glyphs };
533
+ const persisted = writeUsageConfig({ glyphs });
534
+ updateUsageWidget(ctx);
535
+ const suffix = persisted ? "" : " for this session (could not write the config file)";
536
+ ctx.ui.notify(`OpenCode Go footer glyphs: ${glyphs}${suffix}.`, "info");
537
+ return;
538
+ }
482
539
  if (action === "on" || action === "off") {
483
540
  const enabled = action === "on";
484
541
  usageConfig = { ...usageConfig, enabled };
@@ -510,6 +567,7 @@ export default function (pi: ExtensionAPI) {
510
567
  });
511
568
 
512
569
  pi.on("session_start", async (_event, ctx) => {
570
+ usageContext = ctx;
513
571
  usageController.start(ctx);
514
572
  revalidateAbort?.abort();
515
573
  revalidateAbort = new AbortController();
@@ -551,6 +609,11 @@ export default function (pi: ExtensionAPI) {
551
609
 
552
610
  pi.on("session_shutdown", () => {
553
611
  revalidateAbort?.abort();
612
+ usageContext = undefined;
613
+ unsubscribeMultiprovider?.();
614
+ unsubscribeMultiprovider = undefined;
615
+ multiproviderService = undefined;
616
+ setActiveMultiproviderService(undefined);
554
617
  usageController.shutdown();
555
618
  });
556
619
  }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Soft bridge to pi-multiprovider.
3
+ *
4
+ * When pi-multiprovider pools several opencode-go accounts it announces a
5
+ * service on Pi's event bus and notifies followers whenever the session's
6
+ * active account changes — including a resume, which replays the account the
7
+ * session last switched to. Usage is account-scoped: each account has its own
8
+ * 5h / 7d / 30d budget, so the widget has to bill the active pooled account
9
+ * instead of whichever credential Pi resolves on its own. Without that
10
+ * extension everything here stays inert and resolution is unchanged.
11
+ */
12
+
13
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
14
+
15
+ export const MULTIPROVIDER_SERVICE_EVENT = "pi-multiprovider:service";
16
+
17
+ export type MultiproviderActiveAccount = {
18
+ id: string;
19
+ label: string;
20
+ authKind: string;
21
+ };
22
+
23
+ export type MultiproviderAccountAuth = {
24
+ accessToken: string;
25
+ label: string;
26
+ source?: string;
27
+ };
28
+
29
+ export type MultiproviderAccountChangedEvent = {
30
+ providerId: string;
31
+ account: MultiproviderActiveAccount | undefined;
32
+ ctx: ExtensionContext;
33
+ };
34
+
35
+ export type MultiproviderServiceContext = Pick<
36
+ ExtensionContext,
37
+ "modelRegistry" | "model" | "sessionManager"
38
+ >;
39
+
40
+ export type MultiproviderService = {
41
+ getActiveAccount(
42
+ providerId: string,
43
+ ctx: MultiproviderServiceContext,
44
+ ): Promise<MultiproviderActiveAccount | undefined>;
45
+ resolveActiveAccountAuth(
46
+ providerId: string,
47
+ ctx: MultiproviderServiceContext,
48
+ signal?: AbortSignal,
49
+ ): Promise<MultiproviderAccountAuth | undefined>;
50
+ onActiveAccountChanged(
51
+ providerId: string,
52
+ callback: (event: MultiproviderAccountChangedEvent) => void,
53
+ ): () => void;
54
+ };
55
+
56
+ export function isMultiproviderService(value: unknown): value is MultiproviderService {
57
+ if (typeof value !== "object" || value === null) return false;
58
+ const candidate = value as Partial<MultiproviderService>;
59
+ return (
60
+ typeof candidate.getActiveAccount === "function" &&
61
+ typeof candidate.resolveActiveAccountAuth === "function" &&
62
+ typeof candidate.onActiveAccountChanged === "function"
63
+ );
64
+ }
65
+
66
+ let activeService: MultiproviderService | undefined;
67
+
68
+ export function setActiveMultiproviderService(service: MultiproviderService | undefined): void {
69
+ activeService = service;
70
+ }
71
+
72
+ export function getActiveMultiproviderService(): MultiproviderService | undefined {
73
+ return activeService;
74
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-opencode-go-provider",
3
- "version": "1.0.25",
3
+ "version": "1.1.1",
4
4
  "description": "Opencode Go provider extension for pi - Fast, efficient GLM, Kimi, and MiniMax models through the opencode.ai API",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Glyph policy: legacy terminals (mintty/Cygwin) get an ASCII footer so an
3
+ * over-wide glyph cannot shift the widget line and desync pi's renderer.
4
+ */
5
+ import assert from "node:assert/strict";
6
+ import { test } from "node:test";
7
+ import { DEFAULT_USAGE_CONFIG } from "../config.ts";
8
+ import { ASCII_GLYPHS, UNICODE_GLYPHS, detectLegacyTerminal, resolveGlyphSet, resolveWidgetGlyphSet } from "../glyphs.ts";
9
+ import { formatBar, parseUsageSnapshot, usageSegments } from "../usage.ts";
10
+ import { truncateToWidth } from "../format.ts";
11
+
12
+ const mintty = { TERM_PROGRAM: "mintty", TERM: "xterm" } as NodeJS.ProcessEnv;
13
+ const cygwin = { TERM: "cygwin" } as NodeJS.ProcessEnv;
14
+ const wt = { TERM_PROGRAM: "Windows_Terminal", TERM: "xterm-256color" } as NodeJS.ProcessEnv;
15
+ const isAscii = (value: string) => [...value].every((char) => char.charCodeAt(0) < 128);
16
+
17
+ test("detects legacy terminals", () => {
18
+ assert.equal(detectLegacyTerminal(mintty), true);
19
+ assert.equal(detectLegacyTerminal(cygwin), true);
20
+ assert.equal(detectLegacyTerminal(wt), false);
21
+ assert.equal(detectLegacyTerminal({} as NodeJS.ProcessEnv), false);
22
+ });
23
+
24
+ test("auto degrades on legacy terminals; explicit modes win for the status line", () => {
25
+ assert.equal(resolveGlyphSet("auto", mintty), ASCII_GLYPHS);
26
+ assert.equal(resolveGlyphSet("auto", wt), UNICODE_GLYPHS);
27
+ assert.equal(resolveGlyphSet("unicode", mintty), UNICODE_GLYPHS);
28
+ assert.equal(resolveGlyphSet("ascii", wt), ASCII_GLYPHS);
29
+ });
30
+
31
+ test("widget content clamps an explicit unicode choice on legacy terminals", () => {
32
+ assert.equal(resolveWidgetGlyphSet("unicode", mintty), ASCII_GLYPHS);
33
+ assert.equal(resolveWidgetGlyphSet("unicode", wt), UNICODE_GLYPHS);
34
+ assert.equal(resolveWidgetGlyphSet("auto", mintty), ASCII_GLYPHS);
35
+ assert.equal(resolveWidgetGlyphSet("ascii", wt), ASCII_GLYPHS);
36
+ });
37
+
38
+ test("the default config is auto", () => {
39
+ assert.equal(DEFAULT_USAGE_CONFIG.glyphs, "auto");
40
+ });
41
+
42
+ test("the ASCII set is pure ASCII and renders an ASCII usage line", () => {
43
+ assert.equal(isAscii(Object.values(ASCII_GLYPHS).join("")), true);
44
+ assert.equal(isAscii(Object.values(UNICODE_GLYPHS).join("")), false);
45
+
46
+ // Built through the real parser so the fixture cannot drift from the wire shape.
47
+ const now = Date.now();
48
+ const snapshot = parseUsageSnapshot(
49
+ {
50
+ usage: {
51
+ rolling: { status: "ok", percent: 37, resetsAt: new Date(now + 3_600_000).toISOString() },
52
+ weekly: { status: "ok", percent: 59, resetsAt: new Date(now + 86_400_000).toISOString() },
53
+ },
54
+ },
55
+ now,
56
+ );
57
+ assert.ok(snapshot);
58
+ const asciiLine = usageSegments(snapshot, { showResetTimes: true, glyphs: ASCII_GLYPHS }, now)
59
+ .map((segment) => segment.text)
60
+ .join("");
61
+ const unicodeLine = usageSegments(snapshot, { showResetTimes: true }, now)
62
+ .map((segment) => segment.text)
63
+ .join("");
64
+ assert.equal(isAscii(asciiLine), true);
65
+ assert.equal(asciiLine.includes("5h: 63% - 7d: 41%"), true);
66
+ assert.equal(unicodeLine.includes("\u00b7"), true);
67
+ assert.equal(unicodeLine.includes("\u21ba"), true);
68
+ });
69
+
70
+ test("bars and truncation take the ASCII glyphs too", () => {
71
+ assert.equal(formatBar(50, 4, ASCII_GLYPHS), "##--");
72
+ assert.equal(formatBar(50, 4), "\u2588\u2588\u2591\u2591");
73
+ const cut = truncateToWidth("abcdefghij", 5, ASCII_GLYPHS.ellipsis);
74
+ assert.equal(cut, "ab...");
75
+ assert.equal(isAscii(cut), true);
76
+ });
@@ -0,0 +1,132 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { DEFAULT_USAGE_CONFIG, PROVIDER_ID } from "../config.ts";
5
+ import {
6
+ isMultiproviderService,
7
+ setActiveMultiproviderService,
8
+ type MultiproviderAccountAuth,
9
+ type MultiproviderService,
10
+ } from "../multiprovider.ts";
11
+ import { UsageController } from "../usage-controller.ts";
12
+
13
+ function fakeService(
14
+ resolve: () => Promise<MultiproviderAccountAuth | undefined>,
15
+ ): { value: MultiproviderService; resolveActiveAccountAuth: () => Promise<MultiproviderAccountAuth | undefined> } {
16
+ const resolveActiveAccountAuth = async () => resolve();
17
+ const value: MultiproviderService = {
18
+ getActiveAccount: async () => undefined,
19
+ resolveActiveAccountAuth,
20
+ onActiveAccountChanged: () => () => {},
21
+ };
22
+ return { value, resolveActiveAccountAuth };
23
+ }
24
+
25
+ function sessionContext(registryKey: string | undefined): ExtensionContext {
26
+ return {
27
+ model: { provider: PROVIDER_ID, id: "kimi-k3" },
28
+ hasUI: true,
29
+ signal: undefined,
30
+ ui: { notify: () => {} },
31
+ modelRegistry: { getApiKeyForProvider: async () => registryKey },
32
+ sessionManager: { getSessionId: () => "session-1" },
33
+ } as unknown as ExtensionContext;
34
+ }
35
+
36
+ function makeController(): UsageController {
37
+ return new UsageController(() => DEFAULT_USAGE_CONFIG, () => {});
38
+ }
39
+
40
+ /** Records the bearer token each usage request billed. */
41
+ function stubUsageFetch(): { headers: string[]; restore: () => void } {
42
+ const original = globalThis.fetch;
43
+ const headers: string[] = [];
44
+ globalThis.fetch = (async (_input: unknown, init?: RequestInit) => {
45
+ const requestHeaders = (init?.headers ?? {}) as Record<string, string>;
46
+ headers.push(String(requestHeaders.authorization ?? ""));
47
+ return new Response(
48
+ JSON.stringify({
49
+ usage: {
50
+ rolling: { status: "ok", percent: 10, resetsAt: null },
51
+ weekly: { status: "ok", percent: 20, resetsAt: null },
52
+ monthly: { status: "ok", percent: 30, resetsAt: null },
53
+ },
54
+ }),
55
+ );
56
+ }) as typeof fetch;
57
+ return {
58
+ headers,
59
+ restore() {
60
+ globalThis.fetch = original;
61
+ },
62
+ };
63
+ }
64
+
65
+ test("detects the pi-multiprovider service payload", () => {
66
+ assert.equal(isMultiproviderService(fakeService(async () => undefined).value), true);
67
+ assert.equal(isMultiproviderService(undefined), false);
68
+ assert.equal(isMultiproviderService({ getActiveAccount: () => {} }), false);
69
+ assert.equal(
70
+ isMultiproviderService({
71
+ getActiveAccount: () => {},
72
+ resolveActiveAccountAuth: () => {},
73
+ onActiveAccountChanged: null,
74
+ }),
75
+ false,
76
+ );
77
+ });
78
+
79
+ test("bills the session's pooled account when pi-multiprovider pins one", async () => {
80
+ const fetchStub = stubUsageFetch();
81
+ const service = fakeService(async () => ({ accessToken: "pooled-key", label: "Work" }));
82
+ setActiveMultiproviderService(service.value);
83
+ try {
84
+ const controller = makeController();
85
+ await controller.refresh(sessionContext("registry-key"), { force: true });
86
+ assert.deepEqual(fetchStub.headers, ["Bearer pooled-key"]);
87
+ assert.ok(controller.snapshot);
88
+ } finally {
89
+ setActiveMultiproviderService(undefined);
90
+ fetchStub.restore();
91
+ }
92
+ });
93
+
94
+ test("falls back to the registry key when the session has no pooled account", async () => {
95
+ const fetchStub = stubUsageFetch();
96
+ const service = fakeService(async () => undefined);
97
+ setActiveMultiproviderService(service.value);
98
+ try {
99
+ const controller = makeController();
100
+ await controller.refresh(sessionContext("registry-key"), { force: true });
101
+ assert.deepEqual(fetchStub.headers, ["Bearer registry-key"]);
102
+ } finally {
103
+ setActiveMultiproviderService(undefined);
104
+ fetchStub.restore();
105
+ }
106
+ });
107
+
108
+ test("falls back to the registry key when the bridge rejects", async () => {
109
+ const fetchStub = stubUsageFetch();
110
+ const service = fakeService(() => Promise.reject(new Error("store locked")));
111
+ setActiveMultiproviderService(service.value);
112
+ try {
113
+ const controller = makeController();
114
+ await controller.refresh(sessionContext("registry-key"), { force: true });
115
+ assert.deepEqual(fetchStub.headers, ["Bearer registry-key"]);
116
+ } finally {
117
+ setActiveMultiproviderService(undefined);
118
+ fetchStub.restore();
119
+ }
120
+ });
121
+
122
+ test("keeps its own resolution when pi-multiprovider is absent", async () => {
123
+ const fetchStub = stubUsageFetch();
124
+ setActiveMultiproviderService(undefined);
125
+ try {
126
+ const controller = makeController();
127
+ await controller.refresh(sessionContext("registry-key"), { force: true });
128
+ assert.deepEqual(fetchStub.headers, ["Bearer registry-key"]);
129
+ } finally {
130
+ fetchStub.restore();
131
+ }
132
+ });
@@ -8,6 +8,7 @@
8
8
 
9
9
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
10
  import { PROVIDER_ID, configPath, type UsageConfig } from "./config.ts";
11
+ import { getActiveMultiproviderService } from "./multiprovider.ts";
11
12
  import {
12
13
  USAGE_LIMITS_NOTE,
13
14
  USAGE_URL,
@@ -156,7 +157,27 @@ export class UsageController {
156
157
  }
157
158
  }
158
159
 
160
+ /**
161
+ * The session's active pooled account, when pi-multiprovider pools
162
+ * opencode-go. Every account carries its own budget, so a switched or
163
+ * restored account must bill itself instead of Pi's default credential.
164
+ */
165
+ private async resolvePooledApiKey(ctx: ExtensionContext): Promise<string | undefined> {
166
+ const service = getActiveMultiproviderService();
167
+ if (service === undefined) return undefined;
168
+ try {
169
+ const resolved = await service.resolveActiveAccountAuth(PROVIDER_ID, ctx);
170
+ const token = resolved?.accessToken.trim();
171
+ return token ? token : undefined;
172
+ } catch {
173
+ // A failing bridge must never block the default resolution below.
174
+ return undefined;
175
+ }
176
+ }
177
+
159
178
  private async resolveApiKey(ctx: ExtensionContext): Promise<string | undefined> {
179
+ const pooled = await this.resolvePooledApiKey(ctx);
180
+ if (pooled !== undefined) return pooled;
160
181
  let key: string | undefined;
161
182
  try {
162
183
  key = await ctx.modelRegistry.getApiKeyForProvider(PROVIDER_ID);
package/usage.ts CHANGED
@@ -24,6 +24,8 @@
24
24
  * 30d ↺ 20d0h
25
25
  */
26
26
 
27
+ import { UNICODE_GLYPHS, type GlyphSet } from "./glyphs.ts";
28
+
27
29
  export const USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
28
30
 
29
31
  /** Published Go budget per window; informational only, not sent by the API. */
@@ -66,6 +68,8 @@ export interface UsageSegment {
66
68
  export interface UsageFormatOptions {
67
69
  showResetTimes: boolean;
68
70
  showBankedResets?: boolean;
71
+ /** Glyph set for separators and reset markers; defaults to unicode. */
72
+ glyphs?: GlyphSet;
69
73
  }
70
74
 
71
75
  export const USAGE_WINDOW_LABELS: Record<UsageWindowKey, string> = {
@@ -256,9 +260,10 @@ function formatCompactReset(
256
260
  label: string | undefined,
257
261
  resetAt: number | null,
258
262
  now: number,
263
+ glyphs: GlyphSet = UNICODE_GLYPHS,
259
264
  ): string | null {
260
265
  if (resetAt === null) return null;
261
- return `${label ? `${label} ` : ""} ${formatCountdown(resetAt - now)}`;
266
+ return `${label ? `${label} ` : ""}${glyphs.reset} ${formatCountdown(resetAt - now)}`;
262
267
  }
263
268
 
264
269
  /** "3 banked resets", or null when the count is absent or zero. */
@@ -285,11 +290,12 @@ export function usageSegments(
285
290
  options: UsageFormatOptions,
286
291
  now = Date.now(),
287
292
  ): UsageSegment[] {
293
+ const glyphs = options.glyphs ?? UNICODE_GLYPHS;
288
294
  const windows = snapshot.windows;
289
295
  const labelled = windows.length > 1;
290
296
  const segments: UsageSegment[] = [{ text: "Usage: ", severity: "muted" }];
291
297
  windows.forEach((window, index) => {
292
- if (index > 0) segments.push({ text: " · ", severity: "muted" });
298
+ if (index > 0) segments.push({ text: ` ${glyphs.sep} `, severity: "muted" });
293
299
  segments.push({ text: `${window.label}: `, severity: "muted" });
294
300
  segments.push({
295
301
  text: formatPercent(window.remainingPercent),
@@ -298,13 +304,13 @@ export function usageSegments(
298
304
  });
299
305
  if (options.showResetTimes) {
300
306
  for (const window of windows) {
301
- const reset = formatCompactReset(labelled ? window.label : undefined, window.resetsAt, now);
302
- if (reset) segments.push({ text: ` · ${reset}`, severity: "muted" });
307
+ const reset = formatCompactReset(labelled ? window.label : undefined, window.resetsAt, now, glyphs);
308
+ if (reset) segments.push({ text: ` ${glyphs.sep} ${reset}`, severity: "muted" });
303
309
  }
304
310
  }
305
311
  if (options.showBankedResets !== false) {
306
312
  const banked = formatBankedResetsSuffix(snapshot.bankedResets);
307
- if (banked) segments.push({ text: ` · ${banked}`, severity: "muted" });
313
+ if (banked) segments.push({ text: ` ${glyphs.sep} ${banked}`, severity: "muted" });
308
314
  }
309
315
  return segments;
310
316
  }
@@ -320,13 +326,13 @@ export function formatUsageLine(
320
326
  }
321
327
 
322
328
  /** Progress bar: 20 cells, filled by the remaining percentage. */
323
- export function formatBar(percent: number, width = 20): string {
329
+ export function formatBar(percent: number, width = 20, glyphs: GlyphSet = UNICODE_GLYPHS): string {
324
330
  const filled = Math.round((clampPercent(percent) / 100) * width);
325
- return `${"█".repeat(filled)}${"░".repeat(width - filled)}`;
331
+ return `${glyphs.barFilled.repeat(filled)}${glyphs.barHollow.repeat(width - filled)}`;
326
332
  }
327
333
 
328
334
  /** Multi-line breakdown with bars, used by the command output. */
329
- export function formatUsageDetail(snapshot: UsageSnapshot, now = Date.now()): string[] {
335
+ export function formatUsageDetail(snapshot: UsageSnapshot, now = Date.now(), glyphs: GlyphSet = UNICODE_GLYPHS): string[] {
330
336
  return snapshot.windows.map((window) => {
331
337
  const clock = window.resetsAt === null
332
338
  ? null
@@ -334,8 +340,8 @@ export function formatUsageDetail(snapshot: UsageSnapshot, now = Date.now()): st
334
340
  const reset =
335
341
  window.resetsAt === null || clock === null
336
342
  ? ""
337
- : ` ${formatCountdown(window.resetsAt - now)} - ${clock}`;
343
+ : ` ${glyphs.reset} ${formatCountdown(window.resetsAt - now)} - ${clock}`;
338
344
  const limited = window.status === "rate-limited" ? " RATE LIMITED" : "";
339
- return `${window.label.padEnd(3)} ${formatBar(window.remainingPercent)} ${formatPercent(window.remainingPercent).padStart(4)} left${reset}${limited}`;
345
+ return `${window.label.padEnd(3)} ${formatBar(window.remainingPercent, 20, glyphs)} ${formatPercent(window.remainingPercent).padStart(4)} left${reset}${limited}`;
340
346
  });
341
347
  }