opencode-usage-report 0.3.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.
@@ -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);
@@ -0,0 +1,99 @@
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, clip, countdown, padLabel, 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
+ const WINDOW_ORDER: Record<WindowKind, number> = {
29
+ "5h": 0,
30
+ weekly: 1,
31
+ monthly: 2,
32
+ daily: 3,
33
+ other: 4,
34
+ };
35
+
36
+ /** Flattens provider reports into plain, pre-colored text lines. */
37
+ export function buildLines(
38
+ theme: Theme,
39
+ reports: ProviderReport[],
40
+ busy: boolean,
41
+ error: string | null,
42
+ now: Date,
43
+ barWidth: number,
44
+ ): Line[] {
45
+ const lines: Line[] = [];
46
+
47
+ if (error !== null) {
48
+ lines.push({
49
+ segments: [{ text: `api error: ${clip(error)}`, fg: theme.error }],
50
+ });
51
+ }
52
+
53
+ if (reports.length === 0 && busy) {
54
+ lines.push({
55
+ segments: [{ text: "loading…", fg: theme.textMuted }],
56
+ });
57
+ }
58
+
59
+ for (const report of reports) {
60
+ const nameColor = report.source === "error" ? theme.textMuted : theme.accent;
61
+ const segments: Segment[] = [{ text: report.displayName, fg: nameColor, bold: true }];
62
+ if (report.stale) segments.push({ text: " (stale)", fg: theme.textMuted });
63
+ if (report.source === "local-estimate") {
64
+ segments.push({ text: " (est)", fg: theme.textMuted });
65
+ }
66
+ lines.push({ segments });
67
+
68
+ if (report.source === "error") {
69
+ lines.push({
70
+ segments: [{ text: clip(report.error ?? "unknown error"), fg: theme.error }],
71
+ });
72
+ continue;
73
+ }
74
+
75
+ const windows = report.windows.toSorted((a, b) => WINDOW_ORDER[a.kind] - WINDOW_ORDER[b.kind]);
76
+ for (const w of windows) {
77
+ const tone = toneFor(w.usedPercent, w.status);
78
+ const row: Segment[] = [
79
+ { text: padLabel(windowLabel(w.kind, w.label), NAMED_WIDTH), fg: theme.textMuted },
80
+ { text: bar(w.usedPercent, barWidth), fg: theme[tone] },
81
+ { text: " ", fg: theme.text },
82
+ { text: percentText(w), fg: theme.text },
83
+ ];
84
+ const reset = countdown(w.resetsAt, now);
85
+ if (reset !== null) {
86
+ row.push({ text: ` resets in ${reset}`, fg: theme.textMuted });
87
+ }
88
+ lines.push({ segments: row });
89
+ }
90
+
91
+ for (const [key, value] of Object.entries(report.extras)) {
92
+ lines.push({
93
+ segments: [{ text: clip(`${key}: ${value}`, EXTRAS_MAX_LENGTH), fg: theme.textMuted }],
94
+ });
95
+ }
96
+ }
97
+
98
+ return lines;
99
+ }
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
@@ -7,114 +7,19 @@ import type {
7
7
  } from "@opencode-ai/plugin/tui";
8
8
  import { For, createSignal } from "solid-js";
9
9
  import { DEFAULT_OPTIONS, collectReports } from "./report";
10
- import { bar, coerceTuiOptions, countdown, percentText, toneFor, windowLabel } from "./tui-format";
11
- import type { ProviderReport, WindowKind } from "./types";
12
-
13
- type Theme = TuiPluginApi["theme"]["current"];
14
- type ThemeColor = Theme["text"];
15
-
16
- interface Segment {
17
- text: string;
18
- fg: ThemeColor;
19
- bold?: boolean;
20
- }
21
-
22
- interface Line {
23
- segments: Segment[];
24
- }
25
-
26
- const NAMED_WIDTH = 14;
27
- const EXTRAS_MAX_LENGTH = 80;
28
-
29
- /** Order windows for display; unknown kinds sort last. */
30
- const WINDOW_ORDER: Record<WindowKind, number> = {
31
- "5h": 0,
32
- weekly: 1,
33
- monthly: 2,
34
- daily: 3,
35
- other: 4,
36
- };
37
-
38
- function windowRank(kind: WindowKind): number {
39
- return WINDOW_ORDER[kind] ?? WINDOW_ORDER.other;
40
- }
41
-
42
- /** Collapses whitespace and truncates to a single-line label. */
43
- function clip(value: string, max = 60): string {
44
- const single = value.replace(/\s+/g, " ").trim();
45
- if (single.length <= max) return single;
46
- return single.slice(0, Math.max(0, max - 1)) + "…";
47
- }
48
-
49
- /** Pads a label to a fixed width, truncating (with a trailing space) when long. */
50
- function alignLabel(label: string): string {
51
- const text = label.length > NAMED_WIDTH ? label.slice(0, NAMED_WIDTH - 1) + " " : label;
52
- return text.padEnd(NAMED_WIDTH);
53
- }
54
-
55
- /** Flattens the current signal state into plain, pre-colored text lines. */
56
- function buildLines(
57
- theme: Theme,
58
- reports: ProviderReport[],
59
- busy: boolean,
60
- error: string | null,
61
- now: Date,
62
- barWidth: number,
63
- ): Line[] {
64
- const lines: Line[] = [];
65
-
66
- if (error !== null) {
67
- lines.push({
68
- segments: [{ text: `api error: ${clip(error)}`, fg: theme.error }],
69
- });
70
- }
71
-
72
- if (reports.length === 0 && busy) {
73
- lines.push({
74
- segments: [{ text: "loading…", fg: theme.textMuted }],
75
- });
76
- }
77
-
78
- for (const report of reports) {
79
- const nameColor = report.source === "error" ? theme.textMuted : theme.accent;
80
- const segments: Segment[] = [{ text: report.displayName, fg: nameColor, bold: true }];
81
- if (report.stale) segments.push({ text: " (stale)", fg: theme.textMuted });
82
- if (report.source === "local-estimate") {
83
- segments.push({ text: " (est)", fg: theme.textMuted });
84
- }
85
- lines.push({ segments });
86
-
87
- if (report.source === "error") {
88
- lines.push({
89
- segments: [{ text: clip(report.error ?? "unknown error"), fg: theme.error }],
90
- });
91
- continue;
92
- }
93
-
94
- const windows = report.windows.toSorted((a, b) => windowRank(a.kind) - windowRank(b.kind));
95
- for (const w of windows) {
96
- const tone = toneFor(w.usedPercent, w.status);
97
- const row: Segment[] = [
98
- { text: alignLabel(windowLabel(w.kind, w.label)), fg: theme.textMuted },
99
- { text: bar(w.usedPercent, barWidth), fg: theme[tone] },
100
- { text: " ", fg: theme.text },
101
- { text: percentText(w), fg: theme.text },
102
- ];
103
- const reset = countdown(w.resetsAt, now);
104
- if (reset !== null) {
105
- row.push({ text: ` resets in ${reset}`, fg: theme.textMuted });
106
- }
107
- lines.push({ segments: row });
108
- }
109
-
110
- for (const [key, value] of Object.entries(report.extras)) {
111
- lines.push({
112
- segments: [{ text: clip(`${key}: ${value}`, EXTRAS_MAX_LENGTH), fg: theme.textMuted }],
113
- });
114
- }
115
- }
116
-
117
- return lines;
10
+ import { coerceTuiOptions } from "./tui-format";
11
+ import { buildLines } from "./quota-lines";
12
+ import { UsageDialog } from "./context/dialog";
13
+ import { readCapture } from "./context/system";
14
+ import type { SystemCapture } from "./context/types";
15
+ import type { ProviderReport } from "./types";
16
+
17
+ /** Resolves the session the `/usage` dialog should describe, if any. */
18
+ function activeSessionID(api: TuiPluginApi): string | null {
19
+ const route = api.route.current;
20
+ if (route.name !== "session") return null;
21
+ const id: unknown = route.params?.sessionID;
22
+ return typeof id === "string" ? id : null;
118
23
  }
119
24
 
120
25
  export const tui: TuiPlugin = async (api, rawOptions) => {
@@ -124,11 +29,13 @@ export const tui: TuiPlugin = async (api, rawOptions) => {
124
29
  const [inFlight, setInFlight] = createSignal(0);
125
30
  const [error, setError] = createSignal<string | null>(null);
126
31
  const [tick, setTick] = createSignal(Date.now());
32
+ const [capture, setCapture] = createSignal<SystemCapture | null>(null);
33
+ const [scope, setScope] = createSignal(0);
34
+ const [showJson, setShowJson] = createSignal(false);
127
35
 
128
36
  const busy = (): boolean => inFlight() > 0;
129
37
 
130
38
  let disposed = false;
131
-
132
39
  const load = async (refresh = false): Promise<void> => {
133
40
  if (disposed) return;
134
41
  setInFlight((n) => n + 1);
@@ -153,24 +60,58 @@ export const tui: TuiPlugin = async (api, rawOptions) => {
153
60
  }
154
61
  };
155
62
 
156
- // Single timer: refreshes data and advances countdowns.
63
+ // Single timer: refreshes data, advances countdowns, reloads the capture.
64
+ const refreshCapture = async (): Promise<void> => {
65
+ if (disposed) return;
66
+ const sessionID = activeSessionID(api);
67
+ if (sessionID === null) {
68
+ setCapture(null);
69
+ return;
70
+ }
71
+ setCapture(await readCapture(sessionID));
72
+ };
73
+
157
74
  const intervalMs = Math.max(1, Math.floor(options.refreshIntervalSeconds)) * 1000;
158
- const timer = setInterval(() => {
75
+ const refresh = (): void => {
159
76
  setTick(Date.now());
160
77
  void load();
161
- }, intervalMs);
78
+ void refreshCapture();
79
+ };
80
+ const timer = setInterval(refresh, intervalMs);
162
81
 
163
82
  api.lifecycle.onDispose(() => {
164
83
  disposed = true;
165
84
  clearInterval(timer);
166
85
  });
167
86
 
168
- const offIdle = api.event.on("session.idle", () => {
169
- setTick(Date.now());
170
- void load();
171
- });
87
+ const offIdle = api.event.on("session.idle", refresh);
172
88
  api.lifecycle.onDispose(offIdle);
173
89
 
90
+ const openDialog = (): void => {
91
+ const sessionID = activeSessionID(api);
92
+ void refreshCapture().finally(() => {
93
+ if (disposed) return;
94
+ setScope(0);
95
+ setShowJson(false);
96
+ api.ui.dialog.replace(() => (
97
+ <UsageDialog
98
+ api={api}
99
+ sessionID={sessionID}
100
+ reports={reports}
101
+ busy={busy}
102
+ error={error}
103
+ now={() => new Date(tick())}
104
+ system={capture}
105
+ scope={scope}
106
+ showJson={showJson}
107
+ barWidth={options.barWidth}
108
+ />
109
+ ));
110
+ // Must follow replace(): the stack resets the size to "medium".
111
+ api.ui.dialog.setSize("xlarge");
112
+ });
113
+ };
114
+
174
115
  api.lifecycle.onDispose(
175
116
  api.keymap.registerLayer({
176
117
  commands: [
@@ -183,11 +124,47 @@ export const tui: TuiPlugin = async (api, rawOptions) => {
183
124
  void load(true);
184
125
  },
185
126
  },
127
+ {
128
+ name: "usage.open",
129
+ title: "Usage: open report",
130
+ description: "Context usage and provider quota windows",
131
+ category: "Plugin",
132
+ namespace: "palette",
133
+ slashName: "usage",
134
+ run: openDialog,
135
+ },
186
136
  ],
187
137
  bindings: [{ key: "ctrl+shift+u", cmd: "usage.refresh", desc: "Refresh usage panel" }],
188
138
  }),
189
139
  );
190
140
 
141
+ // Scoped to the dialog's "modal" mode so these keys only fire while it is open.
142
+ api.lifecycle.onDispose(
143
+ api.keymap.registerLayer({
144
+ mode: "modal",
145
+ commands: [
146
+ { name: "usage.dialog.refresh", run: () => void load(true) },
147
+ {
148
+ name: "usage.dialog.scope",
149
+ run: () => {
150
+ setScope((n) => (n + 1) % Math.max(1, reports().length + 1));
151
+ },
152
+ },
153
+ {
154
+ name: "usage.dialog.json",
155
+ run: () => {
156
+ setShowJson((v) => !v);
157
+ },
158
+ },
159
+ ],
160
+ bindings: [
161
+ { key: "r", cmd: "usage.dialog.refresh", desc: "Refresh usage report" },
162
+ { key: "tab", cmd: "usage.dialog.scope", desc: "Cycle provider scope" },
163
+ { key: "j", cmd: "usage.dialog.json", desc: "Toggle raw JSON" },
164
+ ],
165
+ }),
166
+ );
167
+
191
168
  const slot: TuiSlotPlugin = {
192
169
  order: 150,
193
170
  slots: {