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 +94 -0
- package/build.tui.mjs +13 -0
- package/dist/tui.js +488 -0
- package/package.json +41 -0
- package/src/index.tsx +345 -0
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
|