opencode-usage-report 0.4.0 → 0.4.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
@@ -283,7 +283,7 @@ Releases are **staged**, not published directly. Pushing a `v*` tag triggers
283
283
  the staged release with 2FA:
284
284
 
285
285
  ```sh
286
- git tag v0.4.0 && git push origin main v0.4.0
286
+ git tag v0.4.1 && git push origin main v0.4.1
287
287
  npm stage list # find the stage id
288
288
  npm stage approve <stage-id> # 2FA prompt; or approve in the npm UI
289
289
  ```
@@ -293,6 +293,9 @@ so a failing check blocks the stage.
293
293
 
294
294
  ## Changelog
295
295
 
296
+ - **0.4.1** — internal: shared provider HTTP plumbing (`src/providers/http.ts`)
297
+ replaces the copy-pasted fetch/retry/error code; context estimates simplified;
298
+ reset times render as `Wed, Sep 16`.
296
299
  - **0.4.0** — context usage panel (`/usage` now shows context breakdown, grid,
297
300
  compaction headroom and cost); `/usage` became a local TUI command that costs
298
301
  no model tokens; sidebar rendering extracted to `src/quota-lines.ts`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-usage-report",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "opencode plugin: /usage command showing subscription quota windows for configured providers",
5
5
  "keywords": [
6
6
  "kimi",
@@ -49,7 +49,6 @@
49
49
  "devDependencies": {
50
50
  "@opencode-ai/plugin": "^1.0.0",
51
51
  "@opentui/core": "^0.5.11",
52
- "@opentui/keymap": "^0.5.11",
53
52
  "@opentui/solid": "^0.5.11",
54
53
  "@types/node": "^26.6.2",
55
54
  "oxfmt": "^0.70.0",
@@ -1,6 +1,7 @@
1
1
  import type { AssistantMessage, Part } from "@opencode-ai/sdk/v2";
2
2
  import type { CollectInput, ContextBreakdown, ContextRow, RowKey, SystemCapture } from "./types";
3
3
  import { estimateJson, estimateTokens } from "./estimate";
4
+ import { finiteOrNull, isRecord, safeString } from "../normalize";
4
5
  import { buildGrid } from "./grid";
5
6
  import { computeHeadroom } from "./headroom";
6
7
 
@@ -15,24 +16,6 @@ const ROW_LABELS: Record<RowKey, string> = {
15
16
 
16
17
  const MEASURED_KEYS: readonly RowKey[] = ["user", "agent", "reasoning", "tools", "system"];
17
18
 
18
- function isRecord(value: unknown): value is Record<string, unknown> {
19
- return typeof value === "object" && value !== null;
20
- }
21
-
22
- function finiteOrNull(value: unknown): number | null {
23
- return typeof value === "number" && Number.isFinite(value) ? value : null;
24
- }
25
-
26
- function safeString(value: unknown): string {
27
- if (typeof value === "string") return value;
28
- if (value === null || value === undefined) return "";
29
- try {
30
- return String(value);
31
- } catch {
32
- return "";
33
- }
34
- }
35
-
36
19
  function isAssistant(value: unknown): value is AssistantMessage {
37
20
  return isRecord(value) && value.role === "assistant";
38
21
  }
@@ -72,49 +55,25 @@ function isUsableCapture(capture: SystemCapture | null | undefined): capture is
72
55
  }
73
56
 
74
57
  /**
75
- * Scales `values` so they sum to `target` by the largest-remainder method; the
76
- * result never exceeds `target`. Used when raw `chars/4` estimates overshoot.
58
+ * Scales `values` proportionally so they sum to at most `target`; each row is an
59
+ * estimate, so flooring may leave the five rows a few tokens short of the total.
77
60
  */
78
- function largestRemainderScale(
79
- values: readonly number[],
80
- target: number,
81
- divisor: number,
82
- ): number[] {
61
+ function scaleToTotal(values: readonly number[], target: number, divisor: number): number[] {
83
62
  if (!Number.isFinite(divisor) || divisor <= 0) return values.map(() => 0);
84
63
  const total = Math.max(0, Math.round(target));
85
- const scaled = values.map((value) => (value > 0 ? (value * total) / divisor : 0));
86
- const rounded = scaled.map((value) => Math.floor(value));
87
- let deficit = total - rounded.reduce((sum, value) => sum + value, 0);
88
- if (deficit <= 0) return rounded;
89
-
90
- const order = scaled
91
- .map((value, index) => ({ index, fraction: value - Math.floor(value) }))
92
- .sort((a, b) => b.fraction - a.fraction);
93
- for (const { index } of order) {
94
- if (deficit <= 0) break;
95
- rounded[index] += 1;
96
- deficit -= 1;
97
- }
98
- return rounded;
64
+ return values.map((value) => (value > 0 ? Math.floor((value * total) / divisor) : 0));
99
65
  }
100
66
 
101
- function makeRow(
102
- key: RowKey,
103
- tokens: number | null,
104
- exact: boolean,
105
- limit: number | null,
106
- ): ContextRow {
67
+ function makeRow(key: RowKey, tokens: number | null, limit: number | null): ContextRow {
107
68
  const percent =
108
69
  tokens !== null && limit !== null && limit > 0
109
70
  ? Math.min(100, Math.max(0, Math.round((tokens / limit) * 100)))
110
71
  : null;
111
- return { key, label: ROW_LABELS[key], tokens, percent, exact };
72
+ return { key, label: ROW_LABELS[key], tokens, percent };
112
73
  }
113
74
 
114
75
  function emptyRows(limit: number | null): ContextRow[] {
115
- return [...MEASURED_KEYS, "free" as RowKey].map((key) =>
116
- makeRow(key, null, key === "free", limit),
117
- );
76
+ return [...MEASURED_KEYS, "free" as RowKey].map((key) => makeRow(key, null, limit));
118
77
  }
119
78
 
120
79
  /**
@@ -212,9 +171,9 @@ export function collectContext(input: CollectInput): ContextBreakdown {
212
171
  let systemTokens = Math.max(0, total - estimatedSum);
213
172
 
214
173
  // chars/4 is an estimate in both directions; when it overshoots the exact
215
- // total, scale all five measured rows down so they sum to `total` (6.5).
174
+ // total, scale all five measured rows down so they sum to at most `total` (6.5).
216
175
  if (estimatedSum > total) {
217
- const scaled = largestRemainderScale(
176
+ const scaled = scaleToTotal(
218
177
  [userTokens, agentTokens, reasoningTokens, toolsTokens, systemTokens],
219
178
  total,
220
179
  estimatedSum,
@@ -227,9 +186,9 @@ export function collectContext(input: CollectInput): ContextBreakdown {
227
186
  }
228
187
 
229
188
  const measured = [userTokens, agentTokens, reasoningTokens, toolsTokens, systemTokens];
230
- const rows = MEASURED_KEYS.map((key, index) => makeRow(key, measured[index], false, limit));
189
+ const rows = MEASURED_KEYS.map((key, index) => makeRow(key, measured[index], limit));
231
190
  const freeTokens = limit !== null ? Math.max(0, limit - total) : null;
232
- rows.push(makeRow("free", freeTokens, true, limit));
191
+ rows.push(makeRow("free", freeTokens, limit));
233
192
 
234
193
  return {
235
194
  ready: true,
@@ -5,32 +5,7 @@ export function estimateTokens(text: string): number {
5
5
  return Math.max(0, Math.round(value.length / 4));
6
6
  }
7
7
 
8
- const MAX_JSON_CHARS = 2_000_000;
9
-
10
- /**
11
- * Estimates tokens for the JSON serialization of an arbitrary value. Circular
12
- * references and unserializable values are handled without throwing, and the
13
- * serialized prefix is capped so allocation stays bounded.
14
- */
8
+ /** Estimates tokens for the JSON serialization of a value that came from JSON. */
15
9
  export function estimateJson(value: unknown): number {
16
- let json: string;
17
- try {
18
- const seen = new WeakSet<object>();
19
- json =
20
- JSON.stringify(value, (_key, val) => {
21
- if (typeof val === "bigint") return val.toString();
22
- if (typeof val === "object" && val !== null) {
23
- if (seen.has(val)) return "[Circular]";
24
- seen.add(val);
25
- }
26
- return val;
27
- }) ?? "";
28
- } catch {
29
- try {
30
- json = String(value);
31
- } catch {
32
- json = "";
33
- }
34
- }
35
- return estimateTokens(json.length > MAX_JSON_CHARS ? json.slice(0, MAX_JSON_CHARS) : json);
10
+ return estimateTokens(JSON.stringify(value) ?? "");
36
11
  }
@@ -4,6 +4,8 @@
4
4
  * same shape `buildLines` uses in `src/quota-lines.ts`.
5
5
  */
6
6
  import type { ContextBreakdown, ContextRow, GridCell, RowKey } from "./types";
7
+ import { finiteOrNull, nonNegative } from "../normalize";
8
+ import { padLabel } from "../tui-format";
7
9
 
8
10
  /** Theme color names used by the context block. */
9
11
  export type ContextTone =
@@ -40,15 +42,6 @@ const LEGEND_PERCENT_WIDTH = 6;
40
42
  const LEGEND_GAP = " ";
41
43
  const COLUMNS_PER_LEGEND_ROW = 3;
42
44
 
43
- const ROW_LABELS: Record<RowKey, string> = {
44
- user: "User messages",
45
- agent: "Agent responses",
46
- reasoning: "Reasoning",
47
- tools: "Tool calls",
48
- system: "System & tools",
49
- free: "Free space",
50
- };
51
-
52
45
  const CATEGORY_TONES: Record<RowKey, ContextTone> = {
53
46
  user: "info",
54
47
  agent: "success",
@@ -79,15 +72,6 @@ export function formatMoney(n: number): string {
79
72
  return MONEY.format(value);
80
73
  }
81
74
 
82
- function finite(value: unknown): number | null {
83
- return typeof value === "number" && Number.isFinite(value) ? value : null;
84
- }
85
-
86
- function nonNegative(value: unknown): number | null {
87
- const n = finite(value);
88
- return n !== null && n >= 0 ? n : null;
89
- }
90
-
91
75
  function glyphFor(fill: number): string {
92
76
  if (fill <= 0) return "░░";
93
77
  if (fill <= 0.25) return "▒▒";
@@ -100,19 +84,6 @@ function toneForRowKey(key: unknown): ContextTone {
100
84
  return CATEGORY_TONES[key as RowKey] ?? "borderSubtle";
101
85
  }
102
86
 
103
- function truncateLabel(label: string, width: number): string {
104
- if (label.length <= width) return label;
105
- return label.slice(0, Math.max(0, width - 1)) + "…";
106
- }
107
-
108
- function padLabel(label: string, width: number): string {
109
- return truncateLabel(label, width).padEnd(width);
110
- }
111
-
112
- function rightAlign(value: string, width: number): string {
113
- return value.length >= width ? value : " ".repeat(width - value.length) + value;
114
- }
115
-
116
87
  /**
117
88
  * One-decimal percentage. A non-zero share that would round to `0.0%` reads as
118
89
  * "nothing at all", so anything under a tenth of a percent collapses to `<0.1%`.
@@ -144,10 +115,7 @@ function rowMap(breakdown: ContextBreakdown): Map<RowKey, ContextRow> {
144
115
  }
145
116
 
146
117
  function legendEntry(key: RowKey, row: ContextRow | undefined, systemDerived: boolean): Segment[] {
147
- const baseLabel =
148
- row !== undefined && typeof row.label === "string" && row.label !== ""
149
- ? row.label
150
- : ROW_LABELS[key];
118
+ const baseLabel = row?.label ?? key;
151
119
  const label = key === "system" && systemDerived ? `${baseLabel} (derived)` : baseLabel;
152
120
  const tokens = nonNegative(row?.tokens);
153
121
  const percent = nonNegative(row?.percent);
@@ -156,11 +124,11 @@ function legendEntry(key: RowKey, row: ContextRow | undefined, systemDerived: bo
156
124
  { text: "● ", fg: CATEGORY_TONES[key] },
157
125
  { text: padLabel(label, LEGEND_LABEL_WIDTH), fg: "text" },
158
126
  {
159
- text: rightAlign(tokens === null ? "—" : formatTokens(tokens), LEGEND_COUNT_WIDTH),
127
+ text: (tokens === null ? "—" : formatTokens(tokens)).padStart(LEGEND_COUNT_WIDTH, " "),
160
128
  fg: "text",
161
129
  },
162
130
  { text: " ", fg: "text" },
163
- { text: rightAlign(percentText, LEGEND_PERCENT_WIDTH), fg: "textMuted" },
131
+ { text: percentText.padStart(LEGEND_PERCENT_WIDTH, " "), fg: "textMuted" },
164
132
  ];
165
133
  }
166
134
 
@@ -178,7 +146,7 @@ function gridLines(breakdown: ContextBreakdown): Line[] {
178
146
  const segments: Segment[] = [];
179
147
  for (let c = 0; c < renderCols; c++) {
180
148
  const cell = cells[r * cols + c] as GridCell | undefined;
181
- const fill = cell === undefined ? 0 : Math.min(1, Math.max(0, finite(cell.fill) ?? 0));
149
+ const fill = cell === undefined ? 0 : Math.min(1, Math.max(0, finiteOrNull(cell.fill) ?? 0));
182
150
  segments.push({ text: glyphFor(fill), fg: toneForRowKey(cell?.rowKey) });
183
151
  }
184
152
  lines.push({ segments });
@@ -1,8 +1,8 @@
1
1
  import type { ContextRow, GridCell, RowKey } from "./types";
2
+ import { finiteOrNull } from "../normalize";
2
3
 
3
4
  const GRID_ROWS_DEFAULT = 6;
4
- const MIN_COLS = 8;
5
- const MAX_COLS = 60;
5
+ const GRID_COLS_DEFAULT = 8;
6
6
  const CATEGORY_ORDER: readonly RowKey[] = ["user", "agent", "reasoning", "tools", "system"];
7
7
 
8
8
  export interface BuildGridInput {
@@ -13,20 +13,6 @@ export interface BuildGridInput {
13
13
  gridRows?: number | null;
14
14
  }
15
15
 
16
- function finiteOrNull(value: unknown): number | null {
17
- return typeof value === "number" && Number.isFinite(value) ? value : null;
18
- }
19
-
20
- function clampCols(value: number | null | undefined): number {
21
- const n = finiteOrNull(value);
22
- return Math.min(MAX_COLS, Math.max(MIN_COLS, n === null ? MIN_COLS : Math.floor(n)));
23
- }
24
-
25
- function clampGridRows(value: number | null | undefined): number {
26
- const n = finiteOrNull(value);
27
- return n !== null && Math.floor(n) >= 1 ? Math.floor(n) : GRID_ROWS_DEFAULT;
28
- }
29
-
30
16
  function rowTokens(rows: readonly ContextRow[], key: RowKey): number {
31
17
  if (!Array.isArray(rows)) return 0;
32
18
  for (const row of rows) {
@@ -58,8 +44,8 @@ export function buildGrid(input: BuildGridInput): {
58
44
  rows: number;
59
45
  cells: GridCell[];
60
46
  } {
61
- const cols = clampCols(input.cols);
62
- const rows = clampGridRows(input.gridRows);
47
+ const cols = finiteOrNull(input.cols) ?? GRID_COLS_DEFAULT;
48
+ const rows = finiteOrNull(input.gridRows) ?? GRID_ROWS_DEFAULT;
63
49
  const limit = finiteOrNull(input.limit);
64
50
  if (limit === null || limit <= 0) return { cols, rows, cells: [] };
65
51
 
@@ -1,4 +1,5 @@
1
1
  import type { Model } from "@opencode-ai/sdk/v2";
2
+ import { finiteOrNull } from "../normalize";
2
3
 
3
4
  export interface Headroom {
4
5
  usable: number | null;
@@ -12,10 +13,6 @@ export interface HeadroomInput {
12
13
  compaction?: { reserved?: number } | null;
13
14
  }
14
15
 
15
- function finiteOrNull(value: unknown): number | null {
16
- return typeof value === "number" && Number.isFinite(value) ? value : null;
17
- }
18
-
19
16
  /** Compaction headroom, mirroring opencode's overflow check; nulls when unusable. */
20
17
  export function computeHeadroom(input: HeadroomInput): Headroom {
21
18
  const limit = input.limit;
@@ -7,23 +7,12 @@
7
7
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
8
8
  import { join } from "node:path";
9
9
  import type { SystemCapture } from "./types";
10
+ import { isRecord, nonNegative } from "../normalize";
10
11
  import { pluginStateDir } from "../paths";
11
12
 
12
13
  const CAPTURE_VERSION = 1;
13
- const WRITE_THROTTLE_MS = 60_000;
14
14
  const MAX_SESSION_ID_LENGTH = 200;
15
15
 
16
- /** sessionID -> epoch ms of the last successful write, for throttling. */
17
- const writeThrottle = new Map<string, number>();
18
-
19
- function isRecord(value: unknown): value is Record<string, unknown> {
20
- return typeof value === "object" && value !== null && !Array.isArray(value);
21
- }
22
-
23
- function nonNegative(value: unknown): number | null {
24
- return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
25
- }
26
-
27
16
  /** <pluginStateDir>/context/<sanitized-sessionID>.json */
28
17
  export function contextCapturePath(sessionID: string, env?: NodeJS.ProcessEnv): string {
29
18
  const raw = typeof sessionID === "string" ? sessionID : "";
@@ -35,10 +24,6 @@ export function contextCapturePath(sessionID: string, env?: NodeJS.ProcessEnv):
35
24
  * Reads and validates a capture. Returns null for a missing/unreadable file,
36
25
  * invalid JSON, an unsupported version, a session mismatch, non-string
37
26
  * provider/model, or non-finite/negative sizes and timestamps.
38
- *
39
- * Deliberately independent of the write-throttle map: the server plugin writes
40
- * and the TUI plugin reads in separate processes, so a reader can never have a
41
- * throttle entry of its own.
42
27
  */
43
28
  export async function readCapture(
44
29
  sessionID: string,
@@ -74,10 +59,7 @@ export async function readCapture(
74
59
  }
75
60
  }
76
61
 
77
- /**
78
- * Atomically persists a capture (temp file + rename), throttled to at most one
79
- * write per session per 60 s unless the provider/model changed. Never throws.
80
- */
62
+ /** Atomically persists a capture (temp file + rename). Never throws. */
81
63
  export async function writeCapture(capture: SystemCapture, env?: NodeJS.ProcessEnv): Promise<void> {
82
64
  try {
83
65
  if (!isRecord(capture)) return;
@@ -92,14 +74,6 @@ export async function writeCapture(capture: SystemCapture, env?: NodeJS.ProcessE
92
74
  const capturedAt = nonNegative(capture.capturedAt);
93
75
  if (systemChars === null || systemTokens === null || capturedAt === null) return;
94
76
 
95
- const previous = await readCapture(sessionID, env);
96
- const modelChanged =
97
- previous === null || previous.providerID !== providerID || previous.modelID !== modelID;
98
-
99
- const now = Date.now();
100
- const last = writeThrottle.get(sessionID);
101
- if (!modelChanged && typeof last === "number" && now - last < WRITE_THROTTLE_MS) return;
102
-
103
77
  const directory = join(pluginStateDir(env), "context");
104
78
  await mkdir(directory, { recursive: true });
105
79
  const file = contextCapturePath(sessionID, env);
@@ -115,7 +89,6 @@ export async function writeCapture(capture: SystemCapture, env?: NodeJS.ProcessE
115
89
  const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
116
90
  await writeFile(tmp, JSON.stringify(normalized), "utf8");
117
91
  await rename(tmp, file);
118
- writeThrottle.set(sessionID, now);
119
92
  } catch {
120
93
  // Capture writes are best-effort; a failure must never break a request.
121
94
  }
@@ -140,8 +113,3 @@ export function captureIsFresh(
140
113
  if (capturedAt === null || since === null) return false;
141
114
  return capturedAt >= since;
142
115
  }
143
-
144
- /** Clears the write-throttle map so tests can reset between cases. */
145
- export function resetCaptureThrottle(): void {
146
- writeThrottle.clear();
147
- }
@@ -7,7 +7,6 @@ export interface ContextRow {
7
7
  label: string;
8
8
  tokens: number | null;
9
9
  percent: number | null; // 0-100, null when the limit is unknown
10
- exact: boolean;
11
10
  }
12
11
 
13
12
  export interface GridCell {
package/src/fallback.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import type { AdapterResult, UsageWindow } from "./types";
3
3
  import { dbPath } from "./paths";
4
+ import { asRecord } from "./providers/http";
4
5
 
5
6
  const HOUR_MS = 60 * 60 * 1000;
6
7
  const DAY_MS = 24 * HOUR_MS;
@@ -15,12 +16,6 @@ interface SqliteDatabase {
15
16
  }
16
17
  type DatabaseSyncCtor = new (path: string, options?: { readOnly?: boolean }) => SqliteDatabase;
17
18
 
18
- function asRecord(value: unknown): Record<string, unknown> | null {
19
- return value !== null && typeof value === "object" && !Array.isArray(value)
20
- ? (value as Record<string, unknown>)
21
- : null;
22
- }
23
-
24
19
  function toNum(value: unknown): number | null {
25
20
  return typeof value === "number" && Number.isFinite(value) ? value : null;
26
21
  }
package/src/normalize.ts CHANGED
@@ -1,5 +1,3 @@
1
- import type { UsageWindow, WindowKind } from "./types";
2
-
3
1
  /** Accepts a number or a numeric string; anything else (including NaN/Infinity) yields null. */
4
2
  export function toNumber(v: unknown): number | null {
5
3
  if (typeof v === "number") {
@@ -45,31 +43,23 @@ export function pick(obj: unknown, ...keys: string[]): unknown {
45
43
  return undefined;
46
44
  }
47
45
 
48
- /** Converts a 0-1 ratio into a 0-100 percent, clamped to [0,100] and rounded to 1 decimal. */
49
- export function ratioToPercent(ratio: number): number {
50
- const rounded = Math.round(ratio * 1000) / 10;
51
- return Math.min(100, Math.max(0, rounded));
46
+ /** Finite number or null; numeric strings are not coerced. */
47
+ export function finiteOrNull(value: unknown): number | null {
48
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
49
+ }
50
+
51
+ /** Finite number >= 0 or null. */
52
+ export function nonNegative(value: unknown): number | null {
53
+ const n = finiteOrNull(value);
54
+ return n !== null && n >= 0 ? n : null;
55
+ }
56
+
57
+ /** Plain-object guard: null and arrays are not records. */
58
+ export function isRecord(value: unknown): value is Record<string, unknown> {
59
+ return typeof value === "object" && value !== null && !Array.isArray(value);
52
60
  }
53
61
 
54
- /** Builds a window from tolerant count fields, deriving usedPercent only when used and a positive limit are known. */
55
- export function windowFromCounts(
56
- kind: WindowKind,
57
- label: string,
58
- detail: { limit?: unknown; used?: unknown; remaining?: unknown; reset?: unknown },
59
- ): UsageWindow {
60
- const used = toNumber(detail.used);
61
- const limit = toNumber(detail.limit);
62
- const remaining = toNumber(detail.remaining);
63
- const usedPercent =
64
- used !== null && limit !== null && limit > 0 ? Math.round((used / limit) * 100) : null;
65
- return {
66
- kind,
67
- label,
68
- usedPercent,
69
- used,
70
- limit,
71
- remaining,
72
- resetsAt: toISODate(detail.reset),
73
- status: "ok",
74
- };
62
+ /** Tolerant string coercion; null and undefined become "". */
63
+ export function safeString(value: unknown): string {
64
+ return String(value ?? "");
75
65
  }
@@ -9,17 +9,10 @@ import type {
9
9
  import { AdapterError } from "../types";
10
10
  import { toISODate, toNumber } from "../normalize";
11
11
  import { redact } from "../auth";
12
+ import { asRecord, handleError, readBody, request } from "./http";
12
13
 
13
- const VERSION = "0.3.0";
14
- const USER_AGENT = "opencode-usage-report/" + VERSION;
15
14
  const ENDPOINT = "https://chatgpt.com/backend-api/wham/usage";
16
15
 
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
16
  /** "foo_bar-baz" -> "Foo Bar Baz". */
24
17
  function titleCase(v: string): string {
25
18
  return v
@@ -54,74 +47,6 @@ function windowKind(seconds: number | null): { kind: WindowKind; label: string }
54
47
  return { kind: "other", label: "Other" };
55
48
  }
56
49
 
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
50
  export const chatgptAdapter: ProviderAdapter = {
126
51
  id: "openai",
127
52
  displayName: "ChatGPT",
@@ -130,8 +55,29 @@ export const chatgptAdapter: ProviderAdapter = {
130
55
  throw new AdapterError("auth", "ChatGPT account id missing (re-run: opencode auth login)");
131
56
  }
132
57
 
133
- const res = await request(cred, cred.accountId, opts);
134
- if (!res.ok) return handleError(res, cred);
58
+ const res = await request(cred, opts, {
59
+ endpoint: ENDPOINT,
60
+ label: "ChatGPT request failed",
61
+ headers: { "ChatGPT-Account-Id": cred.accountId },
62
+ });
63
+ if (!res.ok) {
64
+ return handleError(res, cred, (status, { text }) => {
65
+ if (status === 401) return { kind: "auth", message: "invalid ChatGPT login" };
66
+ if (status === 403) {
67
+ return { kind: "no-plan", message: "ChatGPT is not enabled for this account" };
68
+ }
69
+ if (status === 429) {
70
+ return { kind: "rate-limited", message: "ChatGPT rate limit reached" };
71
+ }
72
+ if (status >= 500) {
73
+ return { kind: "network", message: `ChatGPT server error ${status}` };
74
+ }
75
+ return {
76
+ kind: "bad-response",
77
+ message: `ChatGPT unexpected status ${status}: ${text.slice(0, 200)}`,
78
+ };
79
+ });
80
+ }
135
81
 
136
82
  const { json } = await readBody(res);
137
83
  const body = asRecord(json);
@@ -8,9 +8,8 @@ import type {
8
8
  import { AdapterError } from "../types";
9
9
  import { toISODate, toNumber } from "../normalize";
10
10
  import { redact } from "../auth";
11
+ import { asRecord, handleError, readBody, request } from "./http";
11
12
 
12
- const VERSION = "0.3.0";
13
- const USER_AGENT = "opencode-usage-report/" + VERSION;
14
13
  const ENDPOINT = "https://api.github.com/copilot_internal/user";
15
14
 
16
15
  /** Snapshot keys in display order, paired with their human-readable labels. */
@@ -20,12 +19,6 @@ const SNAPSHOTS: Array<{ key: string; label: string }> = [
20
19
  { key: "premium_interactions", label: "Premium interactions" },
21
20
  ];
22
21
 
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
22
  /** "foo_bar-baz" -> "Foo Bar Baz". */
30
23
  function titleCase(v: string): string {
31
24
  return v
@@ -60,88 +53,37 @@ export function copilotTier(sku: unknown, plan: unknown): string {
60
53
  return "Copilot";
61
54
  }
62
55
 
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
56
  export const copilotAdapter: ProviderAdapter = {
140
57
  id: "github-copilot",
141
58
  displayName: "GitHub Copilot",
142
59
  async fetch(cred: Credential, opts: FetchOptions): Promise<AdapterResult> {
143
- const res = await request(cred, opts);
144
- if (!res.ok) return handleError(res, cred);
60
+ const res = await request(cred, opts, {
61
+ endpoint: ENDPOINT,
62
+ label: "GitHub Copilot request failed",
63
+ });
64
+ if (!res.ok) {
65
+ return handleError(res, cred, (status, { text }) => {
66
+ if (status === 401 || status === 404) {
67
+ return {
68
+ kind: "auth",
69
+ message: "GitHub Copilot login invalid or not available for this account",
70
+ };
71
+ }
72
+ if (status === 403) {
73
+ return { kind: "no-plan", message: "GitHub Copilot is not enabled for this account" };
74
+ }
75
+ if (status === 429) {
76
+ return { kind: "rate-limited", message: "GitHub Copilot rate limit reached" };
77
+ }
78
+ if (status >= 500) {
79
+ return { kind: "network", message: `GitHub Copilot server error ${status}` };
80
+ }
81
+ return {
82
+ kind: "bad-response",
83
+ message: `GitHub Copilot unexpected status ${status}: ${text.slice(0, 200)}`,
84
+ };
85
+ });
86
+ }
145
87
 
146
88
  const { json } = await readBody(res);
147
89
  const body = asRecord(json);
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Shared HTTP plumbing for provider adapters. Every adapter performs the same
3
+ * authenticated GET with one retry, so the fetch, body-reading and error-mapping
4
+ * helpers live here instead of being copy-pasted per provider.
5
+ */
6
+ import type { AdapterErrorKind, Credential, FetchOptions } from "../types";
7
+ import { AdapterError } from "../types";
8
+ import { redact } from "../auth";
9
+
10
+ /** Sent as the User-Agent on every provider request; keep in sync with package.json. */
11
+ export const USER_AGENT = "opencode-usage-report/0.4.1";
12
+
13
+ export function asRecord(v: unknown): Record<string, unknown> | null {
14
+ return v !== null && typeof v === "object" && !Array.isArray(v)
15
+ ? (v as Record<string, unknown>)
16
+ : null;
17
+ }
18
+
19
+ export interface Body {
20
+ text: string;
21
+ json: unknown;
22
+ }
23
+
24
+ /** Reads the body once as text and best-effort parses JSON, so error and success paths share it. */
25
+ export async function readBody(res: Response): Promise<Body> {
26
+ const text = await res.text().catch(() => "");
27
+ let json: unknown = null;
28
+ try {
29
+ json = JSON.parse(text);
30
+ } catch {
31
+ json = null;
32
+ }
33
+ return { text, json };
34
+ }
35
+
36
+ export interface RequestSpec {
37
+ endpoint: string;
38
+ /** Prefix of the thrown network error, e.g. "Kimi API network error". */
39
+ label: string;
40
+ headers?: Record<string, string>;
41
+ }
42
+
43
+ function attempt(cred: Credential, opts: FetchOptions, spec: RequestSpec): Promise<Response> {
44
+ return fetch(spec.endpoint, {
45
+ method: "GET",
46
+ headers: {
47
+ Authorization: "Bearer " + cred.key,
48
+ Accept: "application/json",
49
+ "User-Agent": USER_AGENT,
50
+ ...spec.headers,
51
+ },
52
+ signal: AbortSignal.timeout(opts.timeoutMs),
53
+ });
54
+ }
55
+
56
+ /** Exactly one retry on a network throw or 5xx; 4xx is returned immediately (never retried). */
57
+ export async function request(
58
+ cred: Credential,
59
+ opts: FetchOptions,
60
+ spec: RequestSpec,
61
+ ): Promise<Response> {
62
+ let lastError: unknown = null;
63
+ for (let i = 0; i < 2; i++) {
64
+ try {
65
+ const res = await attempt(cred, opts, spec);
66
+ if (res.status >= 500 && i === 0) continue;
67
+ return res;
68
+ } catch (err) {
69
+ lastError = err;
70
+ }
71
+ }
72
+ const detail = lastError instanceof Error ? lastError.message : String(lastError);
73
+ throw new AdapterError("network", redact(`${spec.label}: ${detail}`, cred.key));
74
+ }
75
+
76
+ export interface ErrorResolution {
77
+ kind: AdapterErrorKind;
78
+ message: string;
79
+ }
80
+
81
+ /** Reads the error body, asks the adapter to map it, and throws the resulting AdapterError. */
82
+ export async function handleError(
83
+ res: Response,
84
+ cred: Credential,
85
+ resolve: (status: number, body: Body) => ErrorResolution,
86
+ ): Promise<never> {
87
+ const body = await readBody(res);
88
+ const { kind, message } = resolve(res.status, body);
89
+ throw new AdapterError(kind, redact(message, cred.key));
90
+ }
@@ -4,12 +4,12 @@ import type {
4
4
  FetchOptions,
5
5
  ProviderAdapter,
6
6
  UsageWindow,
7
+ WindowKind,
7
8
  } from "../types";
8
9
  import { AdapterError } from "../types";
9
- import { pick, ratioToPercent, toISODate, toNumber, windowFromCounts } from "../normalize";
10
+ import { pick, toISODate, toNumber } from "../normalize";
10
11
  import { redact } from "../auth";
11
-
12
- const VERSION = "0.3.0";
12
+ import { handleError, readBody, request } from "./http";
13
13
 
14
14
  /**
15
15
  * Kimi Code is split by region in opencode/models.dev: the global plan lives on
@@ -23,39 +23,33 @@ export interface KimiAdapterConfig {
23
23
  baseUrl: string;
24
24
  }
25
25
 
26
- function request(cred: Credential, opts: FetchOptions, endpoint: string): Promise<Response> {
27
- return fetch(endpoint, {
28
- method: "GET",
29
- headers: {
30
- Authorization: "Bearer " + cred.key,
31
- Accept: "application/json",
32
- "User-Agent": "opencode-usage-report/" + VERSION,
33
- },
34
- signal: AbortSignal.timeout(opts.timeoutMs),
35
- });
26
+ /** Converts a 0-1 ratio into a 0-100 percent, clamped to [0,100] and rounded to 1 decimal. */
27
+ function ratioToPercent(ratio: number): number {
28
+ const rounded = Math.round(ratio * 1000) / 10;
29
+ return Math.min(100, Math.max(0, rounded));
36
30
  }
37
31
 
38
- /** One retry on network throw or 5xx; 4xx and 2xx return immediately. */
39
- async function fetchWithRetry(
40
- cred: Credential,
41
- opts: FetchOptions,
42
- endpoint: string,
43
- ): Promise<Response> {
44
- let lastError: unknown = null;
45
- for (let attempt = 0; attempt < 2; attempt++) {
46
- try {
47
- const res = await request(cred, opts, endpoint);
48
- if (res.status >= 500) {
49
- lastError = new Error(`HTTP ${res.status}`);
50
- continue;
51
- }
52
- return res;
53
- } catch (err) {
54
- lastError = err;
55
- }
56
- }
57
- const detail = lastError instanceof Error ? lastError.message : String(lastError);
58
- throw new AdapterError("network", redact(`Kimi API network error: ${detail}`, cred.key));
32
+ /** Builds a window from tolerant count fields, deriving usedPercent only when used and a positive limit are known. */
33
+ function windowFromCounts(
34
+ kind: WindowKind,
35
+ label: string,
36
+ detail: { limit?: unknown; used?: unknown; remaining?: unknown; reset?: unknown },
37
+ ): UsageWindow {
38
+ const used = toNumber(detail.used);
39
+ const limit = toNumber(detail.limit);
40
+ const remaining = toNumber(detail.remaining);
41
+ const usedPercent =
42
+ used !== null && limit !== null && limit > 0 ? Math.round((used / limit) * 100) : null;
43
+ return {
44
+ kind,
45
+ label,
46
+ usedPercent,
47
+ used,
48
+ limit,
49
+ remaining,
50
+ resetsAt: toISODate(detail.reset),
51
+ status: "ok",
52
+ };
59
53
  }
60
54
 
61
55
  function findLimitDetail(limits: unknown, durationMinutes: number): unknown {
@@ -174,39 +168,33 @@ export function createKimiAdapter(config: KimiAdapterConfig): ProviderAdapter {
174
168
  id: config.id,
175
169
  displayName: config.displayName,
176
170
  async fetch(cred: Credential, opts: FetchOptions): Promise<AdapterResult> {
177
- const res = await fetchWithRetry(cred, opts, endpoint);
178
-
179
- if (res.status === 401) {
180
- throw new AdapterError("auth", redact("invalid Kimi API key", cred.key));
181
- }
182
- if (res.status === 429) {
183
- throw new AdapterError("rate-limited", redact("Kimi API rate limit exceeded", cred.key));
184
- }
185
- if (res.status < 200 || res.status >= 300) {
186
- throw new AdapterError(
187
- "bad-response",
188
- redact(`Kimi API returned HTTP ${res.status}`, cred.key),
189
- );
190
- }
191
-
192
- let body: unknown;
193
- try {
194
- body = await res.json();
195
- } catch {
196
- throw new AdapterError(
197
- "bad-response",
198
- redact("Kimi API returned a non-JSON response", cred.key),
199
- );
171
+ const res = await request(cred, opts, {
172
+ endpoint,
173
+ label: "Kimi API network error",
174
+ });
175
+
176
+ if (!res.ok) {
177
+ return handleError(res, cred, (status) => {
178
+ if (status === 401) return { kind: "auth", message: "invalid Kimi API key" };
179
+ if (status === 429) {
180
+ return { kind: "rate-limited", message: "Kimi API rate limit exceeded" };
181
+ }
182
+ if (status >= 500) {
183
+ return { kind: "network", message: `Kimi API network error: HTTP ${status}` };
184
+ }
185
+ return { kind: "bad-response", message: `Kimi API returned HTTP ${status}` };
186
+ });
200
187
  }
201
188
 
202
- if (body === null || typeof body !== "object" || Array.isArray(body)) {
189
+ const { json } = await readBody(res);
190
+ if (json === null || typeof json !== "object" || Array.isArray(json)) {
203
191
  throw new AdapterError(
204
192
  "bad-response",
205
193
  redact("Kimi API returned an unexpected response shape", cred.key),
206
194
  );
207
195
  }
208
196
 
209
- return parse(body as Record<string, unknown>, cred);
197
+ return parse(json as Record<string, unknown>, cred);
210
198
  },
211
199
  };
212
200
  }
@@ -10,9 +10,8 @@ import type {
10
10
  import { AdapterError } from "../types";
11
11
  import { toISODate, toNumber } from "../normalize";
12
12
  import { redact } from "../auth";
13
+ import { asRecord, handleError, readBody, request } from "./http";
13
14
 
14
- const VERSION = "0.3.0";
15
- const USER_AGENT = "opencode-usage-report/" + VERSION;
16
15
  const ENDPOINT = "https://opencode.ai/zen/go/v1/usage";
17
16
 
18
17
  /** Additive options: `sessionId` is optional, so the adapter still satisfies `ProviderAdapter`. */
@@ -28,12 +27,6 @@ function mapStatus(v: unknown): WindowStatus {
28
27
  return v === "ok" || v === "rate-limited" ? v : "unknown";
29
28
  }
30
29
 
31
- function asRecord(v: unknown): Record<string, unknown> | null {
32
- return v !== null && typeof v === "object" && !Array.isArray(v)
33
- ? (v as Record<string, unknown>)
34
- : null;
35
- }
36
-
37
30
  function buildWindow(kind: WindowKind, label: string, src: unknown): UsageWindow | null {
38
31
  const record = asRecord(src);
39
32
  if (!record) return null;
@@ -49,91 +42,42 @@ function buildWindow(kind: WindowKind, label: string, src: unknown): UsageWindow
49
42
  };
50
43
  }
51
44
 
52
- /** Reads the body once as text and best-effort parses JSON, so error and success paths share it. */
53
- async function readBody(res: Response): Promise<{ text: string; json: unknown }> {
54
- const text = await res.text().catch(() => "");
55
- let json: unknown = null;
56
- try {
57
- json = JSON.parse(text);
58
- } catch {
59
- json = null;
60
- }
61
- return { text, json };
62
- }
63
-
64
- async function attempt(cred: Credential, opts: OpenCodeGoFetchOptions): Promise<Response> {
65
- return fetch(ENDPOINT, {
66
- method: "GET",
67
- headers: {
68
- Authorization: "Bearer " + cred.key,
69
- Accept: "application/json",
70
- "User-Agent": USER_AGENT,
71
- "x-opencode-session": opts.sessionId ?? "unknown",
72
- },
73
- signal: AbortSignal.timeout(opts.timeoutMs),
74
- });
75
- }
76
-
77
- /** Exactly one retry on a network throw or 5xx; 4xx is returned immediately (never retried). */
78
- async function request(cred: Credential, opts: OpenCodeGoFetchOptions): Promise<Response> {
79
- try {
80
- const first = await attempt(cred, opts);
81
- if (first.status < 500) return first;
82
- } catch {
83
- // fall through to the single retry
84
- }
85
-
86
- try {
87
- return await attempt(cred, opts);
88
- } catch (err) {
89
- throw new AdapterError(
90
- "network",
91
- redact(
92
- `OpenCode Go request failed: ${err instanceof Error ? err.message : String(err)}`,
93
- cred.key,
94
- ),
95
- );
96
- }
97
- }
98
-
99
- async function handleError(res: Response, cred: Credential): Promise<never> {
100
- const { text, json } = await readBody(res);
101
-
102
- if (res.status === 401) {
103
- throw new AdapterError("auth", redact("invalid OpenCode Go API key", cred.key));
104
- }
105
- if (res.status === 403) {
106
- const record = asRecord(json);
107
- const error = record ? asRecord(record.error) : null;
108
- if (error && error.type === "EntitlementError") {
109
- throw new AdapterError(
110
- "no-plan",
111
- redact("OpenCode Go subscription not active on this key", cred.key),
112
- );
113
- }
114
- throw new AdapterError(
115
- "network",
116
- redact("OpenCode Go request blocked (check User-Agent)", cred.key),
117
- );
118
- }
119
- if (res.status === 429) {
120
- throw new AdapterError("rate-limited", redact("OpenCode Go rate limit reached", cred.key));
121
- }
122
- if (res.status >= 500) {
123
- throw new AdapterError("network", redact(`OpenCode Go server error ${res.status}`, cred.key));
124
- }
125
- throw new AdapterError(
126
- "bad-response",
127
- redact(`OpenCode Go unexpected status ${res.status}: ${text.slice(0, 200)}`, cred.key),
128
- );
129
- }
130
-
131
45
  export const opencodeGoAdapter: ProviderAdapter = {
132
46
  id: "opencode-go",
133
47
  displayName: "OpenCode Go",
134
48
  async fetch(cred: Credential, opts: OpenCodeGoFetchOptions): Promise<AdapterResult> {
135
- const res = await request(cred, opts);
136
- if (!res.ok) return handleError(res, cred);
49
+ const res = await request(cred, opts, {
50
+ endpoint: ENDPOINT,
51
+ label: "OpenCode Go request failed",
52
+ headers: { "x-opencode-session": opts.sessionId ?? "unknown" },
53
+ });
54
+
55
+ if (!res.ok) {
56
+ return handleError(res, cred, (status, { text, json }) => {
57
+ if (status === 401) return { kind: "auth", message: "invalid OpenCode Go API key" };
58
+ if (status === 403) {
59
+ const record = asRecord(json);
60
+ const error = record ? asRecord(record.error) : null;
61
+ if (error && error.type === "EntitlementError") {
62
+ return {
63
+ kind: "no-plan",
64
+ message: "OpenCode Go subscription not active on this key",
65
+ };
66
+ }
67
+ return { kind: "network", message: "OpenCode Go request blocked (check User-Agent)" };
68
+ }
69
+ if (status === 429) {
70
+ return { kind: "rate-limited", message: "OpenCode Go rate limit reached" };
71
+ }
72
+ if (status >= 500) {
73
+ return { kind: "network", message: `OpenCode Go server error ${status}` };
74
+ }
75
+ return {
76
+ kind: "bad-response",
77
+ message: `OpenCode Go unexpected status ${status}: ${text.slice(0, 200)}`,
78
+ };
79
+ });
80
+ }
137
81
 
138
82
  const { json } = await readBody(res);
139
83
  const body = asRecord(json);
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import type { TuiPluginApi } from "@opencode-ai/plugin/tui";
8
8
  import type { ProviderReport, WindowKind } from "./types";
9
- import { bar, countdown, percentText, toneFor, windowLabel } from "./tui-format";
9
+ import { bar, clip, countdown, padLabel, percentText, toneFor, windowLabel } from "./tui-format";
10
10
 
11
11
  type Theme = TuiPluginApi["theme"]["current"];
12
12
  type ThemeColor = Theme["text"];
@@ -25,7 +25,7 @@ export const NAMED_WIDTH = 14;
25
25
  export const EXTRAS_MAX_LENGTH = 80;
26
26
 
27
27
  /** Order windows for display; unknown kinds sort last. */
28
- export const WINDOW_ORDER: Record<WindowKind, number> = {
28
+ const WINDOW_ORDER: Record<WindowKind, number> = {
29
29
  "5h": 0,
30
30
  weekly: 1,
31
31
  monthly: 2,
@@ -33,23 +33,6 @@ export const WINDOW_ORDER: Record<WindowKind, number> = {
33
33
  other: 4,
34
34
  };
35
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
36
  /** Flattens provider reports into plain, pre-colored text lines. */
54
37
  export function buildLines(
55
38
  theme: Theme,
@@ -89,11 +72,11 @@ export function buildLines(
89
72
  continue;
90
73
  }
91
74
 
92
- const windows = report.windows.toSorted((a, b) => windowRank(a.kind) - windowRank(b.kind));
75
+ const windows = report.windows.toSorted((a, b) => WINDOW_ORDER[a.kind] - WINDOW_ORDER[b.kind]);
93
76
  for (const w of windows) {
94
77
  const tone = toneFor(w.usedPercent, w.status);
95
78
  const row: Segment[] = [
96
- { text: alignLabel(windowLabel(w.kind, w.label)), fg: theme.textMuted },
79
+ { text: padLabel(windowLabel(w.kind, w.label), NAMED_WIDTH), fg: theme.textMuted },
97
80
  { text: bar(w.usedPercent, barWidth), fg: theme[tone] },
98
81
  { text: " ", fg: theme.text },
99
82
  { text: percentText(w), fg: theme.text },
package/src/render.ts CHANGED
@@ -1,39 +1,36 @@
1
1
  import type { ProviderReport, UsageWindow } from "./types";
2
-
3
- const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
4
- const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
2
+ import { formatAge } from "./tui-format";
5
3
 
6
4
  const DAY_MS = 24 * 60 * 60 * 1000;
7
5
  const LABEL_WIDTH = 8;
8
6
  const PERCENT_WIDTH = 10;
9
7
  const ABSOLUTES_WIDTH = 24;
10
8
 
9
+ const RESET_DATE = new Intl.DateTimeFormat("en-US", {
10
+ weekday: "short",
11
+ month: "short",
12
+ day: "numeric",
13
+ });
14
+
11
15
  function formatNumber(n: number): string {
12
16
  return n.toLocaleString("en-US");
13
17
  }
14
18
 
15
- /** "14:05" for a same-day reset, "Wed Sep 16" otherwise. */
19
+ /** "14:05" for a same-day reset, "Wed, Sep 16" otherwise. */
16
20
  export function formatReset(resetsAt: string, now: Date): string | null {
17
21
  const d = new Date(resetsAt);
18
22
  if (Number.isNaN(d.getTime())) return null;
19
23
  const within24h = Math.abs(d.getTime() - now.getTime()) < DAY_MS;
20
24
  if (within24h) {
21
- return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
25
+ return d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
22
26
  }
23
- return `${WEEKDAYS[d.getDay()]} ${MONTHS[d.getMonth()]} ${d.getDate()}`;
24
- }
25
-
26
- function formatAge(fetchedAt: string, now: Date): string {
27
- const then = new Date(fetchedAt).getTime();
28
- const minutes = Number.isNaN(then) ? 0 : Math.max(0, Math.floor((now.getTime() - then) / 60000));
29
- if (minutes < 60) return `${minutes}m`;
30
- return `${Math.floor(minutes / 60)}h`;
27
+ return RESET_DATE.format(d);
31
28
  }
32
29
 
33
30
  function header(report: ProviderReport, now: Date): string {
34
31
  const parts: string[] = [report.source];
35
32
  if (report.stale) {
36
- parts.push(`stale ${formatAge(report.fetchedAt, now)} old`);
33
+ parts.push(`stale ${formatAge(report.fetchedAt, now) ?? "0m"} old`);
37
34
  }
38
35
  return `${report.displayName} (${parts.join(", ")})`;
39
36
  }
package/src/tui-format.ts CHANGED
@@ -13,6 +13,18 @@ export const MIN_REFRESH_INTERVAL_SECONDS = 5;
13
13
  export const MAX_REFRESH_INTERVAL_SECONDS = 3600;
14
14
  export const DEFAULT_REFRESH_INTERVAL_SECONDS = 60;
15
15
 
16
+ /** Collapses whitespace and truncates to a single-line label. */
17
+ export function clip(value: string, max = 60): string {
18
+ const single = value.replace(/\s+/g, " ").trim();
19
+ if (single.length <= max) return single;
20
+ return single.slice(0, Math.max(0, max - 1)) + "…";
21
+ }
22
+
23
+ /** Pads a label to a fixed width, truncating with an ellipsis when long. */
24
+ export function padLabel(label: string, width: number): string {
25
+ return clip(label, width).padEnd(width);
26
+ }
27
+
16
28
  /**
17
29
  * Renders a fixed-width progress bar. `percent` is clamped to 0-100 and rounded
18
30
  * to the nearest filled cell; `null` (or any non-finite value) renders an empty
package/src/tui.tsx CHANGED
@@ -72,22 +72,19 @@ export const tui: TuiPlugin = async (api, rawOptions) => {
72
72
  };
73
73
 
74
74
  const intervalMs = Math.max(1, Math.floor(options.refreshIntervalSeconds)) * 1000;
75
- const timer = setInterval(() => {
75
+ const refresh = (): void => {
76
76
  setTick(Date.now());
77
77
  void load();
78
78
  void refreshCapture();
79
- }, intervalMs);
79
+ };
80
+ const timer = setInterval(refresh, intervalMs);
80
81
 
81
82
  api.lifecycle.onDispose(() => {
82
83
  disposed = true;
83
84
  clearInterval(timer);
84
85
  });
85
86
 
86
- const offIdle = api.event.on("session.idle", () => {
87
- setTick(Date.now());
88
- void load();
89
- void refreshCapture();
90
- });
87
+ const offIdle = api.event.on("session.idle", refresh);
91
88
  api.lifecycle.onDispose(offIdle);
92
89
 
93
90
  const openDialog = (): void => {