opencode-usage-report 0.2.0 → 0.4.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.
@@ -0,0 +1,194 @@
1
+ import type {
2
+ AdapterResult,
3
+ Credential,
4
+ FetchOptions,
5
+ ProviderAdapter,
6
+ UsageWindow,
7
+ WindowKind,
8
+ } from "../types";
9
+ import { AdapterError } from "../types";
10
+ import { toISODate, toNumber } from "../normalize";
11
+ import { redact } from "../auth";
12
+
13
+ const VERSION = "0.3.0";
14
+ const USER_AGENT = "opencode-usage-report/" + VERSION;
15
+ const ENDPOINT = "https://chatgpt.com/backend-api/wham/usage";
16
+
17
+ function asRecord(v: unknown): Record<string, unknown> | null {
18
+ return v !== null && typeof v === "object" && !Array.isArray(v)
19
+ ? (v as Record<string, unknown>)
20
+ : null;
21
+ }
22
+
23
+ /** "foo_bar-baz" -> "Foo Bar Baz". */
24
+ function titleCase(v: string): string {
25
+ return v
26
+ .split(/[\s_-]+/)
27
+ .filter((word) => word !== "")
28
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
29
+ .join(" ");
30
+ }
31
+
32
+ /** Maps a ChatGPT plan string to a friendly label. Pure so it can be unit-tested directly. */
33
+ export function chatgptPlan(plan: unknown): string {
34
+ if (typeof plan === "string" && plan.trim() !== "") {
35
+ const p = plan.toLowerCase();
36
+ if (p === "free") return "ChatGPT Free";
37
+ if (p === "go") return "ChatGPT Go";
38
+ if (p === "plus") return "ChatGPT Plus";
39
+ if (p === "pro") return "ChatGPT Pro";
40
+ if (p === "team") return "ChatGPT Team";
41
+ if (p === "business") return "ChatGPT Business";
42
+ if (p === "enterprise") return "ChatGPT Enterprise";
43
+ return titleCase(plan);
44
+ }
45
+ return "ChatGPT";
46
+ }
47
+
48
+ /** Maps a window length (seconds) to its kind and label. */
49
+ function windowKind(seconds: number | null): { kind: WindowKind; label: string } {
50
+ if (seconds === 18000) return { kind: "5h", label: "5-hour" };
51
+ if (seconds === 86400) return { kind: "daily", label: "Daily" };
52
+ if (seconds === 604800) return { kind: "weekly", label: "Weekly" };
53
+ if (seconds === 2592000) return { kind: "monthly", label: "Monthly" };
54
+ return { kind: "other", label: "Other" };
55
+ }
56
+
57
+ /** Reads the body once as text and best-effort parses JSON, so error and success paths share it. */
58
+ async function readBody(res: Response): Promise<{ text: string; json: unknown }> {
59
+ const text = await res.text().catch(() => "");
60
+ let json: unknown = null;
61
+ try {
62
+ json = JSON.parse(text);
63
+ } catch {
64
+ json = null;
65
+ }
66
+ return { text, json };
67
+ }
68
+
69
+ async function attempt(cred: Credential, accountId: string, opts: FetchOptions): Promise<Response> {
70
+ return fetch(ENDPOINT, {
71
+ method: "GET",
72
+ headers: {
73
+ Authorization: "Bearer " + cred.key,
74
+ "ChatGPT-Account-Id": accountId,
75
+ Accept: "application/json",
76
+ "User-Agent": USER_AGENT,
77
+ },
78
+ signal: AbortSignal.timeout(opts.timeoutMs),
79
+ });
80
+ }
81
+
82
+ /** Exactly one retry on a network throw or 5xx; 4xx is returned immediately (never retried). */
83
+ async function request(cred: Credential, accountId: string, opts: FetchOptions): Promise<Response> {
84
+ try {
85
+ const first = await attempt(cred, accountId, opts);
86
+ if (first.status < 500) return first;
87
+ } catch {
88
+ // fall through to the single retry
89
+ }
90
+
91
+ try {
92
+ return await attempt(cred, accountId, opts);
93
+ } catch (err) {
94
+ throw new AdapterError(
95
+ "network",
96
+ redact(
97
+ `ChatGPT request failed: ${err instanceof Error ? err.message : String(err)}`,
98
+ cred.key,
99
+ ),
100
+ );
101
+ }
102
+ }
103
+
104
+ async function handleError(res: Response, cred: Credential): Promise<never> {
105
+ const { text } = await readBody(res);
106
+
107
+ if (res.status === 401) {
108
+ throw new AdapterError("auth", redact("invalid ChatGPT login", cred.key));
109
+ }
110
+ if (res.status === 403) {
111
+ throw new AdapterError("no-plan", redact("ChatGPT is not enabled for this account", cred.key));
112
+ }
113
+ if (res.status === 429) {
114
+ throw new AdapterError("rate-limited", redact("ChatGPT rate limit reached", cred.key));
115
+ }
116
+ if (res.status >= 500) {
117
+ throw new AdapterError("network", redact(`ChatGPT server error ${res.status}`, cred.key));
118
+ }
119
+ throw new AdapterError(
120
+ "bad-response",
121
+ redact(`ChatGPT unexpected status ${res.status}: ${text.slice(0, 200)}`, cred.key),
122
+ );
123
+ }
124
+
125
+ export const chatgptAdapter: ProviderAdapter = {
126
+ id: "openai",
127
+ displayName: "ChatGPT",
128
+ async fetch(cred: Credential, opts: FetchOptions): Promise<AdapterResult> {
129
+ if (typeof cred.accountId !== "string" || cred.accountId === "") {
130
+ throw new AdapterError("auth", "ChatGPT account id missing (re-run: opencode auth login)");
131
+ }
132
+
133
+ const res = await request(cred, cred.accountId, opts);
134
+ if (!res.ok) return handleError(res, cred);
135
+
136
+ const { json } = await readBody(res);
137
+ const body = asRecord(json);
138
+ const rateLimit = body ? asRecord(body.rate_limit) : null;
139
+ if (!rateLimit) {
140
+ throw new AdapterError(
141
+ "bad-response",
142
+ redact("ChatGPT response missing rate_limit", cred.key),
143
+ );
144
+ }
145
+
146
+ const limitReached = rateLimit.limit_reached === true || rateLimit.allowed === false;
147
+ const windows: UsageWindow[] = [];
148
+ for (const raw of [rateLimit.primary_window, rateLimit.secondary_window]) {
149
+ const window = asRecord(raw);
150
+ if (!window) continue;
151
+ const { kind, label } = windowKind(toNumber(window.limit_window_seconds));
152
+ const used = toNumber(window.used_percent);
153
+ windows.push({
154
+ kind,
155
+ label,
156
+ usedPercent: used !== null ? Math.min(100, Math.max(0, Math.round(used * 10) / 10)) : null,
157
+ used: null,
158
+ limit: null,
159
+ remaining: null,
160
+ resetsAt: toISODate(window.reset_at),
161
+ status: limitReached ? "rate-limited" : "ok",
162
+ });
163
+ }
164
+
165
+ if (windows.length === 0) {
166
+ throw new AdapterError(
167
+ "bad-response",
168
+ redact("ChatGPT response missing usage windows", cred.key),
169
+ );
170
+ }
171
+
172
+ const extras: Record<string, string> = {};
173
+ extras["Plan"] = chatgptPlan(body?.plan_type);
174
+
175
+ const credits = body ? asRecord(body.credits) : null;
176
+ if (credits) {
177
+ if (credits.unlimited === true) {
178
+ extras["Credits"] = "unlimited";
179
+ } else {
180
+ const balance = toNumber(credits.balance);
181
+ if (balance !== null) {
182
+ extras["Credits"] = `$${balance.toFixed(2)}`;
183
+ } else if (
184
+ credits.has_credits === true &&
185
+ (credits.balance === null || credits.balance === undefined)
186
+ ) {
187
+ extras["Credits"] = "available";
188
+ }
189
+ }
190
+ }
191
+
192
+ return { windows, extras };
193
+ },
194
+ };
@@ -0,0 +1,234 @@
1
+ import type {
2
+ AdapterResult,
3
+ Credential,
4
+ FetchOptions,
5
+ ProviderAdapter,
6
+ UsageWindow,
7
+ } from "../types";
8
+ import { AdapterError } from "../types";
9
+ import { toISODate, toNumber } from "../normalize";
10
+ import { redact } from "../auth";
11
+
12
+ const VERSION = "0.3.0";
13
+ const USER_AGENT = "opencode-usage-report/" + VERSION;
14
+ const ENDPOINT = "https://api.github.com/copilot_internal/user";
15
+
16
+ /** Snapshot keys in display order, paired with their human-readable labels. */
17
+ const SNAPSHOTS: Array<{ key: string; label: string }> = [
18
+ { key: "chat", label: "Chat" },
19
+ { key: "completions", label: "Completions" },
20
+ { key: "premium_interactions", label: "Premium interactions" },
21
+ ];
22
+
23
+ function asRecord(v: unknown): Record<string, unknown> | null {
24
+ return v !== null && typeof v === "object" && !Array.isArray(v)
25
+ ? (v as Record<string, unknown>)
26
+ : null;
27
+ }
28
+
29
+ /** "foo_bar-baz" -> "Foo Bar Baz". */
30
+ function titleCase(v: string): string {
31
+ return v
32
+ .split(/[\s_-]+/)
33
+ .filter((word) => word !== "")
34
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
35
+ .join(" ");
36
+ }
37
+
38
+ /**
39
+ * Maps a Copilot SKU (preferred) or plan string to a friendly tier label.
40
+ * Pure so it can be unit-tested directly.
41
+ */
42
+ export function copilotTier(sku: unknown, plan: unknown): string {
43
+ if (typeof sku === "string") {
44
+ const s = sku.toLowerCase();
45
+ if (s === "free_educational_quota") return "Copilot Student";
46
+ if (s === "free" || s.startsWith("free")) return "Copilot Free";
47
+ if (s.includes("pro_plus") || s.includes("pro+")) return "Copilot Pro+";
48
+ if (s.includes("max")) return "Copilot Max";
49
+ if (s.includes("business")) return "Copilot Business";
50
+ if (s.includes("enterprise")) return "Copilot Enterprise";
51
+ if (s.includes("pro")) return "Copilot Pro";
52
+ }
53
+ if (typeof plan === "string" && plan.trim() !== "") {
54
+ const p = plan.toLowerCase();
55
+ if (p === "individual") return "Copilot Individual";
56
+ if (p === "business") return "Copilot Business";
57
+ if (p === "enterprise") return "Copilot Enterprise";
58
+ return titleCase(plan);
59
+ }
60
+ return "Copilot";
61
+ }
62
+
63
+ /** Reads the body once as text and best-effort parses JSON, so error and success paths share it. */
64
+ async function readBody(res: Response): Promise<{ text: string; json: unknown }> {
65
+ const text = await res.text().catch(() => "");
66
+ let json: unknown = null;
67
+ try {
68
+ json = JSON.parse(text);
69
+ } catch {
70
+ json = null;
71
+ }
72
+ return { text, json };
73
+ }
74
+
75
+ async function attempt(cred: Credential, opts: FetchOptions): Promise<Response> {
76
+ return fetch(ENDPOINT, {
77
+ method: "GET",
78
+ headers: {
79
+ Authorization: "Bearer " + cred.key,
80
+ Accept: "application/json",
81
+ "User-Agent": USER_AGENT,
82
+ },
83
+ signal: AbortSignal.timeout(opts.timeoutMs),
84
+ });
85
+ }
86
+
87
+ /** Exactly one retry on a network throw or 5xx; 4xx is returned immediately (never retried). */
88
+ async function request(cred: Credential, opts: FetchOptions): Promise<Response> {
89
+ try {
90
+ const first = await attempt(cred, opts);
91
+ if (first.status < 500) return first;
92
+ } catch {
93
+ // fall through to the single retry
94
+ }
95
+
96
+ try {
97
+ return await attempt(cred, opts);
98
+ } catch (err) {
99
+ throw new AdapterError(
100
+ "network",
101
+ redact(
102
+ `GitHub Copilot request failed: ${err instanceof Error ? err.message : String(err)}`,
103
+ cred.key,
104
+ ),
105
+ );
106
+ }
107
+ }
108
+
109
+ async function handleError(res: Response, cred: Credential): Promise<never> {
110
+ const { text } = await readBody(res);
111
+
112
+ if (res.status === 401 || res.status === 404) {
113
+ throw new AdapterError(
114
+ "auth",
115
+ redact("GitHub Copilot login invalid or not available for this account", cred.key),
116
+ );
117
+ }
118
+ if (res.status === 403) {
119
+ throw new AdapterError(
120
+ "no-plan",
121
+ redact("GitHub Copilot is not enabled for this account", cred.key),
122
+ );
123
+ }
124
+ if (res.status === 429) {
125
+ throw new AdapterError("rate-limited", redact("GitHub Copilot rate limit reached", cred.key));
126
+ }
127
+ if (res.status >= 500) {
128
+ throw new AdapterError(
129
+ "network",
130
+ redact(`GitHub Copilot server error ${res.status}`, cred.key),
131
+ );
132
+ }
133
+ throw new AdapterError(
134
+ "bad-response",
135
+ redact(`GitHub Copilot unexpected status ${res.status}: ${text.slice(0, 200)}`, cred.key),
136
+ );
137
+ }
138
+
139
+ export const copilotAdapter: ProviderAdapter = {
140
+ id: "github-copilot",
141
+ displayName: "GitHub Copilot",
142
+ async fetch(cred: Credential, opts: FetchOptions): Promise<AdapterResult> {
143
+ const res = await request(cred, opts);
144
+ if (!res.ok) return handleError(res, cred);
145
+
146
+ const { json } = await readBody(res);
147
+ const body = asRecord(json);
148
+ if (!body) {
149
+ throw new AdapterError(
150
+ "bad-response",
151
+ redact("GitHub Copilot response was not an object", cred.key),
152
+ );
153
+ }
154
+
155
+ const snapshots = asRecord(body.quota_snapshots) ?? {};
156
+ const reset = toISODate(body.quota_reset_date);
157
+ const tokenBasedBilling = body.token_based_billing === true;
158
+
159
+ const windows: UsageWindow[] = [];
160
+ const extras: Record<string, string> = {};
161
+ let unlimitedCount = 0;
162
+
163
+ for (const { key, label } of SNAPSHOTS) {
164
+ const snapshot = asRecord(snapshots[key]);
165
+ if (!snapshot) continue;
166
+
167
+ if (snapshot.unlimited === true) {
168
+ unlimitedCount += 1;
169
+ extras[label] = "unlimited";
170
+ continue;
171
+ }
172
+
173
+ const entitlement = toNumber(snapshot.entitlement);
174
+ const quotaRemaining = toNumber(snapshot.quota_remaining);
175
+ if (snapshot.has_quota === false || (entitlement === null && quotaRemaining === null)) {
176
+ continue;
177
+ }
178
+
179
+ const limit = toNumber(snapshot.entitlement ?? snapshot.quota_remaining);
180
+ const remaining = toNumber(snapshot.remaining ?? snapshot.quota_remaining);
181
+ const used = limit !== null && remaining !== null ? Math.max(0, limit - remaining) : null;
182
+
183
+ const percentRemaining = toNumber(snapshot.percent_remaining);
184
+ let usedPercent: number | null = null;
185
+ if (percentRemaining !== null) {
186
+ usedPercent = Math.min(100, Math.max(0, Math.round((100 - percentRemaining) * 10) / 10));
187
+ } else if (used !== null && limit !== null && limit > 0) {
188
+ usedPercent = Math.min(100, Math.max(0, Math.round((used / limit) * 1000) / 10));
189
+ }
190
+
191
+ const windowLabel =
192
+ key === "premium_interactions"
193
+ ? tokenBasedBilling
194
+ ? "Monthly credits"
195
+ : "Monthly premium requests"
196
+ : key === "chat"
197
+ ? "Chat (monthly)"
198
+ : "Completions (monthly)";
199
+
200
+ windows.push({
201
+ kind: "monthly",
202
+ label: windowLabel,
203
+ usedPercent,
204
+ used,
205
+ limit,
206
+ remaining,
207
+ resetsAt: reset,
208
+ status: remaining !== null && remaining <= 0 ? "rate-limited" : "ok",
209
+ });
210
+ }
211
+
212
+ extras["Plan"] = copilotTier(body.access_type_sku, body.copilot_plan);
213
+
214
+ // Billing extras come from the premium-interactions snapshot (the billed one).
215
+ const premium = asRecord(snapshots["premium_interactions"]);
216
+ const overage = premium ? toNumber(premium.overage_entitlement) : null;
217
+ if (tokenBasedBilling && overage !== null && overage > 0) {
218
+ extras["Additional usage budget"] = `$${(overage / 100).toFixed(2)}`;
219
+ }
220
+ const creditsUsed = premium ? toNumber(premium.credits_used) : null;
221
+ if (creditsUsed !== null && creditsUsed > 0) {
222
+ extras["Credits used"] = `${creditsUsed} credits ($${(creditsUsed / 100).toFixed(2)})`;
223
+ }
224
+
225
+ if (windows.length === 0 && unlimitedCount === 0) {
226
+ throw new AdapterError(
227
+ "bad-response",
228
+ redact("GitHub Copilot response missing quota snapshots", cred.key),
229
+ );
230
+ }
231
+
232
+ return { windows, extras };
233
+ },
234
+ };
@@ -1,5 +1,15 @@
1
- import type { ProviderAdapter } from "../types.js";
2
- import { kimiCnAdapter, kimiGlobalAdapter } from "./kimi.js";
3
- import { opencodeGoAdapter } from "./opencode-go.js";
4
- export const adapters: ProviderAdapter[] = [kimiGlobalAdapter, kimiCnAdapter, opencodeGoAdapter];
5
- export function getAdapter(id: string): ProviderAdapter | undefined { return adapters.find(a => a.id === id); }
1
+ import type { ProviderAdapter } from "../types";
2
+ import { kimiCnAdapter, kimiGlobalAdapter } from "./kimi";
3
+ import { opencodeGoAdapter } from "./opencode-go";
4
+ import { copilotAdapter } from "./copilot";
5
+ import { chatgptAdapter } from "./chatgpt";
6
+ export const adapters: ProviderAdapter[] = [
7
+ kimiGlobalAdapter,
8
+ kimiCnAdapter,
9
+ opencodeGoAdapter,
10
+ copilotAdapter,
11
+ chatgptAdapter,
12
+ ];
13
+ export function getAdapter(id: string): ProviderAdapter | undefined {
14
+ return adapters.find((a) => a.id === id);
15
+ }
@@ -4,12 +4,12 @@ import type {
4
4
  FetchOptions,
5
5
  ProviderAdapter,
6
6
  UsageWindow,
7
- } from "../types.js";
8
- import { AdapterError } from "../types.js";
9
- import { pick, ratioToPercent, toISODate, toNumber, windowFromCounts } from "../normalize.js";
10
- import { redact } from "../auth.js";
7
+ } from "../types";
8
+ import { AdapterError } from "../types";
9
+ import { pick, ratioToPercent, toISODate, toNumber, windowFromCounts } from "../normalize";
10
+ import { redact } from "../auth";
11
11
 
12
- const VERSION = "0.2.0";
12
+ const VERSION = "0.3.0";
13
13
 
14
14
  /**
15
15
  * Kimi Code is split by region in opencode/models.dev: the global plan lives on
@@ -71,11 +71,7 @@ function findLimitDetail(limits: unknown, durationMinutes: number): unknown {
71
71
  return undefined;
72
72
  }
73
73
 
74
- function ratioWindow(
75
- kind: UsageWindow["kind"],
76
- label: string,
77
- value: unknown,
78
- ): UsageWindow {
74
+ function ratioWindow(kind: UsageWindow["kind"], label: string, value: unknown): UsageWindow {
79
75
  const ratio = toNumber(pick(value, "used_ratio", "usedRatio"));
80
76
  return {
81
77
  kind,
@@ -6,12 +6,12 @@ import type {
6
6
  UsageWindow,
7
7
  WindowKind,
8
8
  WindowStatus,
9
- } from "../types.js";
10
- import { AdapterError } from "../types.js";
11
- import { toISODate, toNumber } from "../normalize.js";
12
- import { redact } from "../auth.js";
9
+ } from "../types";
10
+ import { AdapterError } from "../types";
11
+ import { toISODate, toNumber } from "../normalize";
12
+ import { redact } from "../auth";
13
13
 
14
- const VERSION = "0.2.0";
14
+ const VERSION = "0.3.0";
15
15
  const USER_AGENT = "opencode-usage-report/" + VERSION;
16
16
  const ENDPOINT = "https://opencode.ai/zen/go/v1/usage";
17
17
 
@@ -139,7 +139,10 @@ export const opencodeGoAdapter: ProviderAdapter = {
139
139
  const body = asRecord(json);
140
140
  const usage = body ? asRecord(body.usage) : null;
141
141
  if (!usage) {
142
- throw new AdapterError("bad-response", redact("OpenCode Go response missing usage", cred.key));
142
+ throw new AdapterError(
143
+ "bad-response",
144
+ redact("OpenCode Go response missing usage", cred.key),
145
+ );
143
146
  }
144
147
 
145
148
  const windows: UsageWindow[] = [];
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Pure line-building for the sidebar quota panel, extracted verbatim from
3
+ * `src/tui.tsx` so the formatting can be unit-tested and later reused by the
4
+ * `/usage` dialog. No JSX and no Solid imports (only the shared formatting
5
+ * helpers and types).
6
+ */
7
+ import type { TuiPluginApi } from "@opencode-ai/plugin/tui";
8
+ import type { ProviderReport, WindowKind } from "./types";
9
+ import { bar, countdown, percentText, toneFor, windowLabel } from "./tui-format";
10
+
11
+ type Theme = TuiPluginApi["theme"]["current"];
12
+ type ThemeColor = Theme["text"];
13
+
14
+ export interface Segment {
15
+ text: string;
16
+ fg: ThemeColor;
17
+ bold?: boolean;
18
+ }
19
+
20
+ export interface Line {
21
+ segments: Segment[];
22
+ }
23
+
24
+ export const NAMED_WIDTH = 14;
25
+ export const EXTRAS_MAX_LENGTH = 80;
26
+
27
+ /** Order windows for display; unknown kinds sort last. */
28
+ export const WINDOW_ORDER: Record<WindowKind, number> = {
29
+ "5h": 0,
30
+ weekly: 1,
31
+ monthly: 2,
32
+ daily: 3,
33
+ other: 4,
34
+ };
35
+
36
+ export function windowRank(kind: WindowKind): number {
37
+ return WINDOW_ORDER[kind] ?? WINDOW_ORDER.other;
38
+ }
39
+
40
+ /** Collapses whitespace and truncates to a single-line label. */
41
+ export function clip(value: string, max = 60): string {
42
+ const single = value.replace(/\s+/g, " ").trim();
43
+ if (single.length <= max) return single;
44
+ return single.slice(0, Math.max(0, max - 1)) + "…";
45
+ }
46
+
47
+ /** Pads a label to a fixed width, truncating (with a trailing space) when long. */
48
+ export function alignLabel(label: string): string {
49
+ const text = label.length > NAMED_WIDTH ? label.slice(0, NAMED_WIDTH - 1) + " " : label;
50
+ return text.padEnd(NAMED_WIDTH);
51
+ }
52
+
53
+ /** Flattens provider reports into plain, pre-colored text lines. */
54
+ export function buildLines(
55
+ theme: Theme,
56
+ reports: ProviderReport[],
57
+ busy: boolean,
58
+ error: string | null,
59
+ now: Date,
60
+ barWidth: number,
61
+ ): Line[] {
62
+ const lines: Line[] = [];
63
+
64
+ if (error !== null) {
65
+ lines.push({
66
+ segments: [{ text: `api error: ${clip(error)}`, fg: theme.error }],
67
+ });
68
+ }
69
+
70
+ if (reports.length === 0 && busy) {
71
+ lines.push({
72
+ segments: [{ text: "loading…", fg: theme.textMuted }],
73
+ });
74
+ }
75
+
76
+ for (const report of reports) {
77
+ const nameColor = report.source === "error" ? theme.textMuted : theme.accent;
78
+ const segments: Segment[] = [{ text: report.displayName, fg: nameColor, bold: true }];
79
+ if (report.stale) segments.push({ text: " (stale)", fg: theme.textMuted });
80
+ if (report.source === "local-estimate") {
81
+ segments.push({ text: " (est)", fg: theme.textMuted });
82
+ }
83
+ lines.push({ segments });
84
+
85
+ if (report.source === "error") {
86
+ lines.push({
87
+ segments: [{ text: clip(report.error ?? "unknown error"), fg: theme.error }],
88
+ });
89
+ continue;
90
+ }
91
+
92
+ const windows = report.windows.toSorted((a, b) => windowRank(a.kind) - windowRank(b.kind));
93
+ for (const w of windows) {
94
+ const tone = toneFor(w.usedPercent, w.status);
95
+ const row: Segment[] = [
96
+ { text: alignLabel(windowLabel(w.kind, w.label)), fg: theme.textMuted },
97
+ { text: bar(w.usedPercent, barWidth), fg: theme[tone] },
98
+ { text: " ", fg: theme.text },
99
+ { text: percentText(w), fg: theme.text },
100
+ ];
101
+ const reset = countdown(w.resetsAt, now);
102
+ if (reset !== null) {
103
+ row.push({ text: ` resets in ${reset}`, fg: theme.textMuted });
104
+ }
105
+ lines.push({ segments: row });
106
+ }
107
+
108
+ for (const [key, value] of Object.entries(report.extras)) {
109
+ lines.push({
110
+ segments: [{ text: clip(`${key}: ${value}`, EXTRAS_MAX_LENGTH), fg: theme.textMuted }],
111
+ });
112
+ }
113
+ }
114
+
115
+ return lines;
116
+ }
package/src/render.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ProviderReport, UsageWindow } from "./types.js";
1
+ import type { ProviderReport, UsageWindow } from "./types";
2
2
 
3
3
  const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
4
4
  const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
@@ -38,7 +38,7 @@ function header(report: ProviderReport, now: Date): string {
38
38
  return `${report.displayName} (${parts.join(", ")})`;
39
39
  }
40
40
 
41
- function windowLine(report: ProviderReport, w: UsageWindow, now: Date): string {
41
+ function windowLine(report: ProviderReport, w: UsageWindow, now: Date, labelWidth: number): string {
42
42
  // Local estimates always carry the `~` marker, even when only absolutes are
43
43
  // known (percent is null): a bare em dash would hide that the row is estimated.
44
44
  const isEstimate = report.source === "local-estimate";
@@ -48,10 +48,11 @@ function windowLine(report: ProviderReport, w: UsageWindow, now: Date): string {
48
48
  : isEstimate
49
49
  ? "~estimate"
50
50
  : "—";
51
- let line = ` ${w.label.padEnd(LABEL_WIDTH)}${percent.padEnd(PERCENT_WIDTH)}`;
51
+ let line = ` ${w.label.padEnd(labelWidth)}${percent.padEnd(PERCENT_WIDTH)}`;
52
52
 
53
53
  if (w.used !== null && w.limit !== null) {
54
- line += `${formatNumber(w.used)} / ${formatNumber(w.limit)} reqs`.padEnd(ABSOLUTES_WIDTH);
54
+ const unit = /credit/i.test(w.label) ? "credits" : "reqs";
55
+ line += `${formatNumber(w.used)} / ${formatNumber(w.limit)} ${unit}`.padEnd(ABSOLUTES_WIDTH);
55
56
  }
56
57
  if (w.resetsAt !== null) {
57
58
  const reset = formatReset(w.resetsAt, now);
@@ -63,13 +64,17 @@ function windowLine(report: ProviderReport, w: UsageWindow, now: Date): string {
63
64
  /** Renders provider reports as a human-readable text table (spec §3.8). */
64
65
  export function renderText(reports: ProviderReport[], opts?: { now?: Date }): string {
65
66
  const now = opts?.now ?? new Date();
67
+ const labelWidth = Math.max(
68
+ LABEL_WIDTH,
69
+ ...reports.flatMap((report) => report.windows.map((w) => w.label.length + 1)),
70
+ );
66
71
  const blocks = reports.map((report) => {
67
72
  if (report.source === "error") {
68
73
  return `⚠ ${report.displayName}: ${report.error ?? "unknown error"}`;
69
74
  }
70
75
  const lines = [header(report, now)];
71
76
  for (const w of report.windows) {
72
- lines.push(windowLine(report, w, now));
77
+ lines.push(windowLine(report, w, now, labelWidth));
73
78
  }
74
79
  for (const [key, value] of Object.entries(report.extras)) {
75
80
  lines.push(` ${key}: ${value}`);