opencode-openai-quota 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/README.md +293 -3
- package/index.ts +1 -0
- package/package.json +47 -6
- package/src/codex.js +98 -0
- package/src/index.ts +8 -0
- package/src/quota.js +98 -0
- package/src/refresh.js +21 -0
- package/src/tui.tsx +183 -0
- package/tsconfig.json +13 -0
- package/tui.tsx +1 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Arturo Buendia
|
|
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
CHANGED
|
@@ -1,3 +1,293 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
<h1 align="center">OpenAI Quota for OpenCode 2</h1>
|
|
2
|
+
|
|
3
|
+
<p align="center">Codex subscription quota in your OpenCode 2 sidebar — read from your local Codex CLI, with no tokens copied and no inference spent.</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://www.npmjs.com/package/opencode-openai-quota"><img src="https://img.shields.io/npm/v/opencode-openai-quota?style=flat-square&label=npm&color=cb3837" alt="npm version"></a>
|
|
7
|
+
<a href="#requirements"><img src="https://img.shields.io/badge/OpenCode-2%20TUI%20plugin-7357d5?style=flat-square" alt="OpenCode 2 TUI plugin"></a>
|
|
8
|
+
<a href="#how-it-reads-quota"><img src="https://img.shields.io/badge/Quota-local%20Codex%20app--server-159b83?style=flat-square" alt="Quota from the local Codex app-server"></a>
|
|
9
|
+
<a href="#privacy-and-network-behavior"><img src="https://img.shields.io/badge/Tokens-never%20touched-0b7285?style=flat-square" alt="Tokens are never read"></a>
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
<p align="center"><code>opencode plugin add opencode-openai-quota</code></p>
|
|
13
|
+
|
|
14
|
+
<p align="center"><a href="#quick-install"><strong>Other install options ↓</strong></a></p>
|
|
15
|
+
|
|
16
|
+
<p align="center">
|
|
17
|
+
<img src="docs/assets/quota-preview.svg" alt="Recreation of the OpenCode 2 quota panel showing a 5-hour session window with 68 percent remaining, a weekly window with 9 percent remaining in red, two available banked resets, and the next reported expiration." width="440">
|
|
18
|
+
</p>
|
|
19
|
+
|
|
20
|
+
<p align="center"><sub>SVG recreation of the panel with example values—not a captured screenshot. Colors come from your theme; balances at or below 25% turn amber and at or below 10% turn red.</sub></p>
|
|
21
|
+
|
|
22
|
+
## What it does
|
|
23
|
+
|
|
24
|
+
Adds a small panel to OpenCode 2's sidebar with the OpenAI Codex subscription windows that are active on your machine:
|
|
25
|
+
|
|
26
|
+
- **Session (5h)** and **Weekly** windows, with the duration derived from the window Codex reports.
|
|
27
|
+
- **Remaining quota**, not used quota. A window with `63% used` renders as `37% remaining`.
|
|
28
|
+
- The **next reset** for each window as a live countdown.
|
|
29
|
+
- **Available banked resets** and their next known expiration.
|
|
30
|
+
- **Freshness**: when the numbers were last read, and an explicit warning when the read failed.
|
|
31
|
+
|
|
32
|
+
It is a quota panel, not an API billing, usage-credit, token, or cost tracker.
|
|
33
|
+
|
|
34
|
+
## How it reads quota
|
|
35
|
+
|
|
36
|
+
This is the part that changed for OpenCode 2. There is no OAuth token handling and no HTTP call to OpenAI:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
OpenCode 2 TUI ──spawn──▶ codex app-server ──JSON-RPC over stdio──▶ account/rateLimits/read
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
1. The TUI plugin starts a **short-lived `codex app-server`** process.
|
|
43
|
+
2. It performs the `initialize` handshake, sends `initialized`, then asks for `account/rateLimits/read`.
|
|
44
|
+
3. It closes stdin so Codex exits on its own, and kills the child if it lingers.
|
|
45
|
+
4. The raw payload is normalized defensively: anything missing or malformed is shown as **unavailable** instead of being guessed.
|
|
46
|
+
|
|
47
|
+
No model turn is created, so **checking the panel never consumes quota**. The plugin never reads `auth.json` or any credential snapshot, and it never sends your token anywhere.
|
|
48
|
+
|
|
49
|
+
## Requirements
|
|
50
|
+
|
|
51
|
+
- **OpenCode 2** with the TUI plugin API (`@opencode/plugin/tui`).
|
|
52
|
+
- **Codex CLI** installed and logged in with the ChatGPT account whose quota you want to see:
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
codex --version
|
|
56
|
+
codex login status
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
If it is not installed or not logged in, the panel says so instead of showing numbers.
|
|
60
|
+
|
|
61
|
+
> The panel reports the account signed into your **local Codex CLI**. If OpenCode uses a different OpenAI account, the numbers you see are still correct—for the Codex account, not necessarily the OpenCode one.
|
|
62
|
+
|
|
63
|
+
## Quick install
|
|
64
|
+
|
|
65
|
+
One command from npm; the source options below are for development or offline setups.
|
|
66
|
+
|
|
67
|
+
### Option A — from npm (recommended)
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
opencode plugin add opencode-openai-quota
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
This installs the package and configures it globally. The server and TUI parts load together, so there is no config file to edit by hand.
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
opencode plugin add opencode-openai-quota@0.3.0 # pin an exact version
|
|
77
|
+
opencode plugin update # update every package plugin
|
|
78
|
+
opencode plugin update opencode-openai-quota # update only this one
|
|
79
|
+
opencode plugin list # see what is installed
|
|
80
|
+
opencode plugin remove opencode-openai-quota # uninstall
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
If OpenCode is already running, reload with `/reload`. The panel appears in the next session's sidebar.
|
|
84
|
+
|
|
85
|
+
### Option B — installer script (Windows, from source)
|
|
86
|
+
|
|
87
|
+
```powershell
|
|
88
|
+
git clone https://github.com/arturobuendia/opencode-openai-quota.git
|
|
89
|
+
cd opencode-openai-quota
|
|
90
|
+
.\install.ps1
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The script copies the plugin into OpenCode's global discovery folder:
|
|
94
|
+
|
|
95
|
+
```text
|
|
96
|
+
<config>/plugins/openai-quota/
|
|
97
|
+
├── index.ts # server part
|
|
98
|
+
├── tui.tsx # TUI part
|
|
99
|
+
├── package.json
|
|
100
|
+
├── tsconfig.json
|
|
101
|
+
└── src/ # codex.js, quota.js, refresh.js, index.ts, tui.tsx
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`<config>` is `%USERPROFILE%\.config\opencode` on Windows, `~/.config/opencode` (or `$XDG_CONFIG_HOME/opencode`) on macOS/Linux, or the directory selected by `OPENCODE_CONFIG_DIR`.
|
|
105
|
+
|
|
106
|
+
An existing install is moved to `<config>/openai-quota-backups/` before the copy—never inside `plugins/`, where OpenCode would discover both copies. The script does not touch `opencode.json`, `cli.json`, `auth.json`, or your other plugins.
|
|
107
|
+
|
|
108
|
+
### Option C — manual, any platform
|
|
109
|
+
|
|
110
|
+
Copy the same files into the discovery folder:
|
|
111
|
+
|
|
112
|
+
```sh
|
|
113
|
+
mkdir -p ~/.config/opencode/plugins/openai-quota
|
|
114
|
+
cp -R src index.ts tui.tsx tsconfig.json package.json ~/.config/opencode/plugins/openai-quota/
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Or point OpenCode at the checkout from `opencode.json`, which loads both the server and TUI parts of the package:
|
|
118
|
+
|
|
119
|
+
```jsonc
|
|
120
|
+
{
|
|
121
|
+
"$schema": "https://opencode.ai/config.json",
|
|
122
|
+
"plugins": [
|
|
123
|
+
"C:/Users/YOU/Projects/opencode-openai-quota"
|
|
124
|
+
]
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`file:///C:/Users/YOU/Projects/opencode-openai-quota` and relative paths such as `./plugins/openai-quota` are accepted too. Use `cli.json` instead when the TUI talks to a **remote** server and the plugin must run locally:
|
|
129
|
+
|
|
130
|
+
```json
|
|
131
|
+
{
|
|
132
|
+
"plugins": ["C:/Users/YOU/Projects/opencode-openai-quota"]
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Keep the checkout in place after configuring it. OpenCode reloads plugins when configuration changes; if the panel does not appear, run `/reload`.
|
|
137
|
+
|
|
138
|
+
### Option D — install with an AI agent
|
|
139
|
+
|
|
140
|
+
Paste this into any coding agent with shell access. It installs the published package, so no file is copied and no credential is touched.
|
|
141
|
+
|
|
142
|
+
```text
|
|
143
|
+
Install the "OpenAI Quota for OpenCode 2" plugin on this machine.
|
|
144
|
+
|
|
145
|
+
Rules for this task:
|
|
146
|
+
- Do not read, print, or copy any OAuth token, auth.json, or credential store.
|
|
147
|
+
- Do not hand-edit opencode.json, cli.json, auth.json, the OpenCode database, or other plugins.
|
|
148
|
+
- Ask before installing system packages or changing PATH.
|
|
149
|
+
- Stop and report instead of guessing when a prerequisite is missing.
|
|
150
|
+
|
|
151
|
+
Steps:
|
|
152
|
+
1. Check that `opencode --version` reports OpenCode 2. If it is v1 or missing, stop.
|
|
153
|
+
2. Check the Codex CLI with `codex --version`, then `codex login status`. If Codex is
|
|
154
|
+
missing or not logged in to a ChatGPT account, stop and report what I have to run.
|
|
155
|
+
3. Install the plugin with `opencode plugin add opencode-openai-quota`. If that command
|
|
156
|
+
fails, report the exact error and stop; do not clone the repository or copy files as a
|
|
157
|
+
workaround.
|
|
158
|
+
4. Confirm with `opencode plugin list` that the plugin is listed.
|
|
159
|
+
5. Reload OpenCode with `/reload`, or tell me to restart the TUI if the sidebar does not
|
|
160
|
+
refresh.
|
|
161
|
+
6. Verify: the session sidebar must show an "OpenAI Quota" panel. If it says
|
|
162
|
+
"Cannot read Codex quota", report the exact message under it and stop.
|
|
163
|
+
|
|
164
|
+
Finish by reporting the installed version and whether the panel rendered.
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The prompt never asks for your token and never hand-edits your configuration. If an agent proposes reading `auth.json` or writing config files directly, it went off-script—stop it and point it back here.
|
|
168
|
+
|
|
169
|
+
## What appears in the sidebar
|
|
170
|
+
|
|
171
|
+
```text
|
|
172
|
+
OpenAI Quota
|
|
173
|
+
Updated just now
|
|
174
|
+
|
|
175
|
+
Session (5h)
|
|
176
|
+
━━━━━━━━━━━━━━━━───────
|
|
177
|
+
68% remaining
|
|
178
|
+
Resets in 4h 12m
|
|
179
|
+
|
|
180
|
+
Weekly
|
|
181
|
+
━━━────────────────────
|
|
182
|
+
9% remaining
|
|
183
|
+
Resets in 2d 7h
|
|
184
|
+
|
|
185
|
+
───────────────────────
|
|
186
|
+
2 available banked resets
|
|
187
|
+
Next expires in 9d
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
- **Window titles** follow the duration Codex reports: `Session (5h)` and `Weekly` for the usual windows, otherwise the generic name plus the real duration.
|
|
191
|
+
- **Balances** are remaining percentages; the bar length follows the value, so a tiny nonzero balance is still visibly nonzero.
|
|
192
|
+
- **Resets** are relative (`in 3d 4h`), recalculated every 30 seconds. A window the service gives no reset time for says `Reset unavailable`.
|
|
193
|
+
- **Banked resets** are manual redemption benefits, separate from automatic resets and purchased credits. The panel shows a genuine `0`, says `Banked resets: unavailable` when the count is missing or malformed, and only shows an expiration when every available reset has a complete, valid timestamp. It never redeems anything. See [OpenAI's reset guide](https://help.openai.com/en/articles/20001498-how-resets-work-in-codex) for the distinctions and offer terms.
|
|
194
|
+
|
|
195
|
+
### Reading the status line
|
|
196
|
+
|
|
197
|
+
| Panel content | Meaning |
|
|
198
|
+
| --- | --- |
|
|
199
|
+
| `Updated just now` / `Updated 4m ago` | Time of the last successful read. |
|
|
200
|
+
| `Checking · Updated 4m ago` | A read is running; the previous numbers stay visible. |
|
|
201
|
+
| `Reading Codex…` | First read in progress, nothing to show yet. |
|
|
202
|
+
| `Unavailable` | No successful read yet and none in flight. |
|
|
203
|
+
| `Refresh failed · showing saved data` | The last read failed; the previous good numbers stay on screen. |
|
|
204
|
+
| `Cannot read Codex quota` | No usable numbers yet plus the reason, for example Codex missing from `PATH`, not logged in, or the 15-second timeout. |
|
|
205
|
+
| `Try /quota-refresh` | The suggested next step while nothing is displayed. |
|
|
206
|
+
|
|
207
|
+
## When it refreshes
|
|
208
|
+
|
|
209
|
+
- On startup.
|
|
210
|
+
- **7 seconds after a session execution succeeds**, so Codex can publish the new numbers first.
|
|
211
|
+
- Every **2 minutes**, as a safety net.
|
|
212
|
+
- Immediately when a displayed reset time has passed, so a reset window does not sit at 0% until the next poll.
|
|
213
|
+
- The countdown text alone re-renders every **30 seconds**; the panel does not re-read Codex for that.
|
|
214
|
+
|
|
215
|
+
Automatic attempts respect a **60-second cooldown**, and overlapping requests share a single read instead of starting a second `codex` process. A manual refresh skips the cooldown; reset-triggered and post-execution reads wait for it like any other automatic attempt.
|
|
216
|
+
|
|
217
|
+
### Manual refresh
|
|
218
|
+
|
|
219
|
+
- Command palette: **Refresh OpenAI quota** (group **OpenAI quota**).
|
|
220
|
+
- Slash command: **`/quota-refresh`**.
|
|
221
|
+
|
|
222
|
+
## Privacy and network behavior
|
|
223
|
+
|
|
224
|
+
- The plugin makes **no HTTP requests of its own** and holds **no credentials**.
|
|
225
|
+
- It does not read `auth.json`, `OPENCODE_AUTH_CONTENT`, or any token file.
|
|
226
|
+
- The only side effect is a local child process per read: `codex app-server`, with a 15-second timeout, stdin closed to let it exit, and a kill if it does not.
|
|
227
|
+
- Codex `stderr` is drained but never rendered, so diagnostics that may contain private data cannot leak into the sidebar.
|
|
228
|
+
- Errors shown in the panel never include tokens or full command output.
|
|
229
|
+
|
|
230
|
+
## Configuration
|
|
231
|
+
|
|
232
|
+
| Variable | Default | Purpose |
|
|
233
|
+
| --- | --- | --- |
|
|
234
|
+
| `OPENCODE_QUOTA_CODEX` | `codex` | Command used to start Codex. Point it at another executable or shim. On Windows it runs through the shell, so only use a value you trust. |
|
|
235
|
+
|
|
236
|
+
## Compatibility
|
|
237
|
+
|
|
238
|
+
Targets **OpenCode 2** and its built-in TUI plugin API. The published package declares `@opencode/plugin` as a dependency and `@opentui/core`, `@opentui/solid`, and `solid-js` as peers, so OpenCode supplies the rendering runtime and you install nothing by hand. No `engines.opencode` range is declared; older or newer TUI APIs are not guaranteed.
|
|
239
|
+
|
|
240
|
+
## Troubleshooting
|
|
241
|
+
|
|
242
|
+
- **No panel:** confirm `opencode plugin list` shows the plugin (or that a source install exists at `<config>/plugins/openai-quota`), then run `/reload`.
|
|
243
|
+
- **`Cannot read Codex quota`:** read the message under it. `codex` missing from `PATH` → install the Codex CLI. Not logged in → run `codex login`. Timeout → the app-server took longer than 15 seconds.
|
|
244
|
+
- **Numbers look wrong:** check which account the local CLI is using with `codex login status`. The panel follows Codex, not OpenCode.
|
|
245
|
+
- **Verify outside OpenCode:** `npm run probe` prints the same snapshot the panel uses, or fails with the underlying reason.
|
|
246
|
+
- **Banked reset expiration says unavailable:** the count can still be right while the detail list is missing, incomplete, or uses an unknown expiration. The plugin never infers an expiration from a normal reset time.
|
|
247
|
+
- **No weekly or no session row:** Codex only returned one window. The plugin shows what exists and never invents the other.
|
|
248
|
+
|
|
249
|
+
## Development
|
|
250
|
+
|
|
251
|
+
Node.js **20 or newer** is required for the development scripts. The package has one runtime dependency (`@opencode/plugin`) plus the OpenTUI peers that OpenCode provides.
|
|
252
|
+
|
|
253
|
+
```sh
|
|
254
|
+
npm test # offline suite: codex handshake, normalization, refresh, labels
|
|
255
|
+
npm run probe # one real read through your local codex app-server
|
|
256
|
+
npm run check # syntax-check the source modules, then run the suite
|
|
257
|
+
npm pack --dry-run # inspect exactly what would be published
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Tests use a synthetic Codex fixture (`test/fake-codex.mjs`) and injected clocks, so they stay offline and never read real credentials.
|
|
261
|
+
|
|
262
|
+
| Path | Role |
|
|
263
|
+
| --- | --- |
|
|
264
|
+
| `src/index.ts` | Server part. Registers no resources; the work happens in the TUI. |
|
|
265
|
+
| `src/tui.tsx` | Panel, sidebar slot, `/quota-refresh` command, scheduling, and disposal. |
|
|
266
|
+
| `src/codex.js` | Spawns `codex app-server` and runs the JSON-RPC handshake. |
|
|
267
|
+
| `src/quota.js` | Normalizes the payload; formats percentages, bars, and relative times. |
|
|
268
|
+
| `src/refresh.js` | Cooldown and coalescing so only one read runs at a time. |
|
|
269
|
+
| `install.ps1`, `uninstall.ps1` | Source checkout install/removal for the discovery folder. |
|
|
270
|
+
|
|
271
|
+
## Publishing (maintainers)
|
|
272
|
+
|
|
273
|
+
```sh
|
|
274
|
+
npm login # once per machine
|
|
275
|
+
npm run check # also runs automatically via prepublishOnly
|
|
276
|
+
npm version patch|minor|major # bumps package.json, commits, tags
|
|
277
|
+
npm publish --access public
|
|
278
|
+
git push --follow-tags
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
The `files` field limits the tarball to `src/`, the two root entrypoints, and `tsconfig.json`; tests, scripts, installers, and assets stay in the repository. Pinned installs keep their version, and `opencode plugin update` moves unpinned ones forward.
|
|
282
|
+
|
|
283
|
+
## Uninstall
|
|
284
|
+
|
|
285
|
+
```sh
|
|
286
|
+
opencode plugin remove opencode-openai-quota
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
If you installed from a source checkout instead, run `.\uninstall.ps1`, which removes only `<config>/plugins/openai-quota`. If you configured a path in `opencode.json`/`cli.json`, delete that entry. Run `/reload` afterwards; backups under `<config>/openai-quota-backups/` are yours to delete.
|
|
290
|
+
|
|
291
|
+
## License
|
|
292
|
+
|
|
293
|
+
MIT. See [LICENSE](LICENSE).
|
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./src/index.ts"
|
package/package.json
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "opencode-openai-quota",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-openai-quota",
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "OpenAI Codex subscription quota in your OpenCode 2 sidebar, read locally from the Codex CLI.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./tui": "./src/tui.tsx"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"index.ts",
|
|
14
|
+
"tui.tsx",
|
|
15
|
+
"tsconfig.json"
|
|
16
|
+
],
|
|
17
|
+
"keywords": [
|
|
18
|
+
"opencode",
|
|
19
|
+
"opencode-plugin",
|
|
20
|
+
"openai",
|
|
21
|
+
"codex",
|
|
22
|
+
"quota",
|
|
23
|
+
"tui"
|
|
24
|
+
],
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/arturobuendia/opencode-openai-quota.git"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/arturobuendia/opencode-openai-quota#readme",
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/arturobuendia/opencode-openai-quota/issues"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@opencode/plugin": "^2.2.0"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@opentui/core": ">=0.6.6",
|
|
38
|
+
"@opentui/solid": ">=0.6.6",
|
|
39
|
+
"solid-js": ">=1.9.0"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"test": "node --test test/*.test.mjs",
|
|
43
|
+
"probe": "node scripts/probe.mjs",
|
|
44
|
+
"check": "node --check src/codex.js && node --check src/quota.js && node --check src/refresh.js && npm test",
|
|
45
|
+
"prepublishOnly": "npm run check"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/codex.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { spawn } from "node:child_process"
|
|
2
|
+
import { createInterface } from "node:readline"
|
|
3
|
+
|
|
4
|
+
const quoteForShell = (value) => /[\s"]/u.test(value) ? `"${value.replace(/"/g, '""')}"` : value
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One short-lived local Codex app-server per refresh.
|
|
8
|
+
* No credential files are read. No inference turn is created.
|
|
9
|
+
* The protocol is JSON-lines with an initialize handshake before quota lookup.
|
|
10
|
+
*/
|
|
11
|
+
export async function readCodexRateLimits(options = {}) {
|
|
12
|
+
const command = options.command ?? process.env.OPENCODE_QUOTA_CODEX ?? "codex"
|
|
13
|
+
const args = options.args ?? ["app-server"]
|
|
14
|
+
const timeoutMs = options.timeoutMs ?? 15_000
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
let done = false
|
|
17
|
+
let initialized = false
|
|
18
|
+
let child
|
|
19
|
+
let timer
|
|
20
|
+
let lines
|
|
21
|
+
let killTimer
|
|
22
|
+
const finish = (error, value) => {
|
|
23
|
+
if (done) return
|
|
24
|
+
done = true
|
|
25
|
+
clearTimeout(timer)
|
|
26
|
+
lines?.close()
|
|
27
|
+
// End our owned child; it never outlives a refresh.
|
|
28
|
+
// Closing stdin lets Codex exit gracefully; allow a brief grace period
|
|
29
|
+
// before killing our owned process (important for Windows .cmd shims).
|
|
30
|
+
try { child?.stdin?.end() } catch {}
|
|
31
|
+
killTimer = setTimeout(() => { try { child?.kill() } catch {} }, 800)
|
|
32
|
+
killTimer.unref?.()
|
|
33
|
+
if (error) reject(error)
|
|
34
|
+
else resolve(value)
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
// shell on Windows is needed for common npm/bun codex.cmd shims.
|
|
38
|
+
// command and args are constant by default; do not set the custom
|
|
39
|
+
// executable override to arbitrary/untrusted shell text.
|
|
40
|
+
child = process.platform === "win32"
|
|
41
|
+
// Windows shell concatenates tokens, so quote paths such as
|
|
42
|
+
// "C:\Program Files\nodejs\node.exe" and pass no separate args.
|
|
43
|
+
? spawn([command, ...args].map(quoteForShell).join(" "), {
|
|
44
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
45
|
+
windowsHide: true,
|
|
46
|
+
shell: true,
|
|
47
|
+
env: process.env,
|
|
48
|
+
})
|
|
49
|
+
: spawn(command, args, {
|
|
50
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
51
|
+
windowsHide: true,
|
|
52
|
+
env: process.env,
|
|
53
|
+
})
|
|
54
|
+
} catch {
|
|
55
|
+
finish(new Error("Could not start the Codex CLI. Install Codex and check your PATH."))
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
timer = setTimeout(() => finish(new Error(`Codex did not answer in time (${Math.round(timeoutMs / 1000)} s).`)), timeoutMs)
|
|
59
|
+
// Drain diagnostics but don't expose potentially private stderr content.
|
|
60
|
+
child.stderr?.resume()
|
|
61
|
+
child.on("error", () => finish(new Error("Codex CLI not found or not executable. Run: codex --version")))
|
|
62
|
+
child.on("close", (code) => {
|
|
63
|
+
if (killTimer) clearTimeout(killTimer)
|
|
64
|
+
if (!done) finish(new Error(`Codex exited before returning quota (exit ${code ?? "unknown"}). Check: codex login status`))
|
|
65
|
+
})
|
|
66
|
+
const send = (value) => {
|
|
67
|
+
if (done || !child.stdin?.writable) return
|
|
68
|
+
child.stdin.write(`${JSON.stringify(value)}\n`)
|
|
69
|
+
}
|
|
70
|
+
lines = createInterface({ input: child.stdout, crlfDelay: Infinity })
|
|
71
|
+
lines.on("line", (line) => {
|
|
72
|
+
if (done || !line.trim()) return
|
|
73
|
+
let msg
|
|
74
|
+
try { msg = JSON.parse(line) } catch { return }
|
|
75
|
+
if (msg.id === 0) {
|
|
76
|
+
if (msg.error) { finish(new Error("Codex rejected the initialization handshake.")); return }
|
|
77
|
+
if (!msg.result) { finish(new Error("Codex did not confirm initialize.")); return }
|
|
78
|
+
initialized = true
|
|
79
|
+
send({ method: "initialized", params: {} })
|
|
80
|
+
send({ id: 1, method: "account/rateLimits/read" })
|
|
81
|
+
} else if (msg.id === 1 && initialized) {
|
|
82
|
+
if (msg.error) {
|
|
83
|
+
finish(new Error("Could not read quota. Check that Codex is logged in to a ChatGPT account."))
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
finish(null, msg.result)
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
send({
|
|
90
|
+
id: 0,
|
|
91
|
+
method: "initialize",
|
|
92
|
+
params: {
|
|
93
|
+
clientInfo: { name: "opencode_openai_quota", title: "OpenCode OpenAI Quota", version: "0.3.0" },
|
|
94
|
+
// The rate-limit method is documented as stable; no experimental opt-in needed.
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// OpenCode V2 package entrypoint. No server-side resources are required:
|
|
2
|
+
// its TUI plugin lives in ./tui and runs against Codex installed LOCALLY.
|
|
3
|
+
import { Plugin } from "@opencode/plugin"
|
|
4
|
+
|
|
5
|
+
export default Plugin.define({
|
|
6
|
+
id: "openai-quota.server",
|
|
7
|
+
setup() {},
|
|
8
|
+
})
|
package/src/quota.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/** Normalizes the official account/rateLimits/read response, never guessing missing figures. */
|
|
2
|
+
const object = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : null
|
|
3
|
+
const num = (value) => typeof value === "number" && Number.isFinite(value) ? value : null
|
|
4
|
+
const clamp = (value) => Math.min(100, Math.max(0, value))
|
|
5
|
+
|
|
6
|
+
function asWindow(input) {
|
|
7
|
+
const data = object(input)
|
|
8
|
+
const used = num(data?.usedPercent)
|
|
9
|
+
if (used === null || used < 0 || used > 100) return null
|
|
10
|
+
const minutes = num(data?.windowDurationMins)
|
|
11
|
+
const seconds = num(data?.resetsAt)
|
|
12
|
+
return {
|
|
13
|
+
usedPercent: used,
|
|
14
|
+
remainingPercent: clamp(100 - used),
|
|
15
|
+
windowDurationMins: minutes !== null && minutes > 0 ? minutes : null,
|
|
16
|
+
resetsAt: seconds !== null && seconds > 0 ? seconds * 1000 : null,
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function normalizeQuota(payload, now = Date.now()) {
|
|
21
|
+
const result = object(payload)
|
|
22
|
+
if (!result) throw new Error("Codex returned an invalid rate-limit payload")
|
|
23
|
+
const byId = object(result.rateLimitsByLimitId)
|
|
24
|
+
// Codex can return limits for multiple products. Only show Codex's bucket.
|
|
25
|
+
const bucket = object(byId?.codex) ?? object(result.rateLimits)
|
|
26
|
+
if (!bucket) throw new Error("Codex returned no rate limits for this ChatGPT account")
|
|
27
|
+
|
|
28
|
+
const a = asWindow(bucket.primary)
|
|
29
|
+
const b = asWindow(bucket.secondary)
|
|
30
|
+
if (!a && !b) throw new Error("Codex returned no recognizable usage windows")
|
|
31
|
+
const windows = [a, b].filter(Boolean)
|
|
32
|
+
const knownWeekly = windows.find((w) => w.windowDurationMins === 10080) ?? null
|
|
33
|
+
const session = windows.find((w) => w.windowDurationMins === 300)
|
|
34
|
+
?? windows.filter((w) => w !== knownWeekly).reduce((first, current) =>
|
|
35
|
+
!first || (current.windowDurationMins ?? Infinity) < (first.windowDurationMins ?? Infinity) ? current : first, null)
|
|
36
|
+
const weekly = knownWeekly ?? (windows.length > 1 ? windows.find((w) => w !== session) : null)
|
|
37
|
+
// The caller displays the raw duration when it's not one of Codex's usual windows.
|
|
38
|
+
const credits = object(result.rateLimitResetCredits)
|
|
39
|
+
const count = num(credits?.availableCount)
|
|
40
|
+
const bankedAvailable = count !== null && count >= 0 && Number.isInteger(count) ? count : null
|
|
41
|
+
const detailList = Array.isArray(credits?.credits) ? credits.credits : null
|
|
42
|
+
let nextExpiration = null
|
|
43
|
+
if (bankedAvailable !== null && bankedAvailable > 0 && detailList?.length) {
|
|
44
|
+
const dates = detailList
|
|
45
|
+
.filter((c) => object(c)?.status === "available")
|
|
46
|
+
.map((c) => num(c.expiresAt))
|
|
47
|
+
.filter((v) => v !== null && v > 0)
|
|
48
|
+
// Details can be capped or incomplete, so don't call this the earliest
|
|
49
|
+
// expiration unless ALL available credits are described with valid times.
|
|
50
|
+
if (dates.length === bankedAvailable) nextExpiration = Math.min(...dates) * 1000
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
status: "ok",
|
|
54
|
+
updatedAt: now,
|
|
55
|
+
session,
|
|
56
|
+
weekly,
|
|
57
|
+
bankedAvailable,
|
|
58
|
+
nextExpiration,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function remainingText(epochMillis, now = Date.now()) {
|
|
63
|
+
if (epochMillis === null || !Number.isFinite(epochMillis)) return "unavailable"
|
|
64
|
+
const remaining = Math.max(0, Math.ceil((epochMillis - now) / 60_000))
|
|
65
|
+
if (remaining === 0) return "now"
|
|
66
|
+
const days = Math.floor(remaining / 1440)
|
|
67
|
+
const hours = Math.floor((remaining % 1440) / 60)
|
|
68
|
+
const minutes = remaining % 60
|
|
69
|
+
return "in " + [days && `${days}d`, hours && `${hours}h`, (minutes || (!days && !hours)) && `${minutes}m`].filter(Boolean).join(" ")
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function progress(percent, width = 19) {
|
|
73
|
+
if (!Number.isFinite(percent)) return { filled: "", empty: "─".repeat(width) }
|
|
74
|
+
const n = Math.max(0, Math.min(width, Math.ceil((percent / 100) * width)))
|
|
75
|
+
return { filled: "━".repeat(n), empty: "─".repeat(width - n) }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function durationLabel(window, fallback) {
|
|
79
|
+
if (!window) return fallback
|
|
80
|
+
const duration = window.windowDurationMins
|
|
81
|
+
if (duration === 300) return "Session (5h)"
|
|
82
|
+
if (duration === 10080) return "Weekly"
|
|
83
|
+
if (duration && duration % 1440 === 0) return `${fallback} (${duration / 1440}d)`
|
|
84
|
+
if (duration && duration % 60 === 0) return `${fallback} (${duration / 60}h)`
|
|
85
|
+
return fallback
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Friendly UI status, without lying about freshness during a failed read. */
|
|
89
|
+
export function updatedText(updatedAt, now = Date.now()) {
|
|
90
|
+
if (!Number.isFinite(updatedAt)) return "Never updated"
|
|
91
|
+
const minutes = Math.max(0, Math.floor((now - updatedAt) / 60_000))
|
|
92
|
+
if (minutes < 1) return "Updated just now"
|
|
93
|
+
const days = Math.floor(minutes / 1440)
|
|
94
|
+
const hours = Math.floor((minutes % 1440) / 60)
|
|
95
|
+
const mins = minutes % 60
|
|
96
|
+
const duration = [days && `${days}d`, hours && `${hours}h`, mins && `${mins}m`].filter(Boolean).join(" ")
|
|
97
|
+
return `Updated ${duration} ago`
|
|
98
|
+
}
|
package/src/refresh.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coalesces concurrent quota reads and enforces a minimum time between
|
|
3
|
+
* automatic attempts. A manual refresh bypasses the cooldown, but never
|
|
4
|
+
* starts another Codex process while one is running.
|
|
5
|
+
*/
|
|
6
|
+
export function createQuotaRefresher({ read, now = () => Date.now(), cooldownMs = 60_000 }) {
|
|
7
|
+
let lastAttempt = null
|
|
8
|
+
let pending = null
|
|
9
|
+
return {
|
|
10
|
+
getLastAttempt: () => lastAttempt,
|
|
11
|
+
refresh({ force = false } = {}) {
|
|
12
|
+
if (pending) return pending
|
|
13
|
+
if (!force && lastAttempt !== null && now() - lastAttempt < cooldownMs) {
|
|
14
|
+
return Promise.resolve(false)
|
|
15
|
+
}
|
|
16
|
+
lastAttempt = now()
|
|
17
|
+
pending = Promise.resolve().then(read).then(() => true).finally(() => { pending = null })
|
|
18
|
+
return pending
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/tui.tsx
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin/tui"
|
|
2
|
+
import { createSignal, Show } from "solid-js"
|
|
3
|
+
import { readCodexRateLimits } from "./codex.js"
|
|
4
|
+
import { createQuotaRefresher } from "./refresh.js"
|
|
5
|
+
import { durationLabel, normalizeQuota, progress, remainingText, updatedText } from "./quota.js"
|
|
6
|
+
|
|
7
|
+
type QuotaWindowData = {
|
|
8
|
+
usedPercent: number
|
|
9
|
+
remainingPercent: number
|
|
10
|
+
windowDurationMins: number | null
|
|
11
|
+
resetsAt: number | null
|
|
12
|
+
}
|
|
13
|
+
type QuotaSnapshot = {
|
|
14
|
+
updatedAt: number
|
|
15
|
+
session: QuotaWindowData | null
|
|
16
|
+
weekly: QuotaWindowData | null
|
|
17
|
+
bankedAvailable: number | null
|
|
18
|
+
nextExpiration: number | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The TUI plugin executes in the LOCAL terminal. It queries the user's Codex
|
|
22
|
+
// app-server, and never needs the OpenCode OAuth token or a backend RPC server.
|
|
23
|
+
export default Plugin.define({
|
|
24
|
+
id: "openai-quota.tui",
|
|
25
|
+
setup(context) {
|
|
26
|
+
const theme = context.theme
|
|
27
|
+
const [quota, setQuota] = createSignal<QuotaSnapshot | null>(null)
|
|
28
|
+
const [error, setError] = createSignal("")
|
|
29
|
+
const [busy, setBusy] = createSignal(false)
|
|
30
|
+
const [clock, setClock] = createSignal(Date.now())
|
|
31
|
+
let disposed = false
|
|
32
|
+
let eventTimer: ReturnType<typeof setTimeout> | undefined
|
|
33
|
+
|
|
34
|
+
async function readQuota() {
|
|
35
|
+
if (disposed) return
|
|
36
|
+
setBusy(true)
|
|
37
|
+
try {
|
|
38
|
+
const raw = await readCodexRateLimits()
|
|
39
|
+
const snapshot = normalizeQuota(raw) as QuotaSnapshot
|
|
40
|
+
if (!disposed) {
|
|
41
|
+
setQuota(snapshot)
|
|
42
|
+
setError("")
|
|
43
|
+
setClock(Date.now())
|
|
44
|
+
}
|
|
45
|
+
} catch (cause) {
|
|
46
|
+
if (!disposed) setError(cause instanceof Error ? cause.message : "Codex quota unavailable")
|
|
47
|
+
} finally {
|
|
48
|
+
if (!disposed) setBusy(false)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const refresher = createQuotaRefresher({
|
|
53
|
+
read: readQuota,
|
|
54
|
+
cooldownMs: 60_000,
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
function refresh(force = false) {
|
|
58
|
+
return refresher.refresh({ force }).catch(() => false)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function QuotaWindow(props: { window: QuotaWindowData; name: string }) {
|
|
62
|
+
const percent = () => Math.round(props.window.remainingPercent)
|
|
63
|
+
const bar = () => progress(props.window.remainingPercent, 23)
|
|
64
|
+
const fg = () => percent() <= 10 ? "#F87171" : percent() <= 25 ? "#FBBF24" : theme.text.base
|
|
65
|
+
const resetLabel = () => {
|
|
66
|
+
const value = remainingText(props.window.resetsAt, clock())
|
|
67
|
+
return value === "unavailable" ? "Reset unavailable" : `Resets ${value}`
|
|
68
|
+
}
|
|
69
|
+
return (
|
|
70
|
+
<box flexDirection="column" paddingTop={1}>
|
|
71
|
+
<text fg={theme.text.base}><b>{durationLabel(props.window, props.name)}</b></text>
|
|
72
|
+
<box flexDirection="row">
|
|
73
|
+
<text fg={fg()}>{bar().filled}</text>
|
|
74
|
+
<text fg={theme.text.muted}>{bar().empty}</text>
|
|
75
|
+
</box>
|
|
76
|
+
<text fg={fg()}>{percent()}% remaining</text>
|
|
77
|
+
<text fg={theme.text.muted}>{resetLabel()}</text>
|
|
78
|
+
</box>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function QuotaPanel() {
|
|
83
|
+
const snap = quota
|
|
84
|
+
const label = () => {
|
|
85
|
+
const data = snap()
|
|
86
|
+
if (!data) return busy() ? "Reading Codex…" : "Unavailable"
|
|
87
|
+
return `${busy() ? "Checking · " : ""}${updatedText(data.updatedAt, clock())}`
|
|
88
|
+
}
|
|
89
|
+
return (
|
|
90
|
+
<box flexDirection="column" paddingTop={1} paddingBottom={1}>
|
|
91
|
+
<text fg={theme.text.base}><b>OpenAI Quota</b></text>
|
|
92
|
+
<text fg={theme.text.muted}>{label()}</text>
|
|
93
|
+
<Show when={snap()}>
|
|
94
|
+
{(snapshot) => (
|
|
95
|
+
<>
|
|
96
|
+
<Show when={snapshot().session}>
|
|
97
|
+
{(value) => <QuotaWindow window={value()} name="Session" />}
|
|
98
|
+
</Show>
|
|
99
|
+
<Show when={snapshot().weekly}>
|
|
100
|
+
{(value) => <QuotaWindow window={value()} name="Weekly" />}
|
|
101
|
+
</Show>
|
|
102
|
+
<box flexDirection="column" paddingTop={1}>
|
|
103
|
+
<text fg={theme.text.muted}>───────────────────────</text>
|
|
104
|
+
<text fg={theme.text.base}>
|
|
105
|
+
{snapshot().bankedAvailable === null
|
|
106
|
+
? "Banked resets: unavailable"
|
|
107
|
+
: `${snapshot().bankedAvailable} available banked resets`}
|
|
108
|
+
</text>
|
|
109
|
+
<text fg={theme.text.muted}>
|
|
110
|
+
{snapshot().nextExpiration
|
|
111
|
+
? `Next expires ${remainingText(snapshot().nextExpiration, clock())}`
|
|
112
|
+
: "Next expiration unavailable"}
|
|
113
|
+
</text>
|
|
114
|
+
</box>
|
|
115
|
+
</>
|
|
116
|
+
)}
|
|
117
|
+
</Show>
|
|
118
|
+
<Show when={Boolean(error())}>
|
|
119
|
+
<box flexDirection="column" paddingTop={1}>
|
|
120
|
+
<text fg="#FBBF24">{snap() ? "Refresh failed · showing saved data" : "Cannot read Codex quota"}</text>
|
|
121
|
+
<Show when={!snap()}>
|
|
122
|
+
<text fg={theme.text.muted}>{error()}</text>
|
|
123
|
+
<text fg={theme.text.muted}>Try /quota-refresh</text>
|
|
124
|
+
</Show>
|
|
125
|
+
</box>
|
|
126
|
+
</Show>
|
|
127
|
+
</box>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const removeSidebar = context.ui.slot({
|
|
132
|
+
append: "sidebar.content",
|
|
133
|
+
render: () => <QuotaPanel />,
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
// These commands belong only to this plugin; never shadow other statusline plugins.
|
|
137
|
+
context.keymap.layer(() => ({
|
|
138
|
+
mode: "global",
|
|
139
|
+
commands: [{
|
|
140
|
+
id: "openai-quota.refresh",
|
|
141
|
+
title: "Refresh OpenAI quota",
|
|
142
|
+
group: "OpenAI quota",
|
|
143
|
+
palette: true,
|
|
144
|
+
slash: { name: "quota-refresh" },
|
|
145
|
+
run: () => { void refresh(true) },
|
|
146
|
+
}],
|
|
147
|
+
}))
|
|
148
|
+
|
|
149
|
+
// Refresh after a completed OpenCode execution. Let Codex publish the new
|
|
150
|
+
// numbers for a few seconds first, then enforce a one-minute cooldown.
|
|
151
|
+
const stopExecution = context.data.on("session.execution.succeeded", () => {
|
|
152
|
+
if (eventTimer) clearTimeout(eventTimer)
|
|
153
|
+
eventTimer = setTimeout(() => {
|
|
154
|
+
eventTimer = undefined
|
|
155
|
+
if (!disposed) void refresh()
|
|
156
|
+
}, 7_000)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
// Existing safety net: periodic reads, live relative times, no per-render IO.
|
|
160
|
+
void refresh()
|
|
161
|
+
const polling = setInterval(() => { if (!disposed) void refresh() }, 120_000)
|
|
162
|
+
const ticking = setInterval(() => {
|
|
163
|
+
if (disposed) return
|
|
164
|
+
const now = Date.now()
|
|
165
|
+
setClock(now)
|
|
166
|
+
const snapshot = quota()
|
|
167
|
+
// Prevent showing a zero-quota window indefinitely after it resets.
|
|
168
|
+
if (snapshot && [snapshot.session?.resetsAt, snapshot.weekly?.resetsAt]
|
|
169
|
+
.some((when) => when !== null && when !== undefined && when <= now)) {
|
|
170
|
+
void refresh()
|
|
171
|
+
}
|
|
172
|
+
}, 30_000)
|
|
173
|
+
|
|
174
|
+
return () => {
|
|
175
|
+
disposed = true
|
|
176
|
+
clearInterval(polling)
|
|
177
|
+
clearInterval(ticking)
|
|
178
|
+
if (eventTimer) clearTimeout(eventTimer)
|
|
179
|
+
stopExecution()
|
|
180
|
+
removeSidebar?.()
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
})
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"jsx": "react-jsx",
|
|
7
|
+
"jsxImportSource": "@opentui/solid",
|
|
8
|
+
"allowJs": true,
|
|
9
|
+
"allowImportingTsExtensions": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"noEmit": true
|
|
12
|
+
}
|
|
13
|
+
}
|
package/tui.tsx
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./src/tui.tsx"
|