@a-r-m-i-n/opencode-openai-usage 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Armin Vieweg
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,116 @@
1
+ # @a-r-m-i-n/opencode-openai-usage
2
+
3
+ OpenCode plugin that reads your ChatGPT account usage and shows it in the TUI sidebar and command palette.
4
+
5
+ The sidebar panel starts expanded and can be collapsed.
6
+
7
+ ![OpenAI Usage sidebar showing active usage windows](docs/screenshots/sidebar-expanded.png)
8
+
9
+ ## What It Does
10
+
11
+ - fetches usage data from OpenAI Backend API
12
+ - reads the OpenCode OpenAI OAuth token from OpenCode's local state
13
+ - shows usage windows in the collapsible sidebar
14
+ - adds `OpenAI Usage`, `OpenAI Usage: Toggle Sidebar Display`, and `OpenAI Usage: Show/Hide Sidebar Section` commands to the TUI command list
15
+
16
+ ## Screenshots
17
+
18
+ ### Command Palette
19
+
20
+ Shows the OpenAI-specific commands added by the plugin.
21
+
22
+ - `OpenAI Usage`: opens the detailed usage dialog
23
+ - `OpenAI Usage: Toggle Sidebar Display`: switches the sidebar between used quota and remaining quota
24
+ - `OpenAI Usage: Show Sidebar Section` / `OpenAI Usage: Hide Sidebar Section`: toggles the sidebar section and persists the setting across restarts
25
+
26
+ <img src="docs/screenshots/command-palette-openai.png" alt="Command palette with OpenAI Usage commands" width="400" />
27
+
28
+ ### Usage Dialog
29
+
30
+ Shows the detailed usage summary, including the current usage windows and account details returned by the upstream endpoint.
31
+
32
+ <img src="docs/screenshots/openai-usage-dialog.png" alt="OpenAI Usage dialog" width="400" />
33
+
34
+ ### Sidebar Display Modes
35
+
36
+ The sidebar can show either used quota or remaining quota.
37
+
38
+ Used mode:
39
+
40
+ <img src="docs/screenshots/mode-used.png" alt="Sidebar showing used quota" width="350" />
41
+
42
+ Left mode:
43
+
44
+ <img src="docs/screenshots/mode-left.png" alt="Sidebar showing remaining quota" width="350" />
45
+
46
+ ## Requirements
47
+
48
+ - OpenCode with an OpenAI account connected via OAuth
49
+ - Node.js and npm available for installation/build steps
50
+
51
+ ## Install From npm
52
+
53
+ ```bash
54
+ npm install @a-r-m-i-n/opencode-openai-usage
55
+ ```
56
+
57
+ Add the runtime plugin in `opencode.json`:
58
+
59
+ ```json
60
+ {
61
+ "$schema": "https://opencode.ai/config.json",
62
+ "plugin": ["@a-r-m-i-n/opencode-openai-usage"]
63
+ }
64
+ ```
65
+
66
+ Add the TUI plugin in `tui.json`:
67
+
68
+ ```json
69
+ {
70
+ "$schema": "https://opencode.ai/tui.json",
71
+ "plugin": [["@a-r-m-i-n/opencode-openai-usage/tui", { "invert": false }]]
72
+ }
73
+ ```
74
+
75
+ After changing config, quit and restart OpenCode.
76
+
77
+ ## TUI Options
78
+
79
+ The TUI plugin accepts options in `tui.json`:
80
+
81
+ ```json
82
+ {
83
+ "$schema": "https://opencode.ai/tui.json",
84
+ "plugin": [["@a-r-m-i-n/opencode-openai-usage/tui", { "invert": true }]]
85
+ }
86
+ ```
87
+
88
+ | Option | Default | Description |
89
+ |---|---|---|
90
+ | `invert` | `false` | Default sidebar mode. `false` shows used quota like `60% used`; `true` shows remaining quota like `40% left`. After the `OpenAI Usage: Toggle Sidebar Display` command is used, the last selected mode is persisted across restarts. |
91
+
92
+ ## Project Structure
93
+
94
+ ```text
95
+ src/
96
+ index.ts
97
+ tui.tsx
98
+ lib/openai-usage.ts
99
+ .opencode/
100
+ plugins/openai-usage.ts
101
+ tui/openai-usage.tsx
102
+ tui.json
103
+ ```
104
+
105
+ ## Notes And Limitations
106
+
107
+ - The plugin depends on OpenCode's locally stored `auth.json` format.
108
+ - The usage endpoint is an internal ChatGPT web endpoint and may change without notice.
109
+ - The plugin caches usage data locally in OpenCode's state directory.
110
+ - The plugin hides its sidebar panel when OpenCode has no OpenAI OAuth account configured.
111
+ - Usage-specific commands are hidden when OpenCode has no OpenAI OAuth account configured, but the sidebar visibility toggle remains available.
112
+ - The command summary currently includes the account email returned by the upstream endpoint.
113
+
114
+ ## License
115
+
116
+ MIT
@@ -0,0 +1,396 @@
1
+ // src/lib/openai-usage.ts
2
+ import { mkdir, readFile, writeFile } from "fs/promises";
3
+ import { homedir } from "os";
4
+ import { dirname, join } from "path";
5
+ var USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
6
+ var CACHE_FILE = "storage/openai-usage-cache.json";
7
+ var AUTH_FILE = "auth.json";
8
+ var FETCH_TIMEOUT_MS = 15e3;
9
+ var COMMAND_SUMMARY_DIVIDER = "-".repeat(56);
10
+ var DEFAULT_USAGE_STATE = {
11
+ primary: null,
12
+ secondary: null,
13
+ fetchedAt: null,
14
+ error: null,
15
+ loading: true,
16
+ configured: null,
17
+ rateLimitReachedType: null,
18
+ accountId: null,
19
+ userId: null,
20
+ email: null,
21
+ planType: null
22
+ };
23
+ function getOpenCodeStateDir() {
24
+ const xdgDataHome = process.env.XDG_DATA_HOME;
25
+ if (xdgDataHome) {
26
+ return join(xdgDataHome, "opencode");
27
+ }
28
+ if (process.platform === "darwin") {
29
+ return join(homedir(), "Library", "Application Support", "opencode");
30
+ }
31
+ if (process.platform === "win32") {
32
+ const appData = process.env.APPDATA;
33
+ if (appData) {
34
+ return join(appData, "opencode");
35
+ }
36
+ }
37
+ return join(homedir(), ".local", "share", "opencode");
38
+ }
39
+ function getUsageCachePath(stateDir) {
40
+ return join(stateDir, CACHE_FILE);
41
+ }
42
+ async function readUsageState(stateDir) {
43
+ const cachePath = getUsageCachePath(stateDir);
44
+ try {
45
+ const raw = await readFile(cachePath, "utf8");
46
+ return normalizeStoredState(JSON.parse(raw));
47
+ } catch {
48
+ return { ...DEFAULT_USAGE_STATE };
49
+ }
50
+ }
51
+ async function writeUsageState(stateDir, state) {
52
+ const cachePath = getUsageCachePath(stateDir);
53
+ await mkdir(dirname(cachePath), { recursive: true });
54
+ await writeFile(cachePath, JSON.stringify(state, null, 2));
55
+ }
56
+ async function buildUsageState(stateDir) {
57
+ let accessToken;
58
+ try {
59
+ const auth = await readAuthFile(stateDir);
60
+ accessToken = extractAccessToken(auth);
61
+ } catch (error) {
62
+ if (isNotConfiguredError(error)) {
63
+ return buildUnconfiguredState();
64
+ }
65
+ throw error;
66
+ }
67
+ const data = await fetchUsagePayload(accessToken);
68
+ const rateLimits = extractRateLimitsPayload(data);
69
+ const secondaryPayload = rateLimits.secondary ?? rateLimits.secondary_window ?? null;
70
+ const rateLimitReachedType = normalizeOptionalString(
71
+ rateLimits.rateLimitReachedType ?? rateLimits.rate_limit_reached_type ?? null
72
+ );
73
+ return {
74
+ primary: normalizeWindow(rateLimits.primary ?? rateLimits.primary_window ?? null, "primary"),
75
+ secondary: secondaryPayload === null ? null : normalizeWindow(secondaryPayload, "secondary"),
76
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
77
+ error: null,
78
+ loading: false,
79
+ configured: true,
80
+ rateLimitReachedType,
81
+ email: normalizeOptionalString(data.email ?? rateLimits.email ?? null),
82
+ accountId: normalizeOptionalString(
83
+ data.account_id ?? data.accountId ?? rateLimits.account_id ?? rateLimits.accountId ?? null
84
+ ),
85
+ userId: normalizeOptionalString(
86
+ data.user_id ?? data.userId ?? rateLimits.user_id ?? rateLimits.userId ?? null
87
+ ),
88
+ planType: normalizeOptionalString(
89
+ data.plan_type ?? data.planType ?? rateLimits.plan_type ?? rateLimits.planType ?? null
90
+ )
91
+ };
92
+ }
93
+ function buildFailureState(previous, error) {
94
+ return {
95
+ ...previous,
96
+ loading: false,
97
+ error: formatError(error)
98
+ };
99
+ }
100
+ function formatCommandSummary(state, packageName, pluginVersion, githubUrl) {
101
+ const lines = [];
102
+ let hasUsageDetails = false;
103
+ if (state.error) {
104
+ lines.push("Status: unavailable");
105
+ lines.push(`Error: ${state.error}`);
106
+ hasUsageDetails = true;
107
+ }
108
+ hasUsageDetails = appendWindowLines(lines, "Primary", state.primary) || hasUsageDetails;
109
+ if (state.primary && state.secondary) {
110
+ lines.push("");
111
+ }
112
+ hasUsageDetails = appendWindowLines(lines, "Secondary", state.secondary) || hasUsageDetails;
113
+ if (state.rateLimitReachedType) {
114
+ lines.push(`Rate limit reached type: ${state.rateLimitReachedType}`);
115
+ hasUsageDetails = true;
116
+ }
117
+ if (state.planType) {
118
+ lines.push("");
119
+ lines.push(`Plan: ${state.planType}`);
120
+ hasUsageDetails = true;
121
+ }
122
+ if (state.email) {
123
+ lines.push(`Account email: ${state.email}`);
124
+ hasUsageDetails = true;
125
+ }
126
+ if (state.fetchedAt) {
127
+ lines.push(`Fetched at: ${formatTimestamp(state.fetchedAt)}`);
128
+ hasUsageDetails = true;
129
+ }
130
+ if (!hasUsageDetails) {
131
+ lines.push("Status: unavailable");
132
+ lines.push("Error: Usage data has not been fetched yet.");
133
+ }
134
+ const pluginLines = [];
135
+ if (packageName) {
136
+ pluginLines.push(`Plugin: ${packageName}`);
137
+ }
138
+ if (pluginVersion) {
139
+ pluginLines.push(`Version: ${pluginVersion}`);
140
+ }
141
+ if (githubUrl) {
142
+ if (pluginLines.length > 0) {
143
+ pluginLines.push("");
144
+ }
145
+ pluginLines.push(githubUrl);
146
+ }
147
+ if (pluginLines.length > 0) {
148
+ lines.push("");
149
+ lines.push(COMMAND_SUMMARY_DIVIDER);
150
+ lines.push("");
151
+ lines.push(...pluginLines);
152
+ }
153
+ return lines.join("\n");
154
+ }
155
+ function getUsageDisplay(usedPercent, invert) {
156
+ const clampedUsedPercent = Math.max(0, Math.min(100, usedPercent));
157
+ if (invert) {
158
+ return {
159
+ percent: Math.max(0, 100 - clampedUsedPercent),
160
+ label: "left"
161
+ };
162
+ }
163
+ return {
164
+ percent: clampedUsedPercent,
165
+ label: "used"
166
+ };
167
+ }
168
+ function appendWindowLines(lines, label, window) {
169
+ if (!window) {
170
+ return false;
171
+ }
172
+ const leftPercent = Math.max(0, 100 - window.usedPercent);
173
+ lines.push(`${label} window`);
174
+ lines.push(
175
+ `${formatWindowLabel(window.windowDurationMins)}: ${formatPercent(window.usedPercent)} used, ${formatPercent(leftPercent)} left, resets ${formatTimestamp(window.resetsAt)}`
176
+ );
177
+ return true;
178
+ }
179
+ async function readAuthFile(stateDir) {
180
+ const authPath = join(stateDir, AUTH_FILE);
181
+ let raw;
182
+ try {
183
+ raw = await readFile(authPath, "utf8");
184
+ } catch (error) {
185
+ if (isMissingFileError(error)) {
186
+ throw new NotConfiguredError("OpenCode has no ChatGPT auth configured.");
187
+ }
188
+ throw error;
189
+ }
190
+ return JSON.parse(raw);
191
+ }
192
+ function extractAccessToken(auth) {
193
+ const provider = auth.openai;
194
+ if (!isRecord(provider)) {
195
+ throw new NotConfiguredError("OpenCode has no ChatGPT auth configured.");
196
+ }
197
+ const oauth = provider;
198
+ if (oauth.type !== "oauth") {
199
+ throw new NotConfiguredError("OpenCode has no ChatGPT auth configured.");
200
+ }
201
+ if (typeof oauth.access !== "string" || oauth.access.length === 0) {
202
+ throw new NotConfiguredError("OpenCode has no ChatGPT auth configured.");
203
+ }
204
+ return oauth.access;
205
+ }
206
+ function buildUnconfiguredState() {
207
+ return {
208
+ ...DEFAULT_USAGE_STATE,
209
+ loading: false,
210
+ configured: false
211
+ };
212
+ }
213
+ async function fetchUsagePayload(accessToken) {
214
+ const controller = new AbortController();
215
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
216
+ let response;
217
+ try {
218
+ response = await fetch(USAGE_URL, {
219
+ headers: {
220
+ Authorization: `Bearer ${accessToken}`,
221
+ Accept: "application/json"
222
+ },
223
+ signal: controller.signal
224
+ });
225
+ } catch (error) {
226
+ if (error instanceof Error && error.name === "AbortError") {
227
+ throw new Error(`Usage request timed out after ${Math.floor(FETCH_TIMEOUT_MS / 1e3)}s.`);
228
+ }
229
+ throw error;
230
+ } finally {
231
+ clearTimeout(timeout);
232
+ }
233
+ if (!response.ok) {
234
+ let details = "";
235
+ try {
236
+ details = await response.text();
237
+ } catch {
238
+ details = "";
239
+ }
240
+ const body = details.trim();
241
+ const suffix = body ? ` - ${body.slice(0, 300)}` : "";
242
+ throw new Error(`Usage request failed: HTTP ${response.status}${suffix}`);
243
+ }
244
+ const data = await response.json();
245
+ if (!isRecord(data)) {
246
+ throw new Error("Usage response is not a JSON object.");
247
+ }
248
+ return data;
249
+ }
250
+ function extractRateLimitsPayload(data) {
251
+ const rateLimits = data.rateLimits ?? data.rate_limits ?? data.rate_limit ?? null;
252
+ return isRecord(rateLimits) ? rateLimits : data;
253
+ }
254
+ function normalizeWindow(window, name) {
255
+ if (!isRecord(window)) {
256
+ throw new Error(`Usage response is missing ${name} window data.`);
257
+ }
258
+ let windowDurationMins = toNumber(window.windowDurationMins ?? window.window_duration_mins ?? null);
259
+ const windowDurationSeconds = toNumber(window.limit_window_seconds ?? null);
260
+ const usedPercent = toNumber(window.usedPercent ?? window.used_percent ?? null);
261
+ const resetsAt = window.resetsAt ?? window.resetAt ?? window.reset_at ?? null;
262
+ if (windowDurationMins === null && windowDurationSeconds !== null) {
263
+ windowDurationMins = Math.ceil(windowDurationSeconds / 60);
264
+ }
265
+ if (usedPercent === null || windowDurationMins === null || !isValidResetValue(resetsAt)) {
266
+ throw new Error(`Usage response contains incomplete ${name} window data.`);
267
+ }
268
+ return {
269
+ usedPercent,
270
+ windowDurationMins,
271
+ resetsAt: normalizeResetValue(resetsAt)
272
+ };
273
+ }
274
+ function normalizeStoredState(input) {
275
+ return {
276
+ primary: normalizeStoredWindow(input.primary),
277
+ secondary: normalizeStoredWindow(input.secondary),
278
+ fetchedAt: normalizeOptionalString(input.fetchedAt ?? null),
279
+ error: normalizeOptionalString(input.error ?? null),
280
+ loading: input.loading === true,
281
+ configured: typeof input.configured === "boolean" ? input.configured : null,
282
+ rateLimitReachedType: normalizeOptionalString(input.rateLimitReachedType ?? null),
283
+ accountId: normalizeOptionalString(input.accountId ?? null),
284
+ userId: normalizeOptionalString(input.userId ?? null),
285
+ email: normalizeOptionalString(input.email ?? null),
286
+ planType: normalizeOptionalString(input.planType ?? null)
287
+ };
288
+ }
289
+ function normalizeStoredWindow(window) {
290
+ if (!isRecord(window)) {
291
+ return null;
292
+ }
293
+ const usedPercent = toNumber(window.usedPercent ?? null);
294
+ const windowDurationMins = toNumber(window.windowDurationMins ?? null);
295
+ const resetsAt = normalizeOptionalString(window.resetsAt ?? null);
296
+ if (usedPercent === null || windowDurationMins === null || !resetsAt) {
297
+ return null;
298
+ }
299
+ return { usedPercent, windowDurationMins, resetsAt };
300
+ }
301
+ function normalizeOptionalString(value) {
302
+ return typeof value === "string" && value.length > 0 ? value : null;
303
+ }
304
+ function formatPercent(value) {
305
+ return Number.isInteger(value) ? `${value}%` : `${value.toFixed(1)}%`;
306
+ }
307
+ function formatWindowLabel(windowDurationMins) {
308
+ if (windowDurationMins % (60 * 24) === 0) {
309
+ return `${windowDurationMins / (60 * 24)}d`;
310
+ }
311
+ if (windowDurationMins % 60 === 0) {
312
+ return `${windowDurationMins / 60}h`;
313
+ }
314
+ return `${windowDurationMins}m`;
315
+ }
316
+ function formatTimestamp(value) {
317
+ const date = new Date(value);
318
+ if (Number.isNaN(date.getTime())) {
319
+ return value;
320
+ }
321
+ const yyyy = `${date.getFullYear()}`;
322
+ const mm = `${date.getMonth() + 1}`.padStart(2, "0");
323
+ const dd = `${date.getDate()}`.padStart(2, "0");
324
+ const hh = `${date.getHours()}`.padStart(2, "0");
325
+ const mi = `${date.getMinutes()}`.padStart(2, "0");
326
+ return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
327
+ }
328
+ function formatRelativeDuration(value) {
329
+ const timestamp = Date.parse(value);
330
+ if (Number.isNaN(timestamp)) {
331
+ return formatTimestamp(value);
332
+ }
333
+ return formatDurationParts(Math.max(0, timestamp - Date.now()));
334
+ }
335
+ function formatDurationParts(diffMs) {
336
+ const totalMinutes = Math.ceil(diffMs / 6e4);
337
+ const days = Math.floor(totalMinutes / (60 * 24));
338
+ const hours = Math.floor(totalMinutes % (60 * 24) / 60);
339
+ const minutes = totalMinutes % 60;
340
+ if (days > 0) {
341
+ return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
342
+ }
343
+ if (hours > 0) {
344
+ return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
345
+ }
346
+ return `${Math.max(1, minutes)}m`;
347
+ }
348
+ function isValidResetValue(value) {
349
+ return typeof value === "string" && value.length > 0 || typeof value === "number";
350
+ }
351
+ function normalizeResetValue(value) {
352
+ return typeof value === "number" ? new Date(value * 1e3).toISOString() : value;
353
+ }
354
+ function toNumber(value) {
355
+ if (typeof value === "number" && Number.isFinite(value)) {
356
+ return value;
357
+ }
358
+ if (typeof value === "string" && value.trim() !== "") {
359
+ const parsed = Number(value);
360
+ return Number.isFinite(parsed) ? parsed : null;
361
+ }
362
+ return null;
363
+ }
364
+ function isRecord(value) {
365
+ return typeof value === "object" && value !== null && !Array.isArray(value);
366
+ }
367
+ function isMissingFileError(error) {
368
+ return isRecord(error) && error.code === "ENOENT";
369
+ }
370
+ function isNotConfiguredError(error) {
371
+ return error instanceof NotConfiguredError;
372
+ }
373
+ var NotConfiguredError = class extends Error {
374
+ constructor(message) {
375
+ super(message);
376
+ this.name = "NotConfiguredError";
377
+ }
378
+ };
379
+ function formatError(error) {
380
+ if (error instanceof Error && error.message) {
381
+ return error.message;
382
+ }
383
+ return "Unknown usage error.";
384
+ }
385
+
386
+ export {
387
+ getOpenCodeStateDir,
388
+ readUsageState,
389
+ writeUsageState,
390
+ buildUsageState,
391
+ buildFailureState,
392
+ formatCommandSummary,
393
+ getUsageDisplay,
394
+ formatWindowLabel,
395
+ formatRelativeDuration
396
+ };
package/dist/index.js ADDED
@@ -0,0 +1,73 @@
1
+ import {
2
+ buildFailureState,
3
+ buildUsageState,
4
+ getOpenCodeStateDir,
5
+ readUsageState,
6
+ writeUsageState
7
+ } from "./chunk-SR64YZVV.js";
8
+
9
+ // src/index.ts
10
+ var id = "openai-usage";
11
+ var REFRESH_MS = 6e4;
12
+ var MAX_BACKOFF_MS = 15 * 6e4;
13
+ function shouldRefreshOnEvent(type) {
14
+ return type === "account.added" || type === "account.removed" || type === "account.switched" || type === "session.next.prompted" || type === "command.executed";
15
+ }
16
+ var module = {
17
+ id,
18
+ server: async () => {
19
+ const stateDir = getOpenCodeStateDir();
20
+ let currentState = await readUsageState(stateDir);
21
+ let refreshInFlight = null;
22
+ let timer;
23
+ let nextDelay = REFRESH_MS;
24
+ const persistState = async (state = currentState) => {
25
+ currentState = state;
26
+ await writeUsageState(stateDir, currentState);
27
+ };
28
+ const refresh = async (_force = false) => {
29
+ if (refreshInFlight) {
30
+ return refreshInFlight;
31
+ }
32
+ refreshInFlight = (async () => {
33
+ try {
34
+ if (!currentState.fetchedAt) {
35
+ await persistState({ ...currentState, loading: true, error: null });
36
+ }
37
+ const nextState = await buildUsageState(stateDir);
38
+ nextDelay = REFRESH_MS;
39
+ await persistState(nextState);
40
+ } catch (error) {
41
+ nextDelay = Math.min(nextDelay * 2, MAX_BACKOFF_MS);
42
+ await persistState(buildFailureState(currentState, error));
43
+ } finally {
44
+ refreshInFlight = null;
45
+ schedule(nextDelay);
46
+ }
47
+ })();
48
+ return refreshInFlight;
49
+ };
50
+ const schedule = (delay) => {
51
+ if (timer) {
52
+ clearTimeout(timer);
53
+ }
54
+ timer = setTimeout(() => {
55
+ void refresh(true);
56
+ }, delay);
57
+ };
58
+ void refresh(true);
59
+ return {
60
+ event: async ({ event }) => {
61
+ if (!shouldRefreshOnEvent(event.type)) {
62
+ return;
63
+ }
64
+ await refresh(true);
65
+ }
66
+ };
67
+ }
68
+ };
69
+ var index_default = module;
70
+ export {
71
+ index_default as default,
72
+ id
73
+ };
package/dist/tui.js ADDED
@@ -0,0 +1,245 @@
1
+ import {
2
+ formatCommandSummary,
3
+ formatRelativeDuration,
4
+ formatWindowLabel,
5
+ getOpenCodeStateDir,
6
+ getUsageDisplay,
7
+ readUsageState
8
+ } from "./chunk-SR64YZVV.js";
9
+
10
+ // src/tui.tsx
11
+ import { createRequire } from "module";
12
+ import { createTextAttributes } from "@opentui/core";
13
+ import { createSignal } from "solid-js";
14
+ import { jsx, jsxs } from "@opentui/solid/jsx-runtime";
15
+ var id = "openai-usage-tui";
16
+ var CACHE_SYNC_MS = 5e3;
17
+ var DIM_ATTRIBUTES = createTextAttributes({ dim: true });
18
+ var BAR_WIDTH = 20;
19
+ var BAR_EMPTY_COLOR = "#6b7280";
20
+ var BAR_LABEL_DARK_COLOR = "#111827";
21
+ var BAR_LABEL_LIGHT_COLOR = "#f9fafb";
22
+ var SIDEBAR_VERSION_COLOR = "#9ca3af";
23
+ var SIDEBAR_INVERT_KV_KEY = "openai-usage.sidebar.invert";
24
+ var SIDEBAR_VISIBLE_KV_KEY = "openai-usage.sidebar.visible";
25
+ var require2 = createRequire(import.meta.url);
26
+ var PLUGIN_MANIFEST = readPluginManifest();
27
+ var PACKAGE_NAME = PLUGIN_MANIFEST.name;
28
+ var PLUGIN_VERSION = PLUGIN_MANIFEST.version;
29
+ var PACKAGE_HOMEPAGE = PLUGIN_MANIFEST.homepage;
30
+ function readPluginManifest() {
31
+ try {
32
+ const manifest = require2("../package.json");
33
+ const homepage = typeof manifest.homepage === "string" && manifest.homepage.length > 0 ? manifest.homepage.replace(/#readme$/i, "") : null;
34
+ return {
35
+ name: typeof manifest.name === "string" && manifest.name.length > 0 ? manifest.name : null,
36
+ version: typeof manifest.version === "string" && manifest.version.length > 0 ? manifest.version : null,
37
+ homepage
38
+ };
39
+ } catch {
40
+ return { name: null, version: null, homepage: null };
41
+ }
42
+ }
43
+ function formatPercent(percent) {
44
+ return Number.isInteger(percent) ? `${percent}%` : `${percent.toFixed(1)}%`;
45
+ }
46
+ function getBarFillColor(percent, labelSuffix) {
47
+ if (labelSuffix === "used") {
48
+ if (percent >= 50) {
49
+ return "#ef4444";
50
+ }
51
+ if (percent >= 20) {
52
+ return "#eab308";
53
+ }
54
+ return "#22c55e";
55
+ }
56
+ if (percent >= 50) {
57
+ return "#22c55e";
58
+ }
59
+ if (percent >= 20) {
60
+ return "#eab308";
61
+ }
62
+ return "#ef4444";
63
+ }
64
+ function getBarSegments(percent, width) {
65
+ const clampedPercent = Math.max(0, Math.min(100, percent));
66
+ const filled = Math.round(clampedPercent / 100 * width);
67
+ return {
68
+ filled: Math.max(0, Math.min(width, filled)),
69
+ empty: Math.max(0, width - filled)
70
+ };
71
+ }
72
+ function getBarLabelColor(fillColor) {
73
+ return fillColor === "#ef4444" ? BAR_LABEL_LIGHT_COLOR : BAR_LABEL_DARK_COLOR;
74
+ }
75
+ function renderProgressBar(percent, labelSuffix) {
76
+ const barSegments = getBarSegments(percent, BAR_WIDTH);
77
+ const barFillColor = getBarFillColor(percent, labelSuffix);
78
+ const label = `${formatPercent(percent)} ${labelSuffix}`;
79
+ const labelStart = Math.max(0, Math.floor((BAR_WIDTH - label.length) / 2));
80
+ const labelEnd = labelStart + label.length;
81
+ return Array.from({ length: BAR_WIDTH }, (_, index) => {
82
+ const isFilled = index < barSegments.filled;
83
+ const isLabelCell = index >= labelStart && index < labelEnd;
84
+ const underlyingColor = isFilled ? barFillColor : BAR_EMPTY_COLOR;
85
+ if (isLabelCell) {
86
+ return /* @__PURE__ */ jsx("text", { fg: isFilled ? getBarLabelColor(barFillColor) : BAR_LABEL_LIGHT_COLOR, bg: underlyingColor, children: label[index - labelStart] });
87
+ }
88
+ return isFilled ? /* @__PURE__ */ jsx("text", { fg: underlyingColor, children: "\u2588" }) : /* @__PURE__ */ jsx("text", { bg: BAR_EMPTY_COLOR, children: " " });
89
+ });
90
+ }
91
+ var module = {
92
+ id,
93
+ tui: async (api, rawOptions) => {
94
+ const stateDir = getOpenCodeStateDir();
95
+ const options = rawOptions ?? {};
96
+ const [sidebarVisible, setSidebarVisible] = createSignal(
97
+ api.kv.get(SIDEBAR_VISIBLE_KV_KEY, true) !== false
98
+ );
99
+ const [invert, setInvert] = createSignal(api.kv.get(SIDEBAR_INVERT_KV_KEY, options.invert === true) === true);
100
+ const [state, setState] = createSignal(await readUsageState(stateDir));
101
+ const [open, setOpen] = createSignal(true);
102
+ let syncInFlight = null;
103
+ const syncState = async () => {
104
+ if (syncInFlight) {
105
+ return syncInFlight;
106
+ }
107
+ syncInFlight = (async () => {
108
+ try {
109
+ const nextState = await readUsageState(stateDir);
110
+ setState(nextState);
111
+ } finally {
112
+ syncInFlight = null;
113
+ }
114
+ })();
115
+ return syncInFlight;
116
+ };
117
+ const showUsageDialog = async () => {
118
+ await syncState();
119
+ const latestState = state();
120
+ setState(latestState);
121
+ api.ui.dialog.replace(
122
+ () => api.ui.DialogAlert({
123
+ title: "OpenAI Usage",
124
+ message: formatCommandSummary(latestState, PACKAGE_NAME, PLUGIN_VERSION, PACKAGE_HOMEPAGE)
125
+ })
126
+ );
127
+ };
128
+ const toggleSidebarInvert = () => {
129
+ const nextInvert = !invert();
130
+ setInvert(nextInvert);
131
+ api.kv.set(SIDEBAR_INVERT_KV_KEY, nextInvert);
132
+ api.ui.toast({
133
+ message: nextInvert ? "Sidebar now shows usage left." : "Sidebar now shows usage used."
134
+ });
135
+ };
136
+ const toggleSidebarVisibility = () => {
137
+ const nextVisible = !sidebarVisible();
138
+ setSidebarVisible(nextVisible);
139
+ api.kv.set(SIDEBAR_VISIBLE_KV_KEY, nextVisible);
140
+ api.ui.toast({
141
+ message: nextVisible ? "Sidebar section is now visible." : "Sidebar section is now hidden."
142
+ });
143
+ };
144
+ void syncState();
145
+ const timer = setInterval(() => {
146
+ void syncState();
147
+ }, CACHE_SYNC_MS);
148
+ api.lifecycle.onDispose(() => {
149
+ clearInterval(timer);
150
+ });
151
+ api.event.on("account.added", () => {
152
+ void syncState();
153
+ });
154
+ api.event.on("account.removed", () => {
155
+ void syncState();
156
+ });
157
+ api.event.on("account.switched", () => {
158
+ void syncState();
159
+ });
160
+ api.event.on("session.next.prompted", () => {
161
+ void syncState();
162
+ });
163
+ api.event.on("command.executed", () => {
164
+ void syncState();
165
+ });
166
+ const renderSidebarWindow = (window) => {
167
+ if (!window) {
168
+ return null;
169
+ }
170
+ const usageDisplay = getUsageDisplay(window.usedPercent, invert());
171
+ return /* @__PURE__ */ jsx("box", { flexDirection: "column", gap: 0, padding: 0, margin: 0, children: /* @__PURE__ */ jsxs("box", { flexDirection: "row", gap: 0, padding: 0, margin: 0, children: [
172
+ /* @__PURE__ */ jsx("text", { children: `${formatWindowLabel(window.windowDurationMins)} ` }),
173
+ renderProgressBar(usageDisplay.percent, usageDisplay.label),
174
+ /* @__PURE__ */ jsx("text", { attributes: DIM_ATTRIBUTES, children: ` Reset: ${formatRelativeDuration(window.resetsAt)}` })
175
+ ] }) });
176
+ };
177
+ const renderSidebarHeader = () => /* @__PURE__ */ jsxs("box", { flexDirection: "row", gap: 0, padding: 0, margin: 0, onMouseDown: () => setOpen(!open()), children: [
178
+ /* @__PURE__ */ jsx("text", { children: open() ? "\u25BC OpenAI Usage" : "\u25B6 OpenAI Usage" }),
179
+ PLUGIN_VERSION ? /* @__PURE__ */ jsx("text", { fg: SIDEBAR_VERSION_COLOR, attributes: DIM_ATTRIBUTES, children: ` ${PLUGIN_VERSION}` }) : null
180
+ ] });
181
+ const renderSidebarBody = () => {
182
+ const currentState = state();
183
+ if (currentState.error && !currentState.primary && !currentState.secondary) {
184
+ return /* @__PURE__ */ jsx("text", { children: `Status: unavailable
185
+ Error: ${currentState.error}` });
186
+ }
187
+ if (!currentState.primary && !currentState.secondary) {
188
+ return /* @__PURE__ */ jsx("text", { children: "Status: waiting for usage data" });
189
+ }
190
+ return /* @__PURE__ */ jsxs("box", { flexDirection: "column", gap: 0, padding: 0, margin: 0, children: [
191
+ renderSidebarWindow(currentState.primary),
192
+ renderSidebarWindow(currentState.secondary)
193
+ ] });
194
+ };
195
+ const renderSidebarContent = () => {
196
+ const currentState = state();
197
+ if (!sidebarVisible() || currentState.configured === false) {
198
+ return null;
199
+ }
200
+ return /* @__PURE__ */ jsxs("box", { flexDirection: "column", gap: 0, padding: 0, margin: 0, children: [
201
+ renderSidebarHeader(),
202
+ open() ? renderSidebarBody() : null
203
+ ] });
204
+ };
205
+ api.slots.register({
206
+ order: -100,
207
+ slots: {
208
+ sidebar_content: renderSidebarContent
209
+ }
210
+ });
211
+ const unregisterCommand = api.command?.register(() => [
212
+ {
213
+ title: sidebarVisible() ? "Hide Sidebar Section" : "Show Sidebar Section",
214
+ value: "openai-usage.toggle-sidebar-visibility",
215
+ description: "show/hide sidebar entry",
216
+ category: "OpenAI Usage",
217
+ onSelect: toggleSidebarVisibility
218
+ },
219
+ ...state().configured === false ? [] : [
220
+ {
221
+ title: "View status",
222
+ value: "openai-usage.show",
223
+ description: "from OpenAI's backend API",
224
+ category: "OpenAI Usage",
225
+ onSelect: showUsageDialog
226
+ },
227
+ {
228
+ title: "Toggle Display Mode",
229
+ value: "openai-usage.toggle-sidebar-invert",
230
+ description: "between used/left quota",
231
+ category: "OpenAI Usage",
232
+ onSelect: toggleSidebarInvert
233
+ }
234
+ ]
235
+ ]);
236
+ api.lifecycle.onDispose(() => {
237
+ unregisterCommand?.();
238
+ });
239
+ }
240
+ };
241
+ var tui_default = module;
242
+ export {
243
+ tui_default as default,
244
+ id
245
+ };
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@a-r-m-i-n/opencode-openai-usage",
3
+ "version": "0.1.0",
4
+ "description": "OpenCode plugin that shows ChatGPT/OpenAI usage in the sidebar and command palette.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/a-r-m-i-n/opencode-openai-usage.git"
9
+ },
10
+ "homepage": "https://github.com/a-r-m-i-n/opencode-openai-usage#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/a-r-m-i-n/opencode-openai-usage/issues"
13
+ },
14
+ "type": "module",
15
+ "keywords": [
16
+ "opencode",
17
+ "opencode-plugin",
18
+ "openai",
19
+ "chatgpt",
20
+ "tui"
21
+ ],
22
+ "files": [
23
+ "dist",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "exports": {
28
+ ".": "./dist/index.js",
29
+ "./tui": "./dist/tui.js"
30
+ },
31
+ "scripts": {
32
+ "build": "tsup ./src/index.ts ./src/tui.tsx --format esm --out-dir dist --clean --external @opencode-ai/plugin --external @opencode-ai/plugin/tui --external @opentui/core --external @opentui/keymap --external @opentui/solid --external solid-js",
33
+ "test": "node --import tsx --test ./src/lib/openai-usage.test.ts",
34
+ "prepublishOnly": "npm run build"
35
+ },
36
+ "dependencies": {
37
+ "@opencode-ai/plugin": "1.15.10",
38
+ "@opentui/core": "0.2.16",
39
+ "@opentui/keymap": "0.2.16",
40
+ "@opentui/solid": "0.2.16",
41
+ "solid-js": "1.9.12"
42
+ },
43
+ "devDependencies": {
44
+ "tsup": "^8.3.6",
45
+ "tsx": "^4.19.2",
46
+ "typescript": "^5.8.3"
47
+ }
48
+ }