opencode-go-usage-tui 1.0.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/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # opencode-go-usage-tui
2
+
3
+ 在 OpenCode 侧边栏实时显示 OpenCode Go 额度用量(5小时/每周/每月)。
4
+
5
+ ## 安装
6
+
7
+ ### 方式一:OpenCode 命令安装(推荐)
8
+
9
+ 在 OpenCode 中按 **`Ctrl+P`** 打开命令面板,搜索 **`install plugin`**,输入:
10
+
11
+ ```
12
+ opencode-go-usage-tui@latest
13
+ ```
14
+
15
+ 回车完成安装,重启 OpenCode 后侧边栏即可看到"Go 用量"面板。
16
+
17
+ ### 方式二:npm 全局安装
18
+
19
+ ```bash
20
+ npm install -g opencode-go-usage-tui
21
+ ```
22
+
23
+ 然后在 `~/.config/opencode/tui.json` 的 `plugin` 数组添加:
24
+
25
+ ```json
26
+ {
27
+ "plugin": ["opencode-go-usage-tui@latest"]
28
+ }
29
+ ```
30
+
31
+ 重启 OpenCode 生效。
32
+
33
+ ## 配置
34
+
35
+ ### 方式一:斜杠命令(推荐)
36
+
37
+ 在 TUI 中执行:
38
+
39
+ ```
40
+ /go-config
41
+ ```
42
+
43
+ 按提示输入两项(每人不同,需登录 opencode.ai 获取):
44
+
45
+ 1. **workspace_id**:`wrk_` 开头,在 opencode.ai 控制台 URL 中获取
46
+ 2. **auth cookie**:浏览器 F12 → Application → Cookies → opencode.ai → 复制 `auth` 的值(`Fe26.2*` 开头)
47
+
48
+ 配置保存在 `~/.config/opencode/go-usage-config.json`。
49
+
50
+ ### 方式二:环境变量
51
+
52
+ | 环境变量 | 说明 |
53
+ | -------- | ---- |
54
+ | `OPENCODE_GO_WORKSPACE_ID` | 工作空间 ID |
55
+ | `OPENCODE_GO_AUTH_COOKIE` | auth cookie 值 |
56
+
57
+ ### 方式三:配置文件
58
+
59
+ 手动创建 `~/.config/opencode/go-usage-config.json`:
60
+
61
+ ```json
62
+ {
63
+ "workspace_id": "wrk_xxxxxxxxxxxx",
64
+ "cookie": "Fe26.2*..."
65
+ }
66
+ ```
67
+
68
+ ## 获取 auth cookie
69
+
70
+ 1. Chrome 登录 opencode.ai
71
+ 2. 按 F12 打开开发者工具 → Application → Cookies → 选中 opencode.ai
72
+ 3. 找到 `auth` 一项,复制 Value 值
73
+
74
+ Cookie 有效期约 1 年,过期后重新获取。
75
+
76
+ ## 使用
77
+
78
+ - 侧边栏面板每 60 秒自动刷新(可用 `OPENCODE_GO_CHECK_INTERVAL` 环境变量调整,单位毫秒)
79
+ - 用量超过阈值(默认 80%,可用 `OPENCODE_GO_WARN_THRESHOLD` 调整)显示为红色
80
+ - 点击面板标题可折叠/展开
81
+ - 配置错误时面板会提示"未配置账号",输入 `/go-config` 即可设置
82
+
83
+ ## 开发
84
+
85
+ ```bash
86
+ # 构建
87
+ bun run build.tui.mjs
88
+ # 或
89
+ npm run build
90
+ ```
91
+
92
+ ## License
93
+
94
+ MIT
package/build.tui.mjs ADDED
@@ -0,0 +1,13 @@
1
+ import * as esbuild from "esbuild"
2
+ import { solidPlugin } from "esbuild-plugin-solid"
3
+
4
+ await esbuild.build({
5
+ entryPoints: ["src/index.tsx"],
6
+ outfile: "dist/tui.js",
7
+ format: "esm",
8
+ platform: "node",
9
+ bundle: true,
10
+ external: ["@opencode-ai/*", "@opentui/*", "solid-js", "node:fs", "node:path"],
11
+ plugins: [solidPlugin({ solid: { moduleName: "@opentui/solid", generate: "universal" } })],
12
+ logLevel: "info",
13
+ })
package/dist/tui.js ADDED
@@ -0,0 +1,488 @@
1
+ // src/index.tsx
2
+ import { createComponent as _$createComponent } from "@opentui/solid";
3
+ import { memo as _$memo } from "@opentui/solid";
4
+ import { use as _$use } from "@opentui/solid";
5
+ import { setProp as _$setProp } from "@opentui/solid";
6
+ import { effect as _$effect } from "@opentui/solid";
7
+ import { createTextNode as _$createTextNode } from "@opentui/solid";
8
+ import { insertNode as _$insertNode } from "@opentui/solid";
9
+ import { insert as _$insert } from "@opentui/solid";
10
+ import { createElement as _$createElement } from "@opentui/solid";
11
+ import { createSignal, createEffect, onMount, onCleanup, Show } from "solid-js";
12
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
13
+ import { join, dirname } from "node:path";
14
+ var CHECK_INTERVAL = Number(process?.env?.OPENCODE_GO_CHECK_INTERVAL ?? 6e4);
15
+ var WARN_THRESHOLD = Number(process?.env?.OPENCODE_GO_WARN_THRESHOLD ?? 0.8);
16
+ var CONFIG_DIR = process?.env?.OPENCODE_CONFIG_DIR || (process?.env?.USERPROFILE ? `${process.env.USERPROFILE}\\.config\\opencode` : "") || process?.env?.HOME + "/.config/opencode";
17
+ var CONFIG_FILE = join(CONFIG_DIR, "go-usage-config.json");
18
+ function loadConfig() {
19
+ const env = process?.env ?? {};
20
+ let fileCfg = {};
21
+ try {
22
+ fileCfg = JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
23
+ } catch {
24
+ fileCfg = {};
25
+ }
26
+ const cookieFile = env.OPENCODE_GO_COOKIE_FILE || fileCfg.cookie_file || `${CONFIG_DIR}\\go-auth-cookie.txt`;
27
+ let fileCookie = "";
28
+ try {
29
+ let p = cookieFile;
30
+ if (p === "~") p = (env.HOME || env.USERPROFILE) + "";
31
+ else if (p.startsWith("~/")) p = (env.HOME || env.USERPROFILE || "") + p.slice(1);
32
+ fileCookie = readFileSync(p, "utf8").trim();
33
+ } catch {
34
+ fileCookie = "";
35
+ }
36
+ return {
37
+ workspaceId: env.OPENCODE_GO_WORKSPACE_ID || fileCfg.workspace_id || "",
38
+ authCookie: env.OPENCODE_GO_AUTH_COOKIE || fileCfg.auth_cookie || fileCfg.cookie || fileCookie
39
+ };
40
+ }
41
+ function saveConfig(patch) {
42
+ let fileCfg = {};
43
+ try {
44
+ fileCfg = JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
45
+ } catch {
46
+ fileCfg = {};
47
+ }
48
+ if (patch.workspace_id !== void 0) fileCfg.workspace_id = patch.workspace_id;
49
+ if (patch.cookie !== void 0) fileCfg.cookie = patch.cookie;
50
+ try {
51
+ mkdirSync(dirname(CONFIG_FILE), {
52
+ recursive: true
53
+ });
54
+ writeFileSync(CONFIG_FILE, JSON.stringify(fileCfg, null, 2), "utf8");
55
+ } catch {
56
+ }
57
+ }
58
+ async function fetchUsage(workspaceId, authCookie) {
59
+ if (!workspaceId || !authCookie) return {
60
+ error: "not_configured"
61
+ };
62
+ try {
63
+ const res = await fetch("https://opencode.ai/_server", {
64
+ method: "POST",
65
+ headers: {
66
+ "content-type": "application/json",
67
+ "x-server-id": "c7389bd0e731f80f49593e5ee53835475f4e28594dd6bd83eb229bab753498cd",
68
+ "x-server-instance": "go-usage-tui",
69
+ "cookie": `auth=${authCookie}`,
70
+ "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
71
+ },
72
+ body: JSON.stringify({
73
+ t: {
74
+ t: 9,
75
+ i: 0,
76
+ l: 1,
77
+ a: [{
78
+ t: 1,
79
+ s: workspaceId
80
+ }],
81
+ o: 0
82
+ },
83
+ f: 31,
84
+ m: []
85
+ })
86
+ });
87
+ if (!res.ok) {
88
+ if (res.status === 302) return {
89
+ error: "cookie_expired"
90
+ };
91
+ return null;
92
+ }
93
+ const text = await res.text();
94
+ if (text.includes("/auth/authorize") || text.includes("location")) {
95
+ return {
96
+ error: "cookie_expired"
97
+ };
98
+ }
99
+ const extract = (key) => {
100
+ const m = text.match(new RegExp(`${key}:[^}]*?\\{status:"ok",resetInSec:(\\d+),usagePercent:(\\d+)\\}`));
101
+ if (!m) return null;
102
+ return {
103
+ resetInSec: Number(m[1]),
104
+ usagePercent: Number(m[2])
105
+ };
106
+ };
107
+ const rollingUsage = extract("rollingUsage");
108
+ const weeklyUsage = extract("weeklyUsage");
109
+ const monthlyUsage = extract("monthlyUsage");
110
+ if (!rollingUsage || !weeklyUsage || !monthlyUsage) return null;
111
+ return {
112
+ rollingUsage,
113
+ weeklyUsage,
114
+ monthlyUsage
115
+ };
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+ function formatReset(sec) {
121
+ if (sec <= 0) return "\u5DF2\u91CD\u7F6E";
122
+ const d = Math.floor(sec / 86400);
123
+ const h = Math.floor(sec % 86400 / 3600);
124
+ const m = Math.floor(sec % 3600 / 60);
125
+ if (d > 0) return `${d}\u5929${h}\u5C0F\u65F6`;
126
+ if (h > 0) return `${h}\u5C0F\u65F6${m}\u5206\u949F`;
127
+ return `${m}\u5206\u949F`;
128
+ }
129
+ function progressBar(percent, width) {
130
+ const clamped = Math.max(0, Math.min(100, percent));
131
+ const filled = Math.round(clamped / 100 * width);
132
+ const empty = Math.max(0, width - filled);
133
+ return "\u2588".repeat(filled) + "\u2591".repeat(empty);
134
+ }
135
+ function visualWidth(s) {
136
+ let w = 0;
137
+ for (const c of s) {
138
+ const code = c.codePointAt(0) ?? 0;
139
+ w += code >= 4352 && code <= 4447 || code >= 11904 && code <= 42191 || code >= 44032 && code <= 55203 || code >= 63744 && code <= 64255 || code >= 65281 && code <= 65376 || code >= 65504 && code <= 65510 ? 2 : 1;
140
+ }
141
+ return w;
142
+ }
143
+ var FALLBACK = {
144
+ primary: "#8B9DAF",
145
+ text: "#C5C5BB",
146
+ muted: "#7A7A72",
147
+ success: "#9CAF8B",
148
+ warning: "#C5B88D",
149
+ error: "#B08A8A",
150
+ border: "#6B6B63"
151
+ };
152
+ function hex(raw) {
153
+ if (typeof raw === "string" && raw.startsWith("#") && raw.length >= 7) return raw;
154
+ if (raw && typeof raw === "object" && typeof raw.r === "number") {
155
+ const r = Math.round(raw.r * 255), g = Math.round(raw.g * 255), b = Math.round(raw.b * 255);
156
+ return "#" + [r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("");
157
+ }
158
+ return "";
159
+ }
160
+ function GoUsagePanel(props) {
161
+ const [usage, setUsage] = createSignal(null);
162
+ const [open, setOpen] = createSignal(true);
163
+ const [error, setError] = createSignal("");
164
+ const [configured, setConfigured] = createSignal(true);
165
+ const [lastUpdated, setLastUpdated] = createSignal("");
166
+ const [panelWidth, setPanelWidth] = createSignal(24);
167
+ let boxEl;
168
+ async function refresh() {
169
+ const cfg = loadConfig();
170
+ if (!cfg.workspaceId || !cfg.authCookie) {
171
+ setConfigured(false);
172
+ setUsage(null);
173
+ setError("");
174
+ return;
175
+ }
176
+ setConfigured(true);
177
+ const u = await fetchUsage(cfg.workspaceId, cfg.authCookie);
178
+ if (u === null) {
179
+ setError("\u67E5\u8BE2\u5931\u8D25");
180
+ return;
181
+ }
182
+ if (u.error === "cookie_expired") {
183
+ setError("cookie \u5DF2\u8FC7\u671F");
184
+ return;
185
+ }
186
+ if (u.error === "not_configured") {
187
+ setConfigured(false);
188
+ return;
189
+ }
190
+ setUsage(u);
191
+ setLastUpdated((/* @__PURE__ */ new Date()).toLocaleTimeString());
192
+ setError("");
193
+ }
194
+ onMount(() => {
195
+ refresh();
196
+ const timer = setInterval(refresh, CHECK_INTERVAL);
197
+ onCleanup(() => clearInterval(timer));
198
+ });
199
+ const [pal, setPal] = createSignal({
200
+ ...FALLBACK
201
+ });
202
+ createEffect(() => {
203
+ const t = props.theme;
204
+ const p = {
205
+ ...FALLBACK
206
+ };
207
+ for (const k of Object.keys(FALLBACK)) {
208
+ const h = hex(t?.[k]);
209
+ if (h) p[k] = h;
210
+ }
211
+ setPal(p);
212
+ });
213
+ const colors = () => pal();
214
+ const barW = () => Math.max(4, Math.min(12, panelWidth() - 34));
215
+ const colorFor = (percent) => {
216
+ const c = colors();
217
+ if (percent >= WARN_THRESHOLD * 100) return c.error;
218
+ if (percent >= 50) return c.warning;
219
+ return c.success;
220
+ };
221
+ const renderRow = (label, u) => {
222
+ const labelW = visualWidth(label);
223
+ const pad = Math.max(0, 9 - labelW);
224
+ return (() => {
225
+ var _el$ = _$createElement("text"), _el$2 = _$createElement("span"), _el$3 = _$createElement("span"), _el$4 = _$createElement("span"), _el$5 = _$createElement("span"), _el$7 = _$createElement("span"), _el$8 = _$createTextNode(`%`), _el$9 = _$createElement("span"), _el$0 = _$createTextNode(` `);
226
+ _$insertNode(_el$, _el$2);
227
+ _$insertNode(_el$, _el$3);
228
+ _$insertNode(_el$, _el$4);
229
+ _$insertNode(_el$, _el$5);
230
+ _$insertNode(_el$, _el$7);
231
+ _$insertNode(_el$, _el$9);
232
+ _$insert(_el$2, label);
233
+ _$insert(_el$3, () => " ".repeat(pad + 1));
234
+ _$insert(_el$4, () => progressBar(u.usagePercent, barW()));
235
+ _$insertNode(_el$5, _$createTextNode(` `));
236
+ _$insertNode(_el$7, _el$8);
237
+ _$insert(_el$7, () => u.usagePercent, _el$8);
238
+ _$insertNode(_el$9, _el$0);
239
+ _$insert(_el$9, () => formatReset(u.resetInSec), null);
240
+ _$effect((_p$) => {
241
+ var _v$ = {
242
+ fg: colors().text
243
+ }, _v$2 = {
244
+ fg: colorFor(u.usagePercent)
245
+ }, _v$3 = {
246
+ fg: colorFor(u.usagePercent)
247
+ }, _v$4 = {
248
+ fg: colors().muted
249
+ };
250
+ _v$ !== _p$.e && (_p$.e = _$setProp(_el$2, "style", _v$, _p$.e));
251
+ _v$2 !== _p$.t && (_p$.t = _$setProp(_el$4, "style", _v$2, _p$.t));
252
+ _v$3 !== _p$.a && (_p$.a = _$setProp(_el$7, "style", _v$3, _p$.a));
253
+ _v$4 !== _p$.o && (_p$.o = _$setProp(_el$9, "style", _v$4, _p$.o));
254
+ return _p$;
255
+ }, {
256
+ e: void 0,
257
+ t: void 0,
258
+ a: void 0,
259
+ o: void 0
260
+ });
261
+ return _el$;
262
+ })();
263
+ };
264
+ const sep = () => "\u2500".repeat(Math.max(1, panelWidth() - 4));
265
+ return (() => {
266
+ var _el$1 = _$createElement("box"), _el$10 = _$createElement("text"), _el$11 = _$createElement("span"), _el$12 = _$createElement("span"), _el$13 = _$createElement("b");
267
+ _$insertNode(_el$1, _el$10);
268
+ var _ref$ = boxEl;
269
+ typeof _ref$ === "function" ? _$use(_ref$, _el$1) : boxEl = _el$1;
270
+ _$setProp(_el$1, "border", true);
271
+ _$setProp(_el$1, "paddingLeft", 1);
272
+ _$setProp(_el$1, "paddingRight", 1);
273
+ _$setProp(_el$1, "flexDirection", "column");
274
+ _$setProp(_el$1, "gap", 0);
275
+ _$setProp(_el$1, "onSizeChange", () => {
276
+ const w = boxEl ? Math.max(20, boxEl.width ?? 24) : 24;
277
+ setPanelWidth(w);
278
+ });
279
+ _$insertNode(_el$10, _el$11);
280
+ _$insertNode(_el$10, _el$12);
281
+ _$setProp(_el$10, "onMouseUp", () => setOpen((o) => !o));
282
+ _$insert(_el$11, () => open() ? "\u25BC " : "\u25B6 ");
283
+ _$insertNode(_el$12, _el$13);
284
+ _$insertNode(_el$13, _$createTextNode(`Go \u7528\u91CF`));
285
+ _$insert(_el$10, _$createComponent(Show, {
286
+ get when() {
287
+ return _$memo(() => !!!open())() && usage();
288
+ },
289
+ get children() {
290
+ var _el$15 = _$createElement("span"), _el$16 = _$createTextNode(` \u5468 `), _el$18 = _$createTextNode(`%`);
291
+ _$insertNode(_el$15, _el$16);
292
+ _$insertNode(_el$15, _el$18);
293
+ _$insert(_el$15, () => usage().weeklyUsage.usagePercent, _el$18);
294
+ _$effect((_$p) => _$setProp(_el$15, "style", {
295
+ fg: colorFor(usage().weeklyUsage.usagePercent)
296
+ }, _$p));
297
+ return _el$15;
298
+ }
299
+ }), null);
300
+ _$insert(_el$1, _$createComponent(Show, {
301
+ get when() {
302
+ return open();
303
+ },
304
+ get children() {
305
+ return [(() => {
306
+ var _el$19 = _$createElement("text");
307
+ _$insert(_el$19, sep);
308
+ _$effect((_$p) => _$setProp(_el$19, "fg", colors().muted, _$p));
309
+ return _el$19;
310
+ })(), _$createComponent(Show, {
311
+ get when() {
312
+ return !configured();
313
+ },
314
+ get children() {
315
+ return [(() => {
316
+ var _el$20 = _$createElement("text");
317
+ _$insertNode(_el$20, _$createTextNode(`\u672A\u914D\u7F6E\u8D26\u53F7`));
318
+ _$effect((_$p) => _$setProp(_el$20, "fg", colors().warning, _$p));
319
+ return _el$20;
320
+ })(), (() => {
321
+ var _el$22 = _$createElement("text");
322
+ _$insertNode(_el$22, _$createTextNode(`\u8F93\u5165 /go-config \u8BBE\u7F6E`));
323
+ _$effect((_$p) => _$setProp(_el$22, "fg", colors().muted, _$p));
324
+ return _el$22;
325
+ })()];
326
+ }
327
+ }), _$createComponent(Show, {
328
+ get when() {
329
+ return _$memo(() => !!configured())() && error();
330
+ },
331
+ get fallback() {
332
+ return _$createComponent(Show, {
333
+ get when() {
334
+ return _$memo(() => !!configured())() && usage();
335
+ },
336
+ get fallback() {
337
+ return _$createComponent(Show, {
338
+ get when() {
339
+ return configured();
340
+ },
341
+ get children() {
342
+ var _el$31 = _$createElement("text");
343
+ _$insertNode(_el$31, _$createTextNode(`\u52A0\u8F7D\u4E2D...`));
344
+ _$effect((_$p) => _$setProp(_el$31, "fg", colors().muted, _$p));
345
+ return _el$31;
346
+ }
347
+ });
348
+ },
349
+ get children() {
350
+ return [_$memo(() => renderRow("5\u5C0F\u65F6\u7528\u91CF", usage().rollingUsage)), _$memo(() => renderRow("\u6BCF\u5468\u7528\u91CF", usage().weeklyUsage)), _$memo(() => renderRow("\u6BCF\u6708\u7528\u91CF", usage().monthlyUsage)), (() => {
351
+ var _el$27 = _$createElement("text"), _el$28 = _$createElement("span"), _el$30 = _$createElement("span");
352
+ _$insertNode(_el$27, _el$28);
353
+ _$insertNode(_el$27, _el$30);
354
+ _$insertNode(_el$28, _$createTextNode(`\u6700\u8FD1\u66F4\u65B0 `));
355
+ _$insert(_el$30, lastUpdated);
356
+ _$effect((_p$) => {
357
+ var _v$8 = {
358
+ fg: colors().muted
359
+ }, _v$9 = {
360
+ fg: colors().muted
361
+ };
362
+ _v$8 !== _p$.e && (_p$.e = _$setProp(_el$28, "style", _v$8, _p$.e));
363
+ _v$9 !== _p$.t && (_p$.t = _$setProp(_el$30, "style", _v$9, _p$.t));
364
+ return _p$;
365
+ }, {
366
+ e: void 0,
367
+ t: void 0
368
+ });
369
+ return _el$27;
370
+ })()];
371
+ }
372
+ });
373
+ },
374
+ get children() {
375
+ return [(() => {
376
+ var _el$24 = _$createElement("text");
377
+ _$insert(_el$24, error);
378
+ _$effect((_$p) => _$setProp(_el$24, "fg", colors().error, _$p));
379
+ return _el$24;
380
+ })(), (() => {
381
+ var _el$25 = _$createElement("text");
382
+ _$insertNode(_el$25, _$createTextNode(`\u8F93\u5165 /go-config \u91CD\u65B0\u914D\u7F6E`));
383
+ _$effect((_$p) => _$setProp(_el$25, "fg", colors().muted, _$p));
384
+ return _el$25;
385
+ })()];
386
+ }
387
+ })];
388
+ }
389
+ }), null);
390
+ _$effect((_p$) => {
391
+ var _v$5 = colors().border, _v$6 = {
392
+ fg: colors().muted
393
+ }, _v$7 = {
394
+ fg: colors().primary
395
+ };
396
+ _v$5 !== _p$.e && (_p$.e = _$setProp(_el$1, "borderColor", _v$5, _p$.e));
397
+ _v$6 !== _p$.t && (_p$.t = _$setProp(_el$11, "style", _v$6, _p$.t));
398
+ _v$7 !== _p$.a && (_p$.a = _$setProp(_el$12, "style", _v$7, _p$.a));
399
+ return _p$;
400
+ }, {
401
+ e: void 0,
402
+ t: void 0,
403
+ a: void 0
404
+ });
405
+ return _el$1;
406
+ })();
407
+ }
408
+ function createSidebarSlot(api) {
409
+ return {
410
+ order: 60,
411
+ slots: {
412
+ sidebar_content(ctx, _input) {
413
+ return _$createComponent(GoUsagePanel, {
414
+ get theme() {
415
+ return ctx.theme.current;
416
+ },
417
+ api
418
+ });
419
+ }
420
+ }
421
+ };
422
+ }
423
+ var tui = async (api) => {
424
+ api.slots.register(createSidebarSlot(api));
425
+ api.command?.register(() => [{
426
+ title: "Go Usage: Configure",
427
+ value: "go-usage.config",
428
+ description: "\u8BBE\u7F6E OpenCode Go workspace ID \u548C auth cookie",
429
+ slash: {
430
+ name: "go-config"
431
+ },
432
+ onSelect: (dialog) => {
433
+ dialog?.replace(() => _$createComponent(api.ui.DialogPrompt, {
434
+ title: "\u8F93\u5165 workspace_id",
435
+ description: () => (() => {
436
+ var _el$33 = _$createElement("text");
437
+ _$insertNode(_el$33, _$createTextNode(`\u5728 opencode.ai \u63A7\u5236\u53F0 URL \u4E2D\u83B7\u53D6\uFF08wrk_ \u5F00\u5934\uFF09`));
438
+ return _el$33;
439
+ })(),
440
+ placeholder: "wrk_xxxxxxxxxxxx",
441
+ onConfirm: (value) => {
442
+ const wsId = value.trim();
443
+ if (!wsId) {
444
+ dialog?.clear();
445
+ return;
446
+ }
447
+ saveConfig({
448
+ workspace_id: wsId
449
+ });
450
+ dialog?.replace(() => _$createComponent(api.ui.DialogPrompt, {
451
+ title: "\u8F93\u5165 auth cookie",
452
+ description: () => (() => {
453
+ var _el$35 = _$createElement("text");
454
+ _$insertNode(_el$35, _$createTextNode(`\u767B\u5F55 opencode.ai \u540E\uFF0C\u6D4F\u89C8\u5668 F12 \u2192 Application \u2192 Cookies \u2192 opencode.ai \u2192 \u590D\u5236 auth \u7684\u503C`));
455
+ return _el$35;
456
+ })(),
457
+ placeholder: "Fe26.2*...",
458
+ onConfirm: (val) => {
459
+ const cookie = val.trim();
460
+ if (!cookie) {
461
+ dialog?.clear();
462
+ return;
463
+ }
464
+ saveConfig({
465
+ cookie
466
+ });
467
+ api.ui.toast({
468
+ variant: "success",
469
+ message: "Go \u7528\u91CF\u914D\u7F6E\u5DF2\u4FDD\u5B58"
470
+ });
471
+ dialog?.clear();
472
+ },
473
+ onCancel: () => dialog?.clear()
474
+ }));
475
+ },
476
+ onCancel: () => dialog?.clear()
477
+ }));
478
+ }
479
+ }]);
480
+ };
481
+ var mod = {
482
+ id: "opencode-go-usage-tui",
483
+ tui
484
+ };
485
+ var index_default = mod;
486
+ export {
487
+ index_default as default
488
+ };
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "opencode-go-usage-tui",
3
+ "version": "1.0.0",
4
+ "description": "OpenCode TUI plugin displaying OpenCode Go usage in the sidebar",
5
+ "type": "module",
6
+ "exports": {
7
+ "./tui": {
8
+ "import": "./dist/tui.js"
9
+ }
10
+ },
11
+ "config": {
12
+ "enabled": true
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "src",
17
+ "build.tui.mjs",
18
+ "README.md"
19
+ ],
20
+ "scripts": {
21
+ "build": "bun run build.tui.mjs",
22
+ "version": "node -e \"const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));console.log(p.version)\""
23
+ },
24
+ "keywords": [
25
+ "opencode",
26
+ "opencode-plugin",
27
+ "tui",
28
+ "go-usage",
29
+ "quota"
30
+ ],
31
+ "license": "MIT",
32
+ "peerDependencies": {
33
+ "@opencode-ai/plugin": ">=1.14.0",
34
+ "@opentui/solid": ">=0.2.0",
35
+ "solid-js": ">=1.9.0"
36
+ },
37
+ "devDependencies": {
38
+ "esbuild": "^0.25.0",
39
+ "esbuild-plugin-solid": "^0.5.0"
40
+ }
41
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,345 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+
3
+ import type {
4
+ TuiPlugin,
5
+ TuiPluginApi,
6
+ TuiSlotContext,
7
+ TuiSlotPlugin,
8
+ TuiPluginModule,
9
+ TuiThemeCurrent,
10
+ } from "@opencode-ai/plugin/tui"
11
+ import { createSignal, createEffect, onMount, onCleanup, Show } from "solid-js"
12
+ import type { JSX } from "@opentui/solid"
13
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs"
14
+ import { join, dirname } from "node:path"
15
+
16
+ declare const process: { env: Record<string, string | undefined> } | undefined
17
+
18
+ const CHECK_INTERVAL = Number(process?.env?.OPENCODE_GO_CHECK_INTERVAL ?? 60000)
19
+ const WARN_THRESHOLD = Number(process?.env?.OPENCODE_GO_WARN_THRESHOLD ?? 0.8)
20
+ const CONFIG_DIR = process?.env?.OPENCODE_CONFIG_DIR
21
+ || (process?.env?.USERPROFILE ? `${process.env.USERPROFILE}\\.config\\opencode` : "")
22
+ || process?.env?.HOME + "/.config/opencode"
23
+ const CONFIG_FILE = join(CONFIG_DIR, "go-usage-config.json")
24
+
25
+ function loadConfig(): { workspaceId: string; authCookie: string } {
26
+ const env = process?.env ?? {}
27
+ let fileCfg: Record<string, string> = {}
28
+ try {
29
+ fileCfg = JSON.parse(readFileSync(CONFIG_FILE, "utf8"))
30
+ } catch { fileCfg = {} }
31
+ const cookieFile = env.OPENCODE_GO_COOKIE_FILE || fileCfg.cookie_file || `${CONFIG_DIR}\\go-auth-cookie.txt`
32
+ let fileCookie = ""
33
+ try {
34
+ let p = cookieFile
35
+ if (p === "~") p = (env.HOME || env.USERPROFILE) + ""
36
+ else if (p.startsWith("~/")) p = (env.HOME || env.USERPROFILE || "") + p.slice(1)
37
+ fileCookie = readFileSync(p, "utf8").trim()
38
+ } catch { fileCookie = "" }
39
+ return {
40
+ workspaceId: env.OPENCODE_GO_WORKSPACE_ID || fileCfg.workspace_id || "",
41
+ authCookie: env.OPENCODE_GO_AUTH_COOKIE || fileCfg.auth_cookie || fileCfg.cookie || fileCookie,
42
+ }
43
+ }
44
+
45
+ function saveConfig(patch: { workspace_id?: string; cookie?: string }): void {
46
+ let fileCfg: Record<string, string> = {}
47
+ try {
48
+ fileCfg = JSON.parse(readFileSync(CONFIG_FILE, "utf8"))
49
+ } catch { fileCfg = {} }
50
+ if (patch.workspace_id !== undefined) fileCfg.workspace_id = patch.workspace_id
51
+ if (patch.cookie !== undefined) fileCfg.cookie = patch.cookie
52
+ try {
53
+ mkdirSync(dirname(CONFIG_FILE), { recursive: true })
54
+ writeFileSync(CONFIG_FILE, JSON.stringify(fileCfg, null, 2), "utf8")
55
+ } catch { /* 写入失败忽略 */ }
56
+ }
57
+
58
+ interface Usage {
59
+ rollingUsage: { usagePercent: number; resetInSec: number }
60
+ weeklyUsage: { usagePercent: number; resetInSec: number }
61
+ monthlyUsage: { usagePercent: number; resetInSec: number }
62
+ error?: string
63
+ }
64
+
65
+ async function fetchUsage(workspaceId: string, authCookie: string): Promise<Usage | null> {
66
+ if (!workspaceId || !authCookie) return { error: "not_configured" } as Usage
67
+ try {
68
+ const res = await fetch("https://opencode.ai/_server", {
69
+ method: "POST",
70
+ headers: {
71
+ "content-type": "application/json",
72
+ "x-server-id": "c7389bd0e731f80f49593e5ee53835475f4e28594dd6bd83eb229bab753498cd",
73
+ "x-server-instance": "go-usage-tui",
74
+ "cookie": `auth=${authCookie}`,
75
+ "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
76
+ },
77
+ body: JSON.stringify({
78
+ t: { t: 9, i: 0, l: 1, a: [{ t: 1, s: workspaceId }], o: 0 },
79
+ f: 31,
80
+ m: [],
81
+ }),
82
+ })
83
+ if (!res.ok) {
84
+ if (res.status === 302) return { error: "cookie_expired" } as Usage
85
+ return null
86
+ }
87
+ const text = await res.text()
88
+ if (text.includes("/auth/authorize") || text.includes("location")) {
89
+ return { error: "cookie_expired" } as Usage
90
+ }
91
+ const extract = (key: string): { resetInSec: number; usagePercent: number } | null => {
92
+ const m = text.match(new RegExp(`${key}:[^}]*?\\{status:"ok",resetInSec:(\\d+),usagePercent:(\\d+)\\}`))
93
+ if (!m) return null
94
+ return { resetInSec: Number(m[1]), usagePercent: Number(m[2]) }
95
+ }
96
+ const rollingUsage = extract("rollingUsage")
97
+ const weeklyUsage = extract("weeklyUsage")
98
+ const monthlyUsage = extract("monthlyUsage")
99
+ if (!rollingUsage || !weeklyUsage || !monthlyUsage) return null
100
+ return { rollingUsage, weeklyUsage, monthlyUsage }
101
+ } catch {
102
+ return null
103
+ }
104
+ }
105
+
106
+ function formatReset(sec: number): string {
107
+ if (sec <= 0) return "已重置"
108
+ const d = Math.floor(sec / 86400)
109
+ const h = Math.floor((sec % 86400) / 3600)
110
+ const m = Math.floor((sec % 3600) / 60)
111
+ if (d > 0) return `${d}天${h}小时`
112
+ if (h > 0) return `${h}小时${m}分钟`
113
+ return `${m}分钟`
114
+ }
115
+
116
+ function progressBar(percent: number, width: number): string {
117
+ const clamped = Math.max(0, Math.min(100, percent))
118
+ const filled = Math.round((clamped / 100) * width)
119
+ const empty = Math.max(0, width - filled)
120
+ return "\u2588".repeat(filled) + "\u2591".repeat(empty)
121
+ }
122
+
123
+ function visualWidth(s: string): number {
124
+ let w = 0
125
+ for (const c of s) {
126
+ const code = c.codePointAt(0) ?? 0
127
+ w += (code >= 0x1100 && code <= 0x115F) ||
128
+ (code >= 0x2E80 && code <= 0xA4CF) ||
129
+ (code >= 0xAC00 && code <= 0xD7A3) ||
130
+ (code >= 0xF900 && code <= 0xFAFF) ||
131
+ (code >= 0xFF01 && code <= 0xFF60) ||
132
+ (code >= 0xFFE0 && code <= 0xFFE6) ? 2 : 1
133
+ }
134
+ return w
135
+ }
136
+
137
+ const FALLBACK = {
138
+ primary: "#8B9DAF",
139
+ text: "#C5C5BB",
140
+ muted: "#7A7A72",
141
+ success: "#9CAF8B",
142
+ warning: "#C5B88D",
143
+ error: "#B08A8A",
144
+ border: "#6B6B63",
145
+ } as const
146
+
147
+ function hex(raw: any): string {
148
+ if (typeof raw === "string" && raw.startsWith("#") && raw.length >= 7) return raw
149
+ if (raw && typeof raw === "object" && typeof raw.r === "number") {
150
+ const r = Math.round(raw.r * 255), g = Math.round(raw.g * 255), b = Math.round(raw.b * 255)
151
+ return "#" + [r, g, b].map(v => v.toString(16).padStart(2, "0")).join("")
152
+ }
153
+ return ""
154
+ }
155
+
156
+ function GoUsagePanel(props: { theme: TuiThemeCurrent; api: TuiPluginApi }): JSX.Element {
157
+ const [usage, setUsage] = createSignal<Usage | null>(null)
158
+ const [open, setOpen] = createSignal(true)
159
+ const [error, setError] = createSignal("")
160
+ const [configured, setConfigured] = createSignal(true)
161
+ const [lastUpdated, setLastUpdated] = createSignal("")
162
+ const [panelWidth, setPanelWidth] = createSignal(24)
163
+ let boxEl: any
164
+
165
+ async function refresh() {
166
+ const cfg = loadConfig()
167
+ if (!cfg.workspaceId || !cfg.authCookie) {
168
+ setConfigured(false)
169
+ setUsage(null)
170
+ setError("")
171
+ return
172
+ }
173
+ setConfigured(true)
174
+ const u = await fetchUsage(cfg.workspaceId, cfg.authCookie)
175
+ if (u === null) { setError("查询失败"); return }
176
+ if (u.error === "cookie_expired") { setError("cookie 已过期"); return }
177
+ if (u.error === "not_configured") { setConfigured(false); return }
178
+ setUsage(u)
179
+ setLastUpdated(new Date().toLocaleTimeString())
180
+ setError("")
181
+ }
182
+
183
+ onMount(() => {
184
+ refresh()
185
+ const timer = setInterval(refresh, CHECK_INTERVAL)
186
+ onCleanup(() => clearInterval(timer))
187
+ })
188
+
189
+ const [pal, setPal] = createSignal<Record<string, string>>({ ...FALLBACK })
190
+ createEffect(() => {
191
+ const t = props.theme as any
192
+ const p: Record<string, string> = { ...FALLBACK }
193
+ for (const k of Object.keys(FALLBACK)) {
194
+ const h = hex(t?.[k])
195
+ if (h) p[k] = h
196
+ }
197
+ setPal(p)
198
+ })
199
+ const colors = () => pal()
200
+
201
+ const barW = () => Math.max(4, Math.min(12, panelWidth() - 34))
202
+
203
+ const colorFor = (percent: number) => {
204
+ const c = colors()
205
+ if (percent >= WARN_THRESHOLD * 100) return c.error
206
+ if (percent >= 50) return c.warning
207
+ return c.success
208
+ }
209
+
210
+ const renderRow = (label: string, u: { usagePercent: number; resetInSec: number }) => {
211
+ const labelW = visualWidth(label)
212
+ const pad = Math.max(0, 9 - labelW)
213
+ return (
214
+ <text>
215
+ <span style={{ fg: colors().text }}>{label}</span>
216
+ <span>{" ".repeat(pad + 1)}</span>
217
+ <span style={{ fg: colorFor(u.usagePercent) }}>
218
+ {progressBar(u.usagePercent, barW())}
219
+ </span>
220
+ <span>{" "}</span>
221
+ <span style={{ fg: colorFor(u.usagePercent) }}>{u.usagePercent}%</span>
222
+ <span style={{ fg: colors().muted }}> {formatReset(u.resetInSec)}</span>
223
+ </text>
224
+ )
225
+ }
226
+
227
+ const sep = () => "\u2500".repeat(Math.max(1, panelWidth() - 4))
228
+
229
+ return (
230
+ <box
231
+ border
232
+ borderColor={colors().border}
233
+ paddingLeft={1}
234
+ paddingRight={1}
235
+ flexDirection="column"
236
+ gap={0}
237
+ ref={boxEl}
238
+ onSizeChange={() => {
239
+ const w = boxEl ? Math.max(20, boxEl.width ?? 24) : 24
240
+ setPanelWidth(w)
241
+ }}
242
+ >
243
+ <text onMouseUp={() => setOpen(o => !o)}>
244
+ <span style={{ fg: colors().muted }}>{open() ? "\u25bc " : "\u25b6 "}</span>
245
+ <span style={{ fg: colors().primary }}><b>Go 用量</b></span>
246
+ <Show when={!open() && usage()}>
247
+ <span style={{ fg: colorFor(usage()!.weeklyUsage.usagePercent) }}>
248
+ {" ".repeat(2)}周 {usage()!.weeklyUsage.usagePercent}%
249
+ </span>
250
+ </Show>
251
+ </text>
252
+
253
+ <Show when={open()}>
254
+ <text fg={colors().muted}>{sep()}</text>
255
+
256
+ <Show when={!configured()}>
257
+ <text fg={colors().warning}>未配置账号</text>
258
+ <text fg={colors().muted}>输入 /go-config 设置</text>
259
+ </Show>
260
+
261
+ <Show when={configured() && error()} fallback={
262
+ <Show when={configured() && usage()} fallback={
263
+ <Show when={configured()}><text fg={colors().muted}>加载中...</text></Show>
264
+ }>
265
+ {renderRow("5小时用量", usage()!.rollingUsage)}
266
+ {renderRow("每周用量", usage()!.weeklyUsage)}
267
+ {renderRow("每月用量", usage()!.monthlyUsage)}
268
+ <text>
269
+ <span style={{ fg: colors().muted }}>最近更新 </span>
270
+ <span style={{ fg: colors().muted }}>{lastUpdated()}</span>
271
+ </text>
272
+ </Show>
273
+ }>
274
+ <text fg={colors().error}>{error()}</text>
275
+ <text fg={colors().muted}>输入 /go-config 重新配置</text>
276
+ </Show>
277
+ </Show>
278
+ </box>
279
+ )
280
+ }
281
+
282
+ function createSidebarSlot(api: TuiPluginApi): TuiSlotPlugin {
283
+ return {
284
+ order: 60,
285
+ slots: {
286
+ sidebar_content(ctx: TuiSlotContext, _input: { session_id: string }): JSX.Element {
287
+ return <GoUsagePanel theme={ctx.theme.current} api={api} />
288
+ },
289
+ },
290
+ }
291
+ }
292
+
293
+ const tui: TuiPlugin = async (api: TuiPluginApi) => {
294
+ api.slots.register(createSidebarSlot(api))
295
+
296
+ api.command?.register(() => [
297
+ {
298
+ title: "Go Usage: Configure",
299
+ value: "go-usage.config",
300
+ description: "设置 OpenCode Go workspace ID 和 auth cookie",
301
+ slash: { name: "go-config" },
302
+ onSelect: (dialog) => {
303
+ dialog?.replace(() => (
304
+ <api.ui.DialogPrompt
305
+ title="输入 workspace_id"
306
+ description={() => (
307
+ <text>在 opencode.ai 控制台 URL 中获取(wrk_ 开头)</text>
308
+ )}
309
+ placeholder="wrk_xxxxxxxxxxxx"
310
+ onConfirm={(value) => {
311
+ const wsId = value.trim()
312
+ if (!wsId) { dialog?.clear(); return }
313
+ saveConfig({ workspace_id: wsId })
314
+ dialog?.replace(() => (
315
+ <api.ui.DialogPrompt
316
+ title="输入 auth cookie"
317
+ description={() => (
318
+ <text>登录 opencode.ai 后,浏览器 F12 → Application → Cookies → opencode.ai → 复制 auth 的值</text>
319
+ )}
320
+ placeholder="Fe26.2*..."
321
+ onConfirm={(val) => {
322
+ const cookie = val.trim()
323
+ if (!cookie) { dialog?.clear(); return }
324
+ saveConfig({ cookie })
325
+ api.ui.toast({ variant: "success", message: "Go 用量配置已保存" })
326
+ dialog?.clear()
327
+ }}
328
+ onCancel={() => dialog?.clear()}
329
+ />
330
+ ))
331
+ }}
332
+ onCancel={() => dialog?.clear()}
333
+ />
334
+ ))
335
+ },
336
+ },
337
+ ])
338
+ }
339
+
340
+ const mod: TuiPluginModule & { id: string } = {
341
+ id: "opencode-go-usage-tui",
342
+ tui,
343
+ }
344
+
345
+ export default mod