pi-opencode-go-provider 1.0.24 → 1.1.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/README.md CHANGED
@@ -21,6 +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). 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.
24
25
 
25
26
  ## Installation
26
27
 
@@ -110,6 +111,65 @@ Then select "opencode-go" as the provider and choose from the available models.
110
111
 
111
112
  The default model for this provider is `kimi-k2.6` (matching pi core's built-in default); use `/model` to pick another.
112
113
 
114
+ ## Usage Widget
115
+
116
+ OpenCode Go meters the plan with three dollar budgets — a rolling 5-hour window,
117
+ a weekly window and a monthly window. The extension polls
118
+ `GET https://opencode.ai/zen/go/v1/usage` with your API key and shows how much of
119
+ each budget is **left**, below the editor. When no terminal UI is attached (print
120
+ or JSON mode) the same line goes to the footer status bar instead.
121
+
122
+ ```
123
+ Usage: 5h: 63% · 7d: 41% · 30d: 12% · 5h ↺ 2h14m · 7d ↺ 3d20h · 30d ↺ 20d0h
124
+ ```
125
+
126
+ The line matches the pi-better-openai usage line: the remaining percentages come
127
+ first, then a countdown per window. Countdowns only — three windows with three
128
+ wall-clock reset times run past the terminal width, and `/opencode-go-usage`
129
+ lists the exact local reset time for each. Colours track what is left — green,
130
+ amber at 30% or less, red at 10% or less or when a window reports
131
+ `rate-limited` — and the widget only appears while an `opencode-go` model is
132
+ selected. The Go API publishes no banked reset credits,
133
+ so that trailing segment appears only if a response ever carries a
134
+ `bankedResets` count.
135
+
136
+ ```
137
+ /opencode-go-usage # refresh and show the full breakdown
138
+ /opencode-go-usage refresh # same, but always re-reads the API
139
+ /opencode-go-usage off # hide the widget (persisted)
140
+ /opencode-go-usage on # show it again
141
+ /opencode-go-usage debug # config, last fetch/error, endpoint
142
+ ```
143
+
144
+ Polling runs every 60 seconds, plus after every turn and whenever the selected
145
+ model changes. Settings are read from `~/.pi/agent/opencode-go-provider.json`:
146
+
147
+ ```json
148
+ {
149
+ "usage": {
150
+ "enabled": true,
151
+ "refreshIntervalMs": 60000,
152
+ "showOnlyOnProvider": true,
153
+ "showResetTimes": true,
154
+ "placement": "belowEditor"
155
+ }
156
+ }
157
+ ```
158
+
159
+ `placement` accepts `belowEditor` (default) or `aboveEditor`. Every key is
160
+ optional; `/opencode-go-usage on|off` writes only `enabled`.
161
+
162
+ ### Pooled accounts
163
+
164
+ When [pi-multiprovider](https://github.com/monotykamary/pi-multiprovider)
165
+ 0.8.0+ pools several `opencode-go` accounts, usage reads bill the session's
166
+ active account instead of Pi's default credential: every account has its own
167
+ 5h / 7d / 30d budgets. The widget also repaints when the account changes,
168
+ including when a resumed session restores the account last chosen with
169
+ `/switch-account`, rather than showing the previous account until the next
170
+ poll. Without pi-multiprovider nothing changes: the widget bills the key from
171
+ the resolution order above.
172
+
113
173
  ## Authentication
114
174
 
115
175
  The opencode-go API key can be configured in multiple ways. Credentials are resolved in this order:
@@ -126,7 +186,9 @@ The opencode-go API key can be configured in multiple ways. Credentials are reso
126
186
 
127
187
  | Variable | Required | Description |
128
188
  |----------|----------|-------------|
129
- | `OPENCODE_API_KEY` | No | Your opencode.ai API key (fallback if not in auth.json) |
189
+ | `OPENCODE_API_KEY` | No | Your opencode.ai API key (fallback if not in auth.json). Also used for usage requests |
190
+ | `OPENCODE_GO_USAGE` | No | Set to `off`/`false`/`0` to disable the usage widget without editing the config file |
191
+ | `OPENCODE_GO_USAGE_INTERVAL_MS` | No | Override the usage poll interval (clamped to 15s–10m) |
130
192
 
131
193
  ## Configuration
132
194
 
package/config.ts ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Persistent settings for the opencode-go provider extension.
3
+ *
4
+ * The file is deliberately tiny and optional: every field falls back to
5
+ * DEFAULT_USAGE_CONFIG, then an environment override wins. Written only by
6
+ * `/opencode-go-usage on|off` so the widget choice survives a restart.
7
+ */
8
+
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
12
+
13
+ export const PROVIDER_ID = "opencode-go";
14
+ export const CONFIG_BASENAME = "opencode-go-provider.json";
15
+ /** Shared key so the widget and the status fallback never collide with other extensions. */
16
+ export const USAGE_WIDGET_KEY = "opencode-go-usage";
17
+
18
+ export type UsagePlacement = "aboveEditor" | "belowEditor";
19
+
20
+ export interface UsageConfig {
21
+ /** Show the usage widget/status at all. */
22
+ enabled: boolean;
23
+ /** Poll interval while a session is open. */
24
+ refreshIntervalMs: number;
25
+ /** Only display usage while the selected model belongs to opencode-go. */
26
+ showOnlyOnProvider: boolean;
27
+ /** Include reset countdowns in the widget line. */
28
+ showResetTimes: boolean;
29
+ placement: UsagePlacement;
30
+ }
31
+
32
+ export const DEFAULT_USAGE_CONFIG: UsageConfig = {
33
+ enabled: true,
34
+ refreshIntervalMs: 60_000,
35
+ showOnlyOnProvider: true,
36
+ showResetTimes: true,
37
+ placement: "belowEditor",
38
+ };
39
+
40
+ export const MIN_USAGE_REFRESH_MS = 15_000;
41
+ export const MAX_USAGE_REFRESH_MS = 10 * 60_000;
42
+
43
+ const USAGE_PLACEMENTS: UsagePlacement[] = ["aboveEditor", "belowEditor"];
44
+ const DISABLED_VALUES = new Set(["0", "false", "off", "no", "disable", "disabled"]);
45
+ const ENABLED_VALUES = new Set(["1", "true", "on", "yes", "enable", "enabled"]);
46
+
47
+ export function configPath(): string {
48
+ return path.join(getAgentDir(), CONFIG_BASENAME);
49
+ }
50
+
51
+ export function clampRefreshInterval(milliseconds: number): number {
52
+ if (!Number.isFinite(milliseconds)) return DEFAULT_USAGE_CONFIG.refreshIntervalMs;
53
+ return Math.max(MIN_USAGE_REFRESH_MS, Math.min(MAX_USAGE_REFRESH_MS, Math.round(milliseconds)));
54
+ }
55
+
56
+ function parseFlag(value: string | undefined): boolean | undefined {
57
+ const normalized = value?.trim().toLowerCase();
58
+ if (!normalized) return undefined;
59
+ if (DISABLED_VALUES.has(normalized)) return false;
60
+ if (ENABLED_VALUES.has(normalized)) return true;
61
+ return undefined;
62
+ }
63
+
64
+ function readFileUsage(): Record<string, unknown> {
65
+ try {
66
+ const parsed: unknown = JSON.parse(fs.readFileSync(configPath(), "utf8"));
67
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
68
+ const usage = (parsed as Record<string, unknown>).usage;
69
+ if (usage && typeof usage === "object" && !Array.isArray(usage)) {
70
+ return usage as Record<string, unknown>;
71
+ }
72
+ }
73
+ } catch {
74
+ // Missing or unreadable config is the normal first-run state.
75
+ }
76
+ return {};
77
+ }
78
+
79
+ /**
80
+ * Resolve the effective usage config: defaults <- config file <- environment.
81
+ * Never throws; a broken config file degrades to defaults.
82
+ */
83
+ export function readUsageConfig(env: NodeJS.ProcessEnv = process.env): UsageConfig {
84
+ const config = { ...DEFAULT_USAGE_CONFIG };
85
+ const file = readFileUsage();
86
+
87
+ if (typeof file.enabled === "boolean") config.enabled = file.enabled;
88
+ if (typeof file.showOnlyOnProvider === "boolean") config.showOnlyOnProvider = file.showOnlyOnProvider;
89
+ if (typeof file.showResetTimes === "boolean") config.showResetTimes = file.showResetTimes;
90
+ if (typeof file.refreshIntervalMs === "number") {
91
+ config.refreshIntervalMs = clampRefreshInterval(file.refreshIntervalMs);
92
+ }
93
+ if (USAGE_PLACEMENTS.includes(file.placement as UsagePlacement)) {
94
+ config.placement = file.placement as UsagePlacement;
95
+ }
96
+
97
+ const envEnabled = parseFlag(env.OPENCODE_GO_USAGE);
98
+ if (envEnabled !== undefined) config.enabled = envEnabled;
99
+ const envInterval = env.OPENCODE_GO_USAGE_INTERVAL_MS
100
+ ? Number(env.OPENCODE_GO_USAGE_INTERVAL_MS)
101
+ : undefined;
102
+ if (envInterval !== undefined) config.refreshIntervalMs = clampRefreshInterval(envInterval);
103
+
104
+ return config;
105
+ }
106
+
107
+ /** Merge a patch into the config file, preserving unrelated keys. */
108
+ export function writeUsageConfig(patch: Partial<UsageConfig>): boolean {
109
+ try {
110
+ const file = configPath();
111
+ let document: Record<string, unknown> = {};
112
+ try {
113
+ const parsed: unknown = JSON.parse(fs.readFileSync(file, "utf8"));
114
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
115
+ document = parsed as Record<string, unknown>;
116
+ }
117
+ } catch {
118
+ document = {};
119
+ }
120
+ document.usage = { ...readFileUsage(), ...patch };
121
+ fs.mkdirSync(path.dirname(file), { recursive: true });
122
+ fs.writeFileSync(file, `${JSON.stringify(document, null, 2)}\n`);
123
+ return true;
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
package/format.ts ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Terminal text helpers for the usage widget.
3
+ *
4
+ * Kept dependency-free (no @earendil-works/pi-tui import) so the usage logic
5
+ * stays unit-testable with a bare Node test run and the extension keeps its
6
+ * single devDependency.
7
+ */
8
+
9
+ const ANSI_PATTERN = "\u001B\\[[0-?]*[ -/]*[@-~]";
10
+ const ANSI_SPLIT_REGEXP = new RegExp("(" + ANSI_PATTERN + ")");
11
+ const ANSI_TEST_REGEXP = new RegExp(ANSI_PATTERN);
12
+
13
+ export function stripAnsi(value: string): string {
14
+ return value.replace(new RegExp(ANSI_PATTERN, "g"), "");
15
+ }
16
+
17
+ /** Approximate terminal cell width of a single code point. */
18
+ function charWidth(char: string): number {
19
+ const code = char.codePointAt(0) ?? 0;
20
+ if (code === 0) return 0;
21
+ // Combining marks and zero-width joiners occupy no cells.
22
+ if (code >= 0x0300 && code <= 0x036f) return 0;
23
+ if (code === 0x200d || code === 0xfe0f) return 0;
24
+ if (
25
+ (code >= 0x1100 && code <= 0x115f) ||
26
+ (code >= 0x2e80 && code <= 0xa4cf) ||
27
+ (code >= 0xac00 && code <= 0xd7a3) ||
28
+ (code >= 0xf900 && code <= 0xfaff) ||
29
+ (code >= 0xfe30 && code <= 0xfe6f) ||
30
+ (code >= 0xff00 && code <= 0xff60) ||
31
+ (code >= 0xffe0 && code <= 0xffe6) ||
32
+ (code >= 0x1f300 && code <= 0x1faff) ||
33
+ (code >= 0x20000 && code <= 0x3fffd)
34
+ ) {
35
+ return 2;
36
+ }
37
+ return 1;
38
+ }
39
+
40
+ /** Visible width of a string, ignoring ANSI escape sequences. */
41
+ export function visibleWidth(value: string): number {
42
+ let width = 0;
43
+ for (const char of stripAnsi(value)) width += charWidth(char);
44
+ return width;
45
+ }
46
+
47
+ /**
48
+ * Slice a possibly-colored string to a terminal width, appending an ellipsis.
49
+ * ANSI sequences before the cut are preserved and a reset is emitted so the
50
+ * ellipsis (and anything after it) keeps the caller's own styling.
51
+ */
52
+ export function truncateToWidth(value: string, width: number, ellipsis = "\u2026"): string {
53
+ if (width <= 0) return "";
54
+ if (visibleWidth(value) <= width) return value;
55
+ const budget = Math.max(0, width - visibleWidth(ellipsis));
56
+ const tokens = value.split(ANSI_SPLIT_REGEXP);
57
+ let result = "";
58
+ let used = 0;
59
+ let truncated = false;
60
+ for (const token of tokens) {
61
+ if (token === "") continue;
62
+ if (ANSI_TEST_REGEXP.test(token)) {
63
+ result += token;
64
+ continue;
65
+ }
66
+ for (const char of token) {
67
+ const cellWidth = charWidth(char);
68
+ if (used + cellWidth > budget) {
69
+ truncated = true;
70
+ break;
71
+ }
72
+ result += char;
73
+ used += cellWidth;
74
+ }
75
+ if (truncated) break;
76
+ }
77
+ const reset = ANSI_TEST_REGEXP.test(value) ? "\u001B[0m" : "";
78
+ return result + reset + ellipsis;
79
+ }
80
+
81
+ /** Collapse whitespace so a multi-segment line survives a one-line status bar. */
82
+ export function sanitizeStatusText(text: string): string {
83
+ return text.replace(/[ \r\n\t]+/g, " ").trim();
84
+ }
package/index.ts CHANGED
@@ -22,7 +22,23 @@
22
22
  * Then use /model to select from available models
23
23
  */
24
24
 
25
- import { getAgentDir, type ExtensionAPI, type ModelRegistry } from "@earendil-works/pi-coding-agent";
25
+ import {
26
+ getAgentDir,
27
+ type ExtensionAPI,
28
+ type ExtensionContext,
29
+ type ModelRegistry,
30
+ type ThemeColor,
31
+ } from "@earendil-works/pi-coding-agent";
32
+ import { USAGE_WIDGET_KEY, readUsageConfig, writeUsageConfig } from "./config.ts";
33
+ import { sanitizeStatusText, truncateToWidth } from "./format.ts";
34
+ import { usageSegments, type UsageSegment, type UsageSeverity } from "./usage.ts";
35
+ import { UsageController } from "./usage-controller.ts";
36
+ import {
37
+ isMultiproviderService,
38
+ MULTIPROVIDER_SERVICE_EVENT,
39
+ setActiveMultiproviderService,
40
+ type MultiproviderService,
41
+ } from "./multiprovider.ts";
26
42
  import modelsData from "./models.json" with { type: "json" };
27
43
  import customModelsData from "./custom-models.json" with { type: "json" };
28
44
  import patchData from "./patch.json" with { type: "json" };
@@ -402,12 +418,134 @@ export default function (pi: ExtensionAPI) {
402
418
  })),
403
419
  });
404
420
 
421
+ // Usage widget (5h / 7d / 30d).
422
+ // opencode-go meters the Go plan with a rolling 5h window, a weekly window and
423
+ // a monthly window. GET /zen/go/v1/usage publishes all three. The widget below
424
+ // the editor mirrors pi-better-openai's usage widget; the footer status line is
425
+ // the fallback when no terminal UI is attached.
426
+
427
+ let usageConfig = readUsageConfig();
428
+ let usageWidgetInstalled = false;
429
+ const usageController = new UsageController(() => usageConfig, updateUsageWidget);
430
+ let usageContext: ExtensionContext | undefined;
431
+
432
+ // Follow pi-multiprovider's active pooled account. Usage is per-account, so a
433
+ // switch — and a resume, which replays the account the session last switched
434
+ // to — must repaint the widget instead of waiting for the next poll. Without
435
+ // pi-multiprovider nothing here activates and resolution stays unchanged.
436
+ let multiproviderService: MultiproviderService | undefined;
437
+ let unsubscribeMultiprovider: (() => void) | undefined;
438
+ const refreshUsageForActiveAccount = (ctx: ExtensionContext | undefined): void => {
439
+ if (ctx === undefined) return;
440
+ void usageController.refresh(ctx, { force: true });
441
+ };
442
+ if (typeof pi.events?.on === "function") {
443
+ pi.events.on(MULTIPROVIDER_SERVICE_EVENT, (value: unknown) => {
444
+ if (!isMultiproviderService(value)) return;
445
+ if (value !== multiproviderService) {
446
+ unsubscribeMultiprovider?.();
447
+ multiproviderService = value;
448
+ setActiveMultiproviderService(value);
449
+ unsubscribeMultiprovider = value.onActiveAccountChanged(PROVIDER_ID, (event) => {
450
+ refreshUsageForActiveAccount(usageContext ?? event.ctx);
451
+ });
452
+ }
453
+ // The event re-fires at every session start with the same stable object,
454
+ // so this also catches a service that appeared mid-session.
455
+ refreshUsageForActiveAccount(usageContext);
456
+ });
457
+ }
458
+
459
+ const USAGE_SEVERITY_COLORS: Record<UsageSeverity, ThemeColor> = {
460
+ ok: "success",
461
+ warning: "warning",
462
+ critical: "error",
463
+ muted: "dim",
464
+ };
465
+
466
+ function usageSegmentsFor(ctx: ExtensionContext): UsageSegment[] | undefined {
467
+ const snapshot = usageController.snapshot;
468
+ if (!snapshot || !usageConfig.enabled || !usageController.isEligible(ctx)) return undefined;
469
+ const segments = usageSegments(snapshot, { showResetTimes: usageConfig.showResetTimes });
470
+ if (usageController.isStale()) segments.push({ text: " · stale", severity: "warning" });
471
+ return segments;
472
+ }
473
+
474
+ function updateUsageWidget(ctx: ExtensionContext): void {
475
+ try {
476
+ const segments = usageSegmentsFor(ctx);
477
+ if (!segments) {
478
+ if (usageWidgetInstalled) {
479
+ ctx.ui.setWidget(USAGE_WIDGET_KEY, undefined);
480
+ ctx.ui.setStatus(USAGE_WIDGET_KEY, undefined);
481
+ usageWidgetInstalled = false;
482
+ }
483
+ return;
484
+ }
485
+ if (ctx.mode === "tui") {
486
+ ctx.ui.setStatus(USAGE_WIDGET_KEY, undefined);
487
+ ctx.ui.setWidget(
488
+ USAGE_WIDGET_KEY,
489
+ (_tui, theme) => ({
490
+ invalidate() {},
491
+ render(width: number): string[] {
492
+ // Segments are captured per install, keeping render() free of the
493
+ // extension context so a stale ctx can never be touched mid-draw.
494
+ const line = segments
495
+ .map((segment) => theme.fg(USAGE_SEVERITY_COLORS[segment.severity], segment.text))
496
+ .join("");
497
+ return [truncateToWidth(line, width, theme.fg("dim", "\u2026"))];
498
+ },
499
+ }),
500
+ { placement: usageConfig.placement },
501
+ );
502
+ } else {
503
+ ctx.ui.setWidget(USAGE_WIDGET_KEY, undefined);
504
+ ctx.ui.setStatus(USAGE_WIDGET_KEY, sanitizeStatusText(segments.map((s) => s.text).join("")));
505
+ }
506
+ usageWidgetInstalled = true;
507
+ } catch {
508
+ // A stale extension context can surface here; the next session re-installs.
509
+ }
510
+ }
511
+
512
+ pi.registerCommand("opencode-go-usage", {
513
+ description: "Show, refresh, or toggle the OpenCode Go 5h / 7d / 30d usage widget",
514
+ handler: async (args, ctx) => {
515
+ const action = args.trim().toLowerCase();
516
+ if (action === "on" || action === "off") {
517
+ const enabled = action === "on";
518
+ usageConfig = { ...usageConfig, enabled };
519
+ const persisted = writeUsageConfig({ enabled });
520
+ const suffix = persisted ? "" : " for this session (could not write the config file)";
521
+ if (enabled) {
522
+ usageController.start(ctx);
523
+ ctx.ui.notify(`OpenCode Go usage widget enabled${suffix}.`, "info");
524
+ } else {
525
+ usageController.stop();
526
+ usageController.clear();
527
+ updateUsageWidget(ctx);
528
+ ctx.ui.notify(`OpenCode Go usage widget disabled${suffix}.`, "info");
529
+ }
530
+ return;
531
+ }
532
+ if (action === "debug") {
533
+ ctx.ui.notify(usageController.formatDebug(ctx), "info");
534
+ return;
535
+ }
536
+ // No argument (and "refresh") always re-reads the API and reports the breakdown.
537
+ await usageController.refresh(ctx, { notify: true, force: true });
538
+ },
539
+ });
540
+
405
541
  pi.on("before_provider_headers", (event, ctx) => {
406
542
  if (ctx.model?.provider !== PROVIDER_ID) return;
407
543
  applyOpenCodeSessionHeaders(event.headers, ctx.sessionManager.getSessionId());
408
544
  });
409
545
 
410
546
  pi.on("session_start", async (_event, ctx) => {
547
+ usageContext = ctx;
548
+ usageController.start(ctx);
411
549
  revalidateAbort?.abort();
412
550
  revalidateAbort = new AbortController();
413
551
  const signal = revalidateAbort.signal;
@@ -438,7 +576,21 @@ export default function (pi: ExtensionAPI) {
438
576
  });
439
577
  });
440
578
 
579
+ pi.on("turn_end", (_event, ctx) => {
580
+ void usageController.refresh(ctx);
581
+ });
582
+
583
+ pi.on("model_select", (_event, ctx) => {
584
+ void usageController.refresh(ctx);
585
+ });
586
+
441
587
  pi.on("session_shutdown", () => {
442
588
  revalidateAbort?.abort();
589
+ usageContext = undefined;
590
+ unsubscribeMultiprovider?.();
591
+ unsubscribeMultiprovider = undefined;
592
+ multiproviderService = undefined;
593
+ setActiveMultiproviderService(undefined);
594
+ usageController.shutdown();
443
595
  });
444
596
  }
@@ -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,9 +1,17 @@
1
1
  {
2
2
  "name": "pi-opencode-go-provider",
3
- "version": "1.0.24",
3
+ "version": "1.1.0",
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",
7
+ "scripts": {
8
+ "clean": "echo 'nothing to clean'",
9
+ "build": "echo 'nothing to build'",
10
+ "check": "npm run typecheck && npm run test",
11
+ "update-models": "node scripts/update-models.js",
12
+ "test": "node --test",
13
+ "typecheck": "tsc --noEmit"
14
+ },
7
15
  "keywords": [
8
16
  "pi",
9
17
  "extension",
@@ -24,12 +32,8 @@
24
32
  ]
25
33
  },
26
34
  "devDependencies": {
27
- "@earendil-works/pi-coding-agent": "0.85.1"
28
- },
29
- "scripts": {
30
- "clean": "echo 'nothing to clean'",
31
- "build": "echo 'nothing to build'",
32
- "check": "echo 'nothing to check'",
33
- "update-models": "node scripts/update-models.js"
35
+ "@earendil-works/pi-coding-agent": "0.85.1",
36
+ "@types/node": "~22.19.0",
37
+ "typescript": "^6.0.3"
34
38
  }
35
- }
39
+ }
@@ -0,0 +1,35 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { sanitizeStatusText, stripAnsi, truncateToWidth, visibleWidth } from "../format.ts";
4
+
5
+ const RED = "";
6
+ const RESET = "";
7
+
8
+ test("measures visible width without ANSI escapes", () => {
9
+ assert.equal(visibleWidth("abc"), 3);
10
+ assert.equal(visibleWidth(`${RED}abc${RESET}`), 3);
11
+ assert.equal(visibleWidth(""), 0);
12
+ });
13
+
14
+ test("strips ANSI escapes", () => {
15
+ assert.equal(stripAnsi(`${RED}abc${RESET}`), "abc");
16
+ });
17
+
18
+ test("truncates to a width with an ellipsis", () => {
19
+ assert.equal(truncateToWidth("abcdef", 10), "abcdef");
20
+ assert.equal(truncateToWidth("abcdef", 4), "abc…");
21
+ assert.equal(truncateToWidth("abcdef", 0), "");
22
+ assert.equal(truncateToWidth("abcdef", 4, "..."), "a...");
23
+ });
24
+
25
+ test("truncation keeps colour codes and stays within the width", () => {
26
+ const colored = `${RED}abcdef${RESET}`;
27
+ const cut = truncateToWidth(colored, 4);
28
+ assert.equal(cut.includes(RED), true);
29
+ assert.equal(visibleWidth(cut), 4);
30
+ assert.equal(stripAnsi(cut), "abc…");
31
+ });
32
+
33
+ test("collapses whitespace for the status bar", () => {
34
+ assert.equal(sanitizeStatusText(" a \n b \t c "), "a b c");
35
+ });