opencode-codex-usage-tui 1.3.2
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 +21 -0
- package/README.md +103 -0
- package/dist/core.js +142 -0
- package/dist/tui.js +238 -0
- package/package.json +51 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
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,103 @@
|
|
|
1
|
+
# opencode-codex-usage
|
|
2
|
+
|
|
3
|
+
OpenCode TUI plugin that shows remaining ChatGPT Codex usage limits in the sidebar.
|
|
4
|
+
|
|
5
|
+
It reads the same internal Codex usage data shown by the ChatGPT Codex usage dashboard and renders remaining limit percentages, reset timers, credits, and banked reset credits.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Shows remaining Codex limits, not used percentage
|
|
10
|
+
- Displays the shortest window first, typically the shared 5-hour Plus limit
|
|
11
|
+
- Displays reset timers below each usage row and aligned to the right
|
|
12
|
+
- Shows the ChatGPT plan in uppercase, for example `PLUS`
|
|
13
|
+
- Supports collapsed and expanded sidebar display
|
|
14
|
+
- Automatically reads trusted Codex CLI auth from `~/.codex/auth.json`
|
|
15
|
+
- Continuously refreshes usage in the background
|
|
16
|
+
- Updates reset countdowns in realtime between API refreshes
|
|
17
|
+
- Supports disabling requests with `OPENCODE_CODEX_USAGE_DISABLED=true`
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
Add the published package to OpenCode's TUI configuration:
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"$schema": "https://opencode.ai/tui.json",
|
|
26
|
+
"plugin": ["opencode-codex-usage-tui@1.3.2"]
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
OpenCode resolves the npm package and loads its `./tui` entry point directly. No files are copied into the OpenCode configuration directory. Pin the version for reproducible setups, or omit the version when you want OpenCode to resolve the latest release.
|
|
31
|
+
|
|
32
|
+
For local development, build and pack the project:
|
|
33
|
+
|
|
34
|
+
```powershell
|
|
35
|
+
npm install
|
|
36
|
+
npm run check
|
|
37
|
+
npm pack
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Then reference the generated tarball in `tui.json` using the local package spec supported by your OpenCode installation. Restart OpenCode after editing `tui.json`; TUI plugins are loaded at startup.
|
|
41
|
+
|
|
42
|
+
For example, a local file URL can be used when supported:
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"$schema": "https://opencode.ai/tui.json",
|
|
47
|
+
"plugin": ["file:///absolute/path/to/opencode-codex-usage-tui-1.3.2.tgz"]
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Configure Auth
|
|
52
|
+
|
|
53
|
+
Default flow:
|
|
54
|
+
|
|
55
|
+
```powershell
|
|
56
|
+
codex login
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The plugin reads the Codex CLI token from:
|
|
60
|
+
|
|
61
|
+
```text
|
|
62
|
+
~/.codex/auth.json
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The file must be the regular, non-linked file created by the Codex CLI. The plugin does not support environment-token overrides, custom Codex homes, or a separate OpenCode credential file.
|
|
66
|
+
|
|
67
|
+
To disable the usage request entirely for a shell session:
|
|
68
|
+
|
|
69
|
+
```powershell
|
|
70
|
+
$env:OPENCODE_CODEX_USAGE_DISABLED = "true"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The section starts collapsed but begins fetching immediately. Reset countdowns render live, while usage data refreshes at most once per minute to avoid rate limits.
|
|
74
|
+
|
|
75
|
+
## Getting A Codex Token
|
|
76
|
+
|
|
77
|
+
After logging in with Codex CLI, credentials are normally stored in:
|
|
78
|
+
|
|
79
|
+
```text
|
|
80
|
+
~/.codex/auth.json
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The plugin reads `tokens.access_token` (or `tokens.accessToken`) and an optional account ID from the token object. If the token expires, run `codex login` again and restart OpenCode if needed.
|
|
84
|
+
|
|
85
|
+
Do not commit or share `auth.json` or access tokens.
|
|
86
|
+
|
|
87
|
+
## Endpoint
|
|
88
|
+
|
|
89
|
+
This plugin calls an internal ChatGPT endpoint:
|
|
90
|
+
|
|
91
|
+
```text
|
|
92
|
+
https://chatgpt.com/backend-api/wham/usage
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Because this endpoint is not a public stable API, the plugin is defensive and falls back to a short error plus the official dashboard URL if the payload changes:
|
|
96
|
+
|
|
97
|
+
```text
|
|
98
|
+
https://chatgpt.com/codex/settings/usage
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
MIT
|
package/dist/core.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { lstatSync, readFileSync, realpathSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
const DEFAULT_CODEX_AUTH_FILE = join(homedir(), ".codex", "auth.json");
|
|
5
|
+
const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
|
6
|
+
const CODEX_USAGE_TIMEOUT_MS = 15e3;
|
|
7
|
+
const DASHBOARD_URL = "https://chatgpt.com/codex/settings/usage";
|
|
8
|
+
function isRecord(value) {
|
|
9
|
+
return typeof value === "object" && value !== null;
|
|
10
|
+
}
|
|
11
|
+
function isTrustedCodexAuthFile(path) {
|
|
12
|
+
if (path !== DEFAULT_CODEX_AUTH_FILE) return false;
|
|
13
|
+
const directory = dirname(path);
|
|
14
|
+
try {
|
|
15
|
+
const directoryStats = lstatSync(directory);
|
|
16
|
+
const fileStats = lstatSync(path);
|
|
17
|
+
if (directoryStats.isSymbolicLink() || !directoryStats.isDirectory() || fileStats.isSymbolicLink() || !fileStats.isFile() || fileStats.nlink > 1) return false;
|
|
18
|
+
const compare = (value) => process.platform === "win32" ? value.toLowerCase() : value;
|
|
19
|
+
if (compare(resolve(directory)) !== compare(resolve(realpathSync(directory))) || compare(resolve(path)) !== compare(resolve(realpathSync(path)))) return false;
|
|
20
|
+
return process.platform === "win32" || (fileStats.mode & 63) === 0;
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function toNumber(value) {
|
|
26
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
|
|
27
|
+
if (typeof value !== "string" || !value.trim()) return void 0;
|
|
28
|
+
const parsed = Number(value);
|
|
29
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
30
|
+
}
|
|
31
|
+
function toStringValue(value) {
|
|
32
|
+
return typeof value === "string" && value.trim() ? value.trim() : void 0;
|
|
33
|
+
}
|
|
34
|
+
function firstString(...values) {
|
|
35
|
+
for (const value of values) {
|
|
36
|
+
const result = toStringValue(value);
|
|
37
|
+
if (result) return result;
|
|
38
|
+
}
|
|
39
|
+
return void 0;
|
|
40
|
+
}
|
|
41
|
+
function loadConfig(readFile = (path) => readFileSync(path, "utf8"), isTrustedFile = isTrustedCodexAuthFile) {
|
|
42
|
+
try {
|
|
43
|
+
if (!isTrustedFile(DEFAULT_CODEX_AUTH_FILE)) return null;
|
|
44
|
+
const parsed = JSON.parse(readFile(DEFAULT_CODEX_AUTH_FILE, "utf8"));
|
|
45
|
+
if (!isRecord(parsed) || !isRecord(parsed.tokens)) return null;
|
|
46
|
+
const accessToken = firstString(parsed.tokens.access_token, parsed.tokens.accessToken);
|
|
47
|
+
if (!accessToken) return null;
|
|
48
|
+
const accountId = firstString(parsed.tokens.account_id, parsed.tokens.accountId);
|
|
49
|
+
return accountId ? { accessToken, accountId, source: "codex" } : { accessToken, source: "codex" };
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function isUsageDisabled(env = process.env) {
|
|
55
|
+
return env.OPENCODE_CODEX_USAGE_DISABLED?.trim().toLowerCase() === "true";
|
|
56
|
+
}
|
|
57
|
+
async function fetchUsage(options = {}) {
|
|
58
|
+
if (options.disabled ?? isUsageDisabled()) return { ok: false, reason: "disabled" };
|
|
59
|
+
let config;
|
|
60
|
+
try {
|
|
61
|
+
config = (options.readConfig ?? (() => loadConfig()))();
|
|
62
|
+
} catch {
|
|
63
|
+
return { ok: false, reason: "no-config" };
|
|
64
|
+
}
|
|
65
|
+
if (!config) return { ok: false, reason: "no-config" };
|
|
66
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch?.bind(globalThis);
|
|
67
|
+
if (!fetchImpl) return { ok: false, reason: "request-failed" };
|
|
68
|
+
const headers = { Accept: "application/json", Authorization: `Bearer ${config.accessToken}`, Referer: DASHBOARD_URL, "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" };
|
|
69
|
+
if (config.accountId) headers["ChatGPT-Account-ID"] = config.accountId;
|
|
70
|
+
const controller = new AbortController();
|
|
71
|
+
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? CODEX_USAGE_TIMEOUT_MS);
|
|
72
|
+
try {
|
|
73
|
+
const response = await fetchImpl(CODEX_USAGE_URL, { headers, signal: controller.signal, redirect: "error" });
|
|
74
|
+
if (response.status === 401 || response.status === 403) return { ok: false, reason: "unauthorized", status: response.status };
|
|
75
|
+
if (response.status === 429) return { ok: false, reason: "rate-limited", status: response.status };
|
|
76
|
+
if (!response.ok) return { ok: false, reason: "http-error", status: response.status };
|
|
77
|
+
if (!(response.headers.get("content-type") ?? "").includes("application/json")) return { ok: false, reason: "unexpected-response" };
|
|
78
|
+
let raw;
|
|
79
|
+
try {
|
|
80
|
+
raw = await response.json();
|
|
81
|
+
} catch {
|
|
82
|
+
return { ok: false, reason: "unexpected-response" };
|
|
83
|
+
}
|
|
84
|
+
return isRecord(raw) ? { ok: true, payload: raw } : { ok: false, reason: "unexpected-response" };
|
|
85
|
+
} catch {
|
|
86
|
+
return { ok: false, reason: controller.signal.aborted ? "timeout" : "request-failed" };
|
|
87
|
+
} finally {
|
|
88
|
+
clearTimeout(timeout);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function resetInSeconds(window) {
|
|
92
|
+
const resetAfter = toNumber(window.reset_after_seconds);
|
|
93
|
+
if (resetAfter !== void 0) return Math.max(0, Math.floor(resetAfter));
|
|
94
|
+
const resetAt = toNumber(window.reset_at);
|
|
95
|
+
if (resetAt === void 0) return 0;
|
|
96
|
+
const resetAtMs = resetAt > 1e12 ? resetAt : resetAt * 1e3;
|
|
97
|
+
return Math.max(0, Math.floor((resetAtMs - Date.now()) / 1e3));
|
|
98
|
+
}
|
|
99
|
+
function labelForWindow(seconds, fallback) {
|
|
100
|
+
if (seconds <= 6 * 3600) return "5h";
|
|
101
|
+
if (seconds >= 6 * 86400 && seconds <= 8 * 86400) return "Weekly";
|
|
102
|
+
if (seconds >= 27 * 86400 && seconds <= 32 * 86400) return "Monthly";
|
|
103
|
+
if (seconds >= 86400) return `${Math.round(seconds / 86400)}d`;
|
|
104
|
+
return fallback;
|
|
105
|
+
}
|
|
106
|
+
function normalizeWindow(label, window, limitReached) {
|
|
107
|
+
if (!isRecord(window)) return null;
|
|
108
|
+
const usedPercent = toNumber(window.used_percent);
|
|
109
|
+
const windowSeconds = toNumber(window.limit_window_seconds);
|
|
110
|
+
if (usedPercent === void 0 || windowSeconds === void 0 || windowSeconds < 0) return null;
|
|
111
|
+
return { label: labelForWindow(windowSeconds, label), usedPercent: Math.max(0, Math.min(100, usedPercent)), windowSeconds, resetInSec: resetInSeconds(window), limitReached };
|
|
112
|
+
}
|
|
113
|
+
function normalizeRateLimit(label, rateLimit) {
|
|
114
|
+
if (!isRecord(rateLimit)) return [];
|
|
115
|
+
const limitReached = rateLimit.limit_reached === true;
|
|
116
|
+
return [normalizeWindow(label, rateLimit.primary_window, limitReached), normalizeWindow(label, rateLimit.secondary_window, limitReached)].filter((item) => Boolean(item));
|
|
117
|
+
}
|
|
118
|
+
function normalizePayload(payload) {
|
|
119
|
+
const windows = normalizeRateLimit("Usage", payload.rate_limit);
|
|
120
|
+
if (Array.isArray(payload.additional_rate_limits)) for (const item of payload.additional_rate_limits) {
|
|
121
|
+
if (!isRecord(item)) continue;
|
|
122
|
+
const name = toStringValue(item.limit_name) ?? toStringValue(item.metered_feature) ?? "Extra";
|
|
123
|
+
for (const window of normalizeRateLimit(name, item.rate_limit)) windows.push({ ...window, label: window.label === "5h" || window.label === "Weekly" ? name : window.label });
|
|
124
|
+
}
|
|
125
|
+
windows.sort((a, b) => a.windowSeconds - b.windowSeconds);
|
|
126
|
+
if (!windows.length) return { windows, error: isRecord(payload.rate_limit) ? "usage windows missing" : "rate_limit missing" };
|
|
127
|
+
const creditsPayload = isRecord(payload.credits) ? payload.credits : void 0;
|
|
128
|
+
const credits = creditsPayload ? { hasCredits: creditsPayload.has_credits === true, unlimited: creditsPayload.unlimited === true, balance: toStringValue(creditsPayload.balance) ?? "0" } : void 0;
|
|
129
|
+
const resetCreditsPayload = isRecord(payload.rate_limit_reset_credits) ? payload.rate_limit_reset_credits : void 0;
|
|
130
|
+
return { plan: toStringValue(payload.plan_type) ?? "Unknown", windows, credits, resetCredits: toNumber(resetCreditsPayload?.available_count) };
|
|
131
|
+
}
|
|
132
|
+
export {
|
|
133
|
+
CODEX_USAGE_TIMEOUT_MS,
|
|
134
|
+
CODEX_USAGE_URL,
|
|
135
|
+
DEFAULT_CODEX_AUTH_FILE,
|
|
136
|
+
fetchUsage,
|
|
137
|
+
isRecord,
|
|
138
|
+
isUsageDisabled,
|
|
139
|
+
loadConfig,
|
|
140
|
+
normalizePayload,
|
|
141
|
+
toNumber
|
|
142
|
+
};
|
package/dist/tui.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { createElement, insert, setProp } from "@opentui/solid";
|
|
2
|
+
import { fetchUsage, normalizePayload } from "./core.js";
|
|
3
|
+
const DASHBOARD_URL = "https://chatgpt.com/codex/settings/usage";
|
|
4
|
+
const REFRESH_MS = 6e4;
|
|
5
|
+
const RENDER_MS = 1e3;
|
|
6
|
+
const COLLAPSED_KV_KEY = "codex-usage.collapsed";
|
|
7
|
+
let state = { status: "loading", windows: [], lastFetch: 0 };
|
|
8
|
+
let inflight = false;
|
|
9
|
+
let fetchRequested = true;
|
|
10
|
+
let collapsed = true;
|
|
11
|
+
let collapsedInitialized = false;
|
|
12
|
+
async function fetchOnce() {
|
|
13
|
+
if (!fetchRequested || inflight) return;
|
|
14
|
+
inflight = true;
|
|
15
|
+
state = { ...state, status: "loading", message: void 0 };
|
|
16
|
+
try {
|
|
17
|
+
const result = await fetchUsage();
|
|
18
|
+
if (!result.ok) {
|
|
19
|
+
const status = result.reason === "disabled" ? "disabled" : result.reason === "no-config" ? "no-config" : "error";
|
|
20
|
+
state = { ...state, status, message: failureMessage(result.reason, result.status), lastFetch: Date.now() };
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const normalized = normalizePayload(result.payload);
|
|
24
|
+
if (normalized.windows.length === 0) {
|
|
25
|
+
state = { ...state, status: "error", message: normalized.error ?? "usage windows missing", lastFetch: Date.now() };
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
state = { status: "ok", lastFetch: Date.now(), ...normalized };
|
|
29
|
+
} catch {
|
|
30
|
+
state = { ...state, status: "error", message: "request failed", lastFetch: Date.now() };
|
|
31
|
+
} finally {
|
|
32
|
+
inflight = false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function failureMessage(reason, status) {
|
|
36
|
+
switch (reason) {
|
|
37
|
+
case "disabled":
|
|
38
|
+
return "disabled by OPENCODE_CODEX_USAGE_DISABLED";
|
|
39
|
+
case "no-config":
|
|
40
|
+
return "auth file unavailable or invalid";
|
|
41
|
+
case "unauthorized":
|
|
42
|
+
return "token expired or unauthorized";
|
|
43
|
+
case "rate-limited":
|
|
44
|
+
return "rate limited";
|
|
45
|
+
case "http-error":
|
|
46
|
+
return `HTTP ${status ?? "error"}`;
|
|
47
|
+
case "unexpected-response":
|
|
48
|
+
return "unexpected response";
|
|
49
|
+
case "timeout":
|
|
50
|
+
return "request timed out";
|
|
51
|
+
case "request-failed":
|
|
52
|
+
return "request failed";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function fmtReset(resetSec) {
|
|
56
|
+
if (!resetSec || resetSec < 0) return "";
|
|
57
|
+
const days = Math.floor(resetSec / 86400);
|
|
58
|
+
const hours = Math.floor(resetSec % 86400 / 3600);
|
|
59
|
+
const mins = Math.floor(resetSec % 3600 / 60);
|
|
60
|
+
if (days > 0) return `${days}d ${hours}h`;
|
|
61
|
+
if (hours > 0) return `${hours}h ${mins}m`;
|
|
62
|
+
return `${mins}m`;
|
|
63
|
+
}
|
|
64
|
+
function fmtPct(pct) {
|
|
65
|
+
return `${pct.toFixed(1).padStart(5)}%`;
|
|
66
|
+
}
|
|
67
|
+
function liveResetSeconds(usage) {
|
|
68
|
+
if (!state.lastFetch) return usage.resetInSec;
|
|
69
|
+
return Math.max(0, usage.resetInSec - Math.floor((Date.now() - state.lastFetch) / 1e3));
|
|
70
|
+
}
|
|
71
|
+
function remainingPercent(usedPercent) {
|
|
72
|
+
return Math.max(0, Math.min(100, 100 - usedPercent));
|
|
73
|
+
}
|
|
74
|
+
function summaryText() {
|
|
75
|
+
const first = state.windows[0];
|
|
76
|
+
if (state.status === "ok" && first) return `(${first.label} ${remainingPercent(first.usedPercent).toFixed(1)}% left)`;
|
|
77
|
+
if (state.status === "no-config") return "(no config)";
|
|
78
|
+
if (state.status === "disabled") return "(disabled)";
|
|
79
|
+
if (state.status === "error") return `(${state.message ?? "error"})`;
|
|
80
|
+
return "(...)";
|
|
81
|
+
}
|
|
82
|
+
function setNodeText(node, text) {
|
|
83
|
+
insert(node, null);
|
|
84
|
+
insert(node, [text]);
|
|
85
|
+
}
|
|
86
|
+
function el(tag, props, children = []) {
|
|
87
|
+
const node = createElement(tag);
|
|
88
|
+
for (const [key, value] of Object.entries(props)) {
|
|
89
|
+
if (value !== void 0) setProp(node, key, value);
|
|
90
|
+
}
|
|
91
|
+
for (const child of children) {
|
|
92
|
+
if (child !== null && child !== void 0 && child !== false) insert(node, child);
|
|
93
|
+
}
|
|
94
|
+
return node;
|
|
95
|
+
}
|
|
96
|
+
function txt(props, children = []) {
|
|
97
|
+
return el("text", props, children);
|
|
98
|
+
}
|
|
99
|
+
function box(props, children = []) {
|
|
100
|
+
return el("box", props, children);
|
|
101
|
+
}
|
|
102
|
+
function valNode(style) {
|
|
103
|
+
return txt(style, [""]);
|
|
104
|
+
}
|
|
105
|
+
const tui = async (api) => {
|
|
106
|
+
if (!collapsedInitialized) {
|
|
107
|
+
collapsed = Boolean(api.kv.get(COLLAPSED_KV_KEY, true));
|
|
108
|
+
fetchRequested = true;
|
|
109
|
+
collapsedInitialized = true;
|
|
110
|
+
}
|
|
111
|
+
api.slots.register({
|
|
112
|
+
order: 160,
|
|
113
|
+
slots: {
|
|
114
|
+
sidebar_content() {
|
|
115
|
+
const theme = api.theme.current;
|
|
116
|
+
const muted = theme.textMuted;
|
|
117
|
+
const normal = theme.text;
|
|
118
|
+
const valStyle = { fg: muted };
|
|
119
|
+
const headerTitle = valNode({ bold: true, fg: normal });
|
|
120
|
+
const headerSummary = valNode(valStyle);
|
|
121
|
+
const planVal = valNode(valStyle);
|
|
122
|
+
const creditsVal = valNode(valStyle);
|
|
123
|
+
const resetsVal = valNode(valStyle);
|
|
124
|
+
const statusVal = valNode(valStyle);
|
|
125
|
+
function usageBlock() {
|
|
126
|
+
const label = valNode({ fg: muted });
|
|
127
|
+
const pct = valNode(valStyle);
|
|
128
|
+
const reset = valNode(valStyle);
|
|
129
|
+
const block = box({ flexDirection: "column", width: "100%" }, [
|
|
130
|
+
box({ flexDirection: "row", width: "100%", justifyContent: "space-between" }, [label, pct]),
|
|
131
|
+
box({ flexDirection: "row", width: "100%", justifyContent: "flex-end" }, [reset])
|
|
132
|
+
]);
|
|
133
|
+
return { block, label, pct, reset };
|
|
134
|
+
}
|
|
135
|
+
const primary = usageBlock();
|
|
136
|
+
const secondary = usageBlock();
|
|
137
|
+
const extraOne = usageBlock();
|
|
138
|
+
const extraTwo = usageBlock();
|
|
139
|
+
function setUsageWindow(view, usage) {
|
|
140
|
+
setProp(view.block, "visible", Boolean(usage));
|
|
141
|
+
if (!usage) {
|
|
142
|
+
setNodeText(view.label, "");
|
|
143
|
+
setNodeText(view.pct, "");
|
|
144
|
+
setNodeText(view.reset, "");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const reset = fmtReset(liveResetSeconds(usage));
|
|
148
|
+
setNodeText(view.label, usage.label);
|
|
149
|
+
setNodeText(view.pct, `${fmtPct(remainingPercent(usage.usedPercent))} left${usage.limitReached ? " exhausted" : ""}`);
|
|
150
|
+
setNodeText(view.reset, reset ? `reset ${reset}` : "");
|
|
151
|
+
}
|
|
152
|
+
function clearWindows() {
|
|
153
|
+
setUsageWindow(primary, void 0);
|
|
154
|
+
setUsageWindow(secondary, void 0);
|
|
155
|
+
setUsageWindow(extraOne, void 0);
|
|
156
|
+
setUsageWindow(extraTwo, void 0);
|
|
157
|
+
}
|
|
158
|
+
function update() {
|
|
159
|
+
const now = Date.now();
|
|
160
|
+
if (fetchRequested && (state.lastFetch === 0 || now - state.lastFetch > REFRESH_MS)) fetchOnce();
|
|
161
|
+
setNodeText(headerTitle, collapsed ? "\u25B6 Codex Usage" : "\u25BC Codex Usage");
|
|
162
|
+
setNodeText(headerSummary, collapsed ? ` ${summaryText()}` : "");
|
|
163
|
+
setProp(headerSummary, "visible", collapsed);
|
|
164
|
+
if (state.status === "ok") {
|
|
165
|
+
const windows = state.windows;
|
|
166
|
+
setNodeText(planVal, (state.plan ?? "Unknown").toUpperCase());
|
|
167
|
+
setUsageWindow(primary, windows[0]);
|
|
168
|
+
setUsageWindow(secondary, windows[1]);
|
|
169
|
+
setUsageWindow(extraOne, windows[2]);
|
|
170
|
+
setUsageWindow(extraTwo, windows[3]);
|
|
171
|
+
setNodeText(
|
|
172
|
+
creditsVal,
|
|
173
|
+
state.credits ? state.credits.hasCredits ? state.credits.unlimited ? "unlimited" : state.credits.balance : "none" : ""
|
|
174
|
+
);
|
|
175
|
+
setNodeText(resetsVal, state.resetCredits === void 0 ? "" : `${state.resetCredits} available`);
|
|
176
|
+
setNodeText(statusVal, "");
|
|
177
|
+
} else if (state.status === "no-config") {
|
|
178
|
+
setNodeText(planVal, "");
|
|
179
|
+
clearWindows();
|
|
180
|
+
setNodeText(creditsVal, "");
|
|
181
|
+
setNodeText(resetsVal, "");
|
|
182
|
+
setNodeText(statusVal, "run codex login to create ~/.codex/auth.json, then restart OpenCode");
|
|
183
|
+
} else if (state.status === "disabled") {
|
|
184
|
+
setNodeText(planVal, "");
|
|
185
|
+
clearWindows();
|
|
186
|
+
setNodeText(creditsVal, "");
|
|
187
|
+
setNodeText(resetsVal, "");
|
|
188
|
+
setNodeText(statusVal, state.message ?? "disabled");
|
|
189
|
+
} else if (state.status === "error") {
|
|
190
|
+
setNodeText(planVal, "");
|
|
191
|
+
clearWindows();
|
|
192
|
+
setNodeText(creditsVal, "");
|
|
193
|
+
setNodeText(resetsVal, "");
|
|
194
|
+
setNodeText(statusVal, `${state.message ?? "error"}
|
|
195
|
+
${DASHBOARD_URL}`);
|
|
196
|
+
} else {
|
|
197
|
+
setNodeText(planVal, "");
|
|
198
|
+
clearWindows();
|
|
199
|
+
setNodeText(creditsVal, "");
|
|
200
|
+
setNodeText(resetsVal, "");
|
|
201
|
+
setNodeText(statusVal, "loading");
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function toggle() {
|
|
205
|
+
collapsed = !collapsed;
|
|
206
|
+
api.kv.set(COLLAPSED_KV_KEY, collapsed);
|
|
207
|
+
if (!collapsed) fetchRequested = true;
|
|
208
|
+
setProp(body, "visible", !collapsed);
|
|
209
|
+
update();
|
|
210
|
+
}
|
|
211
|
+
const header = box({ flexDirection: "row", width: "100%", onMouseUp: () => toggle() }, [headerTitle, headerSummary]);
|
|
212
|
+
const body = box({ flexDirection: "column", width: "100%", visible: !collapsed }, [
|
|
213
|
+
box({ flexDirection: "row", width: "100%", justifyContent: "space-between" }, [txt({ fg: muted }, ["Plan"]), planVal]),
|
|
214
|
+
primary.block,
|
|
215
|
+
secondary.block,
|
|
216
|
+
extraOne.block,
|
|
217
|
+
extraTwo.block,
|
|
218
|
+
box({ flexDirection: "row", width: "100%", justifyContent: "space-between" }, [txt({ fg: muted }, ["Credits"]), creditsVal]),
|
|
219
|
+
box({ flexDirection: "row", width: "100%", justifyContent: "space-between" }, [txt({ fg: muted }, ["Resets"]), resetsVal]),
|
|
220
|
+
statusVal
|
|
221
|
+
]);
|
|
222
|
+
const root = box({ flexDirection: "column", width: "100%" }, [header, body]);
|
|
223
|
+
update();
|
|
224
|
+
const timer = setInterval(update, RENDER_MS);
|
|
225
|
+
api.lifecycle.onDispose(() => clearInterval(timer));
|
|
226
|
+
return root;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
};
|
|
231
|
+
const plugin = {
|
|
232
|
+
id: "codex-usage",
|
|
233
|
+
tui
|
|
234
|
+
};
|
|
235
|
+
var tui_default = plugin;
|
|
236
|
+
export {
|
|
237
|
+
tui_default as default
|
|
238
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-codex-usage-tui",
|
|
3
|
+
"version": "1.3.2",
|
|
4
|
+
"description": "OpenCode TUI plugin that shows remaining ChatGPT Codex usage limits in the sidebar",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "esbuild src/tui.ts src/core.ts --outdir=dist --format=esm --platform=node --packages=external",
|
|
9
|
+
"check": "npm run build && node --check dist/tui.js && node --check dist/core.js",
|
|
10
|
+
"prepack": "npm run build"
|
|
11
|
+
},
|
|
12
|
+
"exports": {
|
|
13
|
+
"./tui": "./dist/tui.js"
|
|
14
|
+
},
|
|
15
|
+
"engines": {
|
|
16
|
+
"opencode": ">=1.14.0"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"LICENSE",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"esbuild": "^0.25.0"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@opencode-ai/plugin": ">=1.14.0",
|
|
28
|
+
"@opentui/core": ">=0.2.6",
|
|
29
|
+
"@opentui/keymap": ">=0.2.6",
|
|
30
|
+
"@opentui/solid": ">=0.2.6"
|
|
31
|
+
},
|
|
32
|
+
"peerDependenciesMeta": {
|
|
33
|
+
"@opentui/core": { "optional": true },
|
|
34
|
+
"@opentui/keymap": { "optional": true },
|
|
35
|
+
"@opentui/solid": { "optional": true }
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"opencode",
|
|
39
|
+
"opencode-plugin",
|
|
40
|
+
"codex",
|
|
41
|
+
"chatgpt",
|
|
42
|
+
"usage",
|
|
43
|
+
"limits",
|
|
44
|
+
"sidebar"
|
|
45
|
+
],
|
|
46
|
+
"homepage": "https://github.com/ahmadmcer/opencode-codex-usage",
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "git+https://github.com/ahmadmcer/opencode-codex-usage.git"
|
|
50
|
+
}
|
|
51
|
+
}
|