opencode-go-usage-tui 1.3.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.tsx CHANGED
@@ -1,866 +1,989 @@
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, renameSync, existsSync } from "node:fs"
14
- import { join, dirname } from "node:path"
15
- import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"
16
- import { createT, detectLang, LANG_META } from "./i18n"
17
- import type { LangCode } from "./i18n"
18
- import { fetchPricing, comparePricing, loadPricingStore, updatePricingStore } from "./pricing"
19
- import type { ModelPrice, PricingData, PricingStore } from "./pricing"
20
-
21
- declare const process: { env: Record<string, string | undefined> } | undefined
22
-
23
- const VERSION: string = __PLUGIN_VERSION__
24
- const CHECK_INTERVAL = Number(process?.env?.OPENCODE_GO_CHECK_INTERVAL ?? 60000)
25
- const WARN_THRESHOLD = Number(process?.env?.OPENCODE_GO_WARN_THRESHOLD ?? 0.8)
26
- const CONFIG_DIR = process?.env?.OPENCODE_CONFIG_DIR
27
- || (process?.env?.USERPROFILE ? `${process.env.USERPROFILE}\\.config\\opencode` : "")
28
- || process?.env?.HOME + "/.config/opencode"
29
- const CONFIG_FILE = join(CONFIG_DIR, "go-usage-config.json")
30
- const COOKIE_ENC_FILE = join(CONFIG_DIR, "go-auth-cookie.enc")
31
- const KEY_FILE = join(CONFIG_DIR, ".encryption-key")
32
- const COOKIE_PLAIN_FILE = `${CONFIG_DIR}\\go-auth-cookie.txt`
33
- const KV_PREFIX = "go-usage"
34
-
35
- // ── AES-256-GCM 加密存储 ────────────────────────────────
36
- // cookie 以密文存 go-auth-cookie.enc,密钥存 .encryption-key。
37
- // 注意:Windows 无 Unix 权限位,mode 0o600 仅对 macOS/Linux 生效;
38
- // Windows 上密钥与密文同目录,防护重点是"被动泄露"(备份/云同步/日志),
39
- // 无法抵御能读取该目录的本地恶意程序。
40
- function loadEncryptionKey(): Buffer {
41
- try {
42
- const hex = readFileSync(KEY_FILE, "utf8").trim()
43
- if (hex) return Buffer.from(hex, "hex")
44
- } catch { /* 无密钥则新建 */ }
45
- const key = randomBytes(32)
46
- mkdirSync(dirname(KEY_FILE), { recursive: true })
47
- writeFileSync(KEY_FILE, key.toString("hex"), { encoding: "utf8", mode: 0o600 })
48
- return key
49
- }
50
-
51
- function encryptText(text: string): string {
52
- const key = loadEncryptionKey()
53
- const iv = randomBytes(16)
54
- const cipher = createCipheriv("aes-256-gcm", key, iv)
55
- const data = cipher.update(text, "utf8", "hex") + cipher.final("hex")
56
- return JSON.stringify({ v: 1, iv: iv.toString("hex"), data, tag: cipher.getAuthTag().toString("hex") })
57
- }
58
-
59
- function decryptText(encJson: string): string {
60
- const { iv, data, tag } = JSON.parse(encJson)
61
- const key = loadEncryptionKey()
62
- const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "hex"))
63
- decipher.setAuthTag(Buffer.from(tag, "hex"))
64
- return decipher.update(data, "hex", "utf8") + decipher.final("utf8")
65
- }
66
-
67
- function readJsonFile<T>(file: string): T | null {
68
- try { return JSON.parse(readFileSync(file, "utf8")) as T } catch { return null }
69
- }
70
-
71
- interface ConfigFile { workspace_id?: string; cookie?: string; auth_cookie?: string; cookie_file?: string; ui?: { usage_panel?: boolean; price_panel?: boolean }; focus_models?: string[]; refresh_interval_sec?: number; pricing_interval_sec?: number }
72
-
73
- interface LoadedConfig { workspaceId: string; authCookie: string; usagePanel: boolean; pricePanel: boolean; focusModels: string[]; refreshIntervalMs: number; pricingIntervalMs: number }
74
-
75
- function loadConfig(): LoadedConfig {
76
- const env = process?.env ?? {}
77
- const fileCfg = readJsonFile<ConfigFile>(CONFIG_FILE) ?? {}
78
-
79
- // 1. 环境变量优先
80
- if (env.OPENCODE_GO_AUTH_COOKIE) {
81
- return {
82
- workspaceId: env.OPENCODE_GO_WORKSPACE_ID || fileCfg.workspace_id || "", authCookie: env.OPENCODE_GO_AUTH_COOKIE,
83
- usagePanel: fileCfg.ui?.usage_panel ?? true, pricePanel: fileCfg.ui?.price_panel ?? true,
84
- focusModels: fileCfg.focus_models ?? [], refreshIntervalMs: (fileCfg.refresh_interval_sec ?? Math.round(CHECK_INTERVAL / 1000)) * 1000,
85
- pricingIntervalMs: (fileCfg.pricing_interval_sec ?? Math.round((CHECK_INTERVAL / 1000) * 30)) * 1000,
86
- }
87
- }
88
-
89
- // 2. 加密存储
90
- try {
91
- if (existsSync(COOKIE_ENC_FILE)) {
92
- const cookie = decryptText(readFileSync(COOKIE_ENC_FILE, "utf8"))
93
- if (cookie) {
94
- return {
95
- workspaceId: env.OPENCODE_GO_WORKSPACE_ID || fileCfg.workspace_id || "", authCookie: cookie,
96
- usagePanel: fileCfg.ui?.usage_panel ?? true, pricePanel: fileCfg.ui?.price_panel ?? true,
97
- focusModels: fileCfg.focus_models ?? [], refreshIntervalMs: (fileCfg.refresh_interval_sec ?? Math.round(CHECK_INTERVAL / 1000)) * 1000,
98
- pricingIntervalMs: (fileCfg.pricing_interval_sec ?? Math.round((CHECK_INTERVAL / 1000) * 30)) * 1000,
99
- }
100
- }
101
- }
102
- } catch { /* 密文损坏时回退到明文迁移 */ }
103
-
104
- // 3. 兼容旧明文(config.json 内嵌 cookie / auth_cookie / 独立 txt 文件)——自动迁移
105
- const legacyCookie = fileCfg.auth_cookie || fileCfg.cookie || (() => {
106
- try { return readFileSync(COOKIE_PLAIN_FILE, "utf8").trim() } catch { return "" }
107
- })()
108
- if (legacyCookie) {
109
- const enc = encryptText(legacyCookie)
110
- mkdirSync(CONFIG_DIR, { recursive: true })
111
- writeFileSync(COOKIE_ENC_FILE, enc, "utf8")
112
- // 清理旧明文
113
- const cleanCfg: ConfigFile = { ...fileCfg }
114
- delete cleanCfg.cookie
115
- delete cleanCfg.auth_cookie
116
- try { writeFileSync(CONFIG_FILE, JSON.stringify(cleanCfg, null, 2), "utf8") } catch { /* 忽略 */ }
117
- try { if (existsSync(COOKIE_PLAIN_FILE)) renameSync(COOKIE_PLAIN_FILE, COOKIE_PLAIN_FILE + ".bak") } catch { /* 忽略 */ }
118
- return {
119
- workspaceId: env.OPENCODE_GO_WORKSPACE_ID || fileCfg.workspace_id || "", authCookie: legacyCookie,
120
- usagePanel: fileCfg.ui?.usage_panel ?? true, pricePanel: fileCfg.ui?.price_panel ?? true,
121
- focusModels: fileCfg.focus_models ?? [], refreshIntervalMs: (fileCfg.refresh_interval_sec ?? Math.round(CHECK_INTERVAL / 1000)) * 1000,
122
- pricingIntervalMs: (fileCfg.pricing_interval_sec ?? Math.round((CHECK_INTERVAL / 1000) * 30)) * 1000,
123
- }
124
- }
125
-
126
- return {
127
- workspaceId: env.OPENCODE_GO_WORKSPACE_ID || fileCfg.workspace_id || "", authCookie: "",
128
- usagePanel: fileCfg.ui?.usage_panel ?? true, pricePanel: fileCfg.ui?.price_panel ?? true,
129
- focusModels: fileCfg.focus_models ?? [], refreshIntervalMs: (fileCfg.refresh_interval_sec ?? Math.round(CHECK_INTERVAL / 1000)) * 1000,
130
- pricingIntervalMs: (fileCfg.pricing_interval_sec ?? Math.round((CHECK_INTERVAL / 1000) * 30)) * 1000,
131
- }
132
- }
133
-
134
- function saveConfig(patch: { workspace_id?: string; cookie?: string; ui?: { usage_panel?: boolean; price_panel?: boolean }; focus_models?: string[]; refresh_interval_sec?: number; pricing_interval_sec?: number }): void {
135
- const fileCfg = readJsonFile<ConfigFile>(CONFIG_FILE) ?? {}
136
- if (patch.workspace_id !== undefined) fileCfg.workspace_id = patch.workspace_id
137
- if (patch.cookie !== undefined) {
138
- // cookie 改为加密存储,不再写明文到 config.json
139
- if (patch.cookie) {
140
- mkdirSync(CONFIG_DIR, { recursive: true })
141
- writeFileSync(COOKIE_ENC_FILE, encryptText(patch.cookie), "utf8")
142
- delete fileCfg.cookie
143
- delete fileCfg.auth_cookie
144
- // 清理可能存在的旧明文 txt
145
- try { if (existsSync(COOKIE_PLAIN_FILE)) renameSync(COOKIE_PLAIN_FILE, COOKIE_PLAIN_FILE + ".bak") } catch { /* 忽略 */ }
146
- } else {
147
- try { if (existsSync(COOKIE_ENC_FILE)) renameSync(COOKIE_ENC_FILE, COOKIE_ENC_FILE + ".bak") } catch { /* 忽略 */ }
148
- }
149
- }
150
- if (patch.ui !== undefined) {
151
- fileCfg.ui = { ...(fileCfg.ui ?? {}), ...patch.ui }
152
- }
153
- if (patch.focus_models !== undefined) fileCfg.focus_models = patch.focus_models
154
- if (patch.refresh_interval_sec !== undefined) fileCfg.refresh_interval_sec = patch.refresh_interval_sec
155
- if (patch.pricing_interval_sec !== undefined) fileCfg.pricing_interval_sec = patch.pricing_interval_sec
156
- try {
157
- mkdirSync(dirname(CONFIG_FILE), { recursive: true })
158
- writeFileSync(CONFIG_FILE, JSON.stringify(fileCfg, null, 2), "utf8")
159
- } catch { /* 写入失败忽略 */ }
160
- }
161
-
162
- // 模块级共享信号:/go-config 保存配置后递增,面板监听它立即刷新
163
- const [configTick, setConfigTick] = createSignal(0)
164
-
165
- // 模块级共享语言信号:/go-lang 或首次引导切换后,面板和命令实时响应
166
- const [langCode, setLangCode] = createSignal<LangCode>(detectLang())
167
- const t = createT(() => langCode())
168
-
169
- // 模块级共享价格数据:PricePanel 抓取后写入,/go-settings 设置关注模型时读取
170
- const [sharedPricing, setSharedPricing] = createSignal<PricingData | null>(null)
171
-
172
- function kvTryGet(api: TuiPluginApi, key: string): string | undefined {
173
- try { return api.kv.get<string>(key) } catch { return undefined }
174
- }
175
- function kvTrySet(api: TuiPluginApi, key: string, val: unknown): void {
176
- try { api.kv.set(key, val) } catch { /* 忽略 */ }
177
- }
178
-
179
- interface Usage {
180
- rollingUsage: { usagePercent: number; resetInSec: number }
181
- weeklyUsage: { usagePercent: number; resetInSec: number }
182
- monthlyUsage: { usagePercent: number; resetInSec: number }
183
- error?: string
184
- }
185
-
186
- const REQUEST_TIMEOUT = 10000
187
-
188
- async function fetchUsage(workspaceId: string, authCookie: string, signal?: AbortSignal): Promise<Usage | null> {
189
- if (!workspaceId || !authCookie) return { error: "not_configured" } as Usage
190
- try {
191
- const res = await fetch("https://opencode.ai/_server", {
192
- method: "POST",
193
- headers: {
194
- "content-type": "application/json",
195
- "x-server-id": "c7389bd0e731f80f49593e5ee53835475f4e28594dd6bd83eb229bab753498cd",
196
- "x-server-instance": "go-usage-tui",
197
- "cookie": `auth=${authCookie}`,
198
- "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
199
- },
200
- body: JSON.stringify({
201
- t: { t: 9, i: 0, l: 1, a: [{ t: 1, s: workspaceId }], o: 0 },
202
- f: 31,
203
- m: [],
204
- }),
205
- signal,
206
- })
207
- if (!res.ok) {
208
- if (res.status === 302) return { error: "cookie_expired" } as Usage
209
- return null
210
- }
211
- const text = await res.text()
212
- if (text.includes("/auth/authorize") || text.includes("location")) {
213
- return { error: "cookie_expired" } as Usage
214
- }
215
- const extract = (key: string): { resetInSec: number; usagePercent: number } | null => {
216
- const m = text.match(new RegExp(`${key}:[^}]*?\\{status:"ok",resetInSec:(\\d+),usagePercent:(\\d+)\\}`))
217
- if (!m) return null
218
- return { resetInSec: Number(m[1]), usagePercent: Number(m[2]) }
219
- }
220
- const rollingUsage = extract("rollingUsage")
221
- const weeklyUsage = extract("weeklyUsage")
222
- const monthlyUsage = extract("monthlyUsage")
223
- if (!rollingUsage || !weeklyUsage || !monthlyUsage) return null
224
- return { rollingUsage, weeklyUsage, monthlyUsage }
225
- } catch {
226
- return null
227
- }
228
- }
229
-
230
- function formatReset(sec: number): string {
231
- if (sec <= 0) return t("resetDone")
232
- const d = Math.floor(sec / 86400)
233
- const h = Math.floor((sec % 86400) / 3600)
234
- const m = Math.floor((sec % 3600) / 60)
235
- if (d > 0) return t("dayHour", { d, h })
236
- if (h > 0) return t("hourMin", { h, m })
237
- return t("minute", { m })
238
- }
239
-
240
- function progressBar(percent: number, width: number): string {
241
- const clamped = Math.max(0, Math.min(100, percent))
242
- const filled = Math.round((clamped / 100) * width)
243
- const empty = Math.max(0, width - filled)
244
- return "\u2588".repeat(filled) + "\u2591".repeat(empty)
245
- }
246
-
247
- function visualWidth(s: string): number {
248
- let w = 0
249
- for (const c of s) {
250
- const code = c.codePointAt(0) ?? 0
251
- w += (code >= 0x1100 && code <= 0x115F) ||
252
- (code >= 0x2E80 && code <= 0xA4CF) ||
253
- (code >= 0xAC00 && code <= 0xD7A3) ||
254
- (code >= 0xF900 && code <= 0xFAFF) ||
255
- (code >= 0xFF01 && code <= 0xFF60) ||
256
- (code >= 0xFFE0 && code <= 0xFFE6) ? 2 : 1
257
- }
258
- return w
259
- }
260
-
261
- const FALLBACK = {
262
- primary: "#8B9DAF",
263
- text: "#C5C5BB",
264
- muted: "#7A7A72",
265
- success: "#9CAF8B",
266
- warning: "#C5B88D",
267
- error: "#B08A8A",
268
- border: "#6B6B63",
269
- } as const
270
-
271
- function hex(raw: any): string {
272
- if (typeof raw === "string" && raw.startsWith("#") && raw.length >= 7) return raw
273
- if (raw && typeof raw === "object" && typeof raw.r === "number") {
274
- const r = Math.round(raw.r * 255), g = Math.round(raw.g * 255), b = Math.round(raw.b * 255)
275
- return "#" + [r, g, b].map(v => v.toString(16).padStart(2, "0")).join("")
276
- }
277
- return ""
278
- }
279
-
280
- function GoUsagePanel(props: { theme: TuiThemeCurrent; api: TuiPluginApi }): JSX.Element {
281
- const [usage, setUsage] = createSignal<Usage | null>(null)
282
- const [open, setOpen] = createSignal(true)
283
- const [error, setError] = createSignal("")
284
- const [configured, setConfigured] = createSignal(true)
285
- const [lastUpdated, setLastUpdated] = createSignal("")
286
- const [panelWidth, setPanelWidth] = createSignal(24)
287
- const [cfg, setCfg] = createSignal<LoadedConfig>(loadConfig())
288
- createEffect(() => { void configTick(); setCfg(loadConfig()) })
289
- let boxEl: any
290
- let abortController: AbortController | null = null
291
- let timeoutId: ReturnType<typeof setTimeout> | null = null
292
- let isRefreshing = false
293
- let resizeTimeout: ReturnType<typeof setTimeout> | null = null
294
-
295
- function cleanupPendingRequest() {
296
- if (timeoutId) { clearTimeout(timeoutId); timeoutId = null }
297
- if (abortController) { abortController.abort(); abortController = null }
298
- }
299
-
300
- async function refresh() {
301
- if (isRefreshing) return
302
- const cfg = loadConfig()
303
- if (!cfg.workspaceId || !cfg.authCookie) {
304
- setConfigured(false)
305
- setUsage(null)
306
- setError("")
307
- return
308
- }
309
- setConfigured(true)
310
- cleanupPendingRequest()
311
- abortController = new AbortController()
312
- const signal = abortController.signal
313
- timeoutId = setTimeout(() => abortController?.abort(), REQUEST_TIMEOUT)
314
- isRefreshing = true
315
- try {
316
- const u = await fetchUsage(cfg.workspaceId, cfg.authCookie, signal)
317
- if (signal.aborted) return
318
- if (u === null) { setError(t("queryFailed")); return }
319
- if (u.error === "cookie_expired") { setError(t("cookieExpired")); return }
320
- if (u.error === "not_configured") { setConfigured(false); return }
321
- setUsage(u)
322
- setLastUpdated(new Date().toLocaleTimeString())
323
- setError("")
324
- } finally {
325
- if (timeoutId) { clearTimeout(timeoutId); timeoutId = null }
326
- isRefreshing = false
327
- }
328
- }
329
-
330
- onMount(() => {
331
- refresh()
332
- const timer = setInterval(refresh, loadConfig().refreshIntervalMs)
333
- const stopWatch = createEffect(() => {
334
- void configTick()
335
- refresh()
336
- })
337
- onCleanup(() => {
338
- clearInterval(timer)
339
- stopWatch()
340
- cleanupPendingRequest()
341
- if (resizeTimeout) { clearTimeout(resizeTimeout); resizeTimeout = null }
342
- })
343
- })
344
-
345
- const [pal, setPal] = createSignal<Record<string, string>>({ ...FALLBACK })
346
- createEffect(() => {
347
- const th = props.theme as any
348
- const p: Record<string, string> = { ...FALLBACK }
349
- for (const k of Object.keys(FALLBACK)) {
350
- const h = hex(th?.[k])
351
- if (h) p[k] = h
352
- }
353
- setPal(p)
354
- })
355
- const colors = () => pal()
356
-
357
- const barW = () => Math.max(4, Math.min(12, panelWidth() - 34))
358
-
359
- const colorFor = (percent: number) => {
360
- const c = colors()
361
- if (percent >= WARN_THRESHOLD * 100) return c.error
362
- if (percent >= 50) return c.warning
363
- return c.success
364
- }
365
-
366
- const renderRow = (label: string, u: { usagePercent: number; resetInSec: number }) => {
367
- const labelW = visualWidth(label)
368
- const pad = Math.max(0, 9 - labelW)
369
- return (
370
- <text>
371
- <span style={{ fg: colors().text }}>{label}</span>
372
- <span>{" ".repeat(pad + 1)}</span>
373
- <span style={{ fg: colorFor(u.usagePercent) }}>
374
- {progressBar(u.usagePercent, barW())}
375
- </span>
376
- <span>{" "}</span>
377
- <span style={{ fg: colorFor(u.usagePercent) }}>{u.usagePercent}%</span>
378
- <span style={{ fg: colors().muted }}> {formatReset(u.resetInSec)}</span>
379
- </text>
380
- )
381
- }
382
-
383
- const sep = () => "\u2500".repeat(Math.max(1, panelWidth() - 4))
384
-
385
- return (
386
- <box
387
- border
388
- borderColor={colors().border}
389
- paddingLeft={1}
390
- paddingRight={1}
391
- flexDirection="column"
392
- gap={0}
393
- ref={boxEl}
394
- onSizeChange={() => {
395
- if (resizeTimeout) clearTimeout(resizeTimeout)
396
- resizeTimeout = setTimeout(() => {
397
- const w = boxEl ? Math.max(20, boxEl.width ?? 24) : 24
398
- setPanelWidth(w)
399
- }, 100)
400
- }}
401
- >
402
- <text onMouseUp={() => setOpen(o => !o)}>
403
- <span style={{ fg: colors().muted }}>{open() ? "\u25bc " : "\u25b6 "}</span>
404
- <span style={{ fg: colors().primary }}><b>{t("panelTitle")}</b></span>
405
- <span style={{ fg: colors().muted }}> v{VERSION}</span>
406
- <Show when={!open() && usage()}>
407
- <span style={{ fg: colorFor(usage()!.weeklyUsage.usagePercent) }}>
408
- {" ".repeat(2)}{t("weeklyShort")} {usage()!.weeklyUsage.usagePercent}%
409
- </span>
410
- </Show>
411
- </text>
412
-
413
- <Show when={open()}>
414
- <text fg={colors().muted}>{sep()}</text>
415
-
416
- <Show when={!configured()}>
417
- <text fg={colors().warning}>{t("notConfigured")}</text>
418
- <text fg={colors().muted}>{t("notConfiguredHint")}</text>
419
- </Show>
420
-
421
- <Show when={configured() && error()} fallback={
422
- <Show when={configured() && usage()} fallback={
423
- <Show when={configured()}><text fg={colors().muted}>{t("loading")}</text></Show>
424
- }>
425
- {renderRow(t("rowRolling"), usage()!.rollingUsage)}
426
- {renderRow(t("rowWeekly"), usage()!.weeklyUsage)}
427
- {renderRow(t("rowMonthly"), usage()!.monthlyUsage)}
428
- <text>
429
- <span style={{ fg: colors().muted }}>{t("updatedAt")} </span>
430
- <span style={{ fg: colors().muted }}>{lastUpdated()}</span>
431
- </text>
432
- </Show>
433
- }>
434
- <text fg={colors().error}>{error()}</text>
435
- <text fg={colors().muted}>{t("reconfigureHint")}</text>
436
- </Show>
437
- </Show>
438
- </box>
439
- )
440
- }
441
-
442
- function PricePanel(props: { theme: TuiThemeCurrent; api: TuiPluginApi }): JSX.Element {
443
- const [open, setOpen] = createSignal(true)
444
- const [pricingStore, setPricingStore] = createSignal<PricingStore | null>(null)
445
- const [priceError, setPriceError] = createSignal("")
446
- const [priceLoading, setPriceLoading] = createSignal(false)
447
- const [panelWidth, setPanelWidth] = createSignal(24)
448
- const [cfg, setCfg] = createSignal<LoadedConfig>(loadConfig())
449
- createEffect(() => { void configTick(); setCfg(loadConfig()) })
450
- let boxEl: any
451
- let priceController: AbortController | null = null
452
-
453
- async function refreshPricing() {
454
- if (priceLoading()) return
455
- setPriceLoading(true)
456
- setPriceError("")
457
- priceController?.abort()
458
- priceController = new AbortController()
459
- try {
460
- const data = await fetchPricing()
461
- const store = updatePricingStore(data)
462
- setSharedPricing(data)
463
- setPricingStore(store)
464
- const prev = store.previous
465
- if (prev && prev.fetchTime !== data.fetchTime) {
466
- const diff = comparePricing(prev, data)
467
- if (diff.hasChanges) {
468
- props.api.toast({
469
- variant: "info",
470
- title: t("priceChanged"),
471
- message: t("priceChangesSummary", {
472
- t: diff.summary.total,
473
- a: diff.summary.added,
474
- r: diff.summary.removed,
475
- p: diff.summary.priceChanges,
476
- l: diff.summary.limitChanges,
477
- }),
478
- })
479
- }
480
- }
481
- } catch {
482
- setPriceError(t("priceError"))
483
- } finally {
484
- setPriceLoading(false)
485
- priceController = null
486
- }
487
- }
488
-
489
- function formatCount(n: number): string {
490
- if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, "") + "k"
491
- return String(n)
492
- }
493
-
494
- function visualPadEnd(s: string, w: number): string {
495
- const cur = visualWidth(s)
496
- if (cur >= w) return s
497
- return s + " ".repeat(w - cur)
498
- }
499
-
500
- function abbreviateName(name: string, maxLen: number): string {
501
- if (visualWidth(name) <= maxLen) return name
502
- let out = ""
503
- let w = 0
504
- for (const c of name) {
505
- const cw = visualWidth(c)
506
- if (w + cw > maxLen - 1) break
507
- out += c
508
- w += cw
509
- }
510
- return out + "…"
511
- }
512
-
513
- // visual-cache 风格:左标签 + 自动填充 + 右值
514
- function justify(label: string, value: string): string {
515
- const gauge = panelWidth() - 4
516
- const used = visualWidth(label) + visualWidth(value)
517
- const gap = Math.max(1, gauge - used)
518
- return label + " ".repeat(gap) + value
519
- }
520
-
521
- function renderLimitRow(m: ModelPrice) {
522
- const name = abbreviateName(m.name, 18)
523
- const req = `${formatCount(m.limits.fiveHour)}/${formatCount(m.limits.weekly)}/${formatCount(m.limits.monthly)}`
524
- return (
525
- <text>
526
- <span style={{ fg: colors().text }}>{justify(name, req)}</span>
527
- </text>
528
- )
529
- }
530
-
531
- const [pal, setPal] = createSignal<Record<string, string>>({ ...FALLBACK })
532
- createEffect(() => {
533
- const th = props.theme as any
534
- const p: Record<string, string> = { ...FALLBACK }
535
- for (const k of Object.keys(FALLBACK)) {
536
- const h = hex(th?.[k])
537
- if (h) p[k] = h
538
- }
539
- setPal(p)
540
- })
541
- const colors = () => pal()
542
-
543
- const sep = () => "\u2500".repeat(Math.max(1, panelWidth() - 4))
544
-
545
- setTimeout(() => {
546
- const store = loadPricingStore()
547
- if (store.current.models.length > 0) {
548
- setSharedPricing(store.current)
549
- setPricingStore(store)
550
- }
551
- refreshPricing()
552
- setInterval(() => refreshPricing(), loadConfig().pricingIntervalMs)
553
- }, 2000)
554
-
555
- return (
556
- <box
557
- border
558
- borderColor={colors().border}
559
- paddingLeft={1}
560
- paddingRight={1}
561
- flexDirection="column"
562
- gap={0}
563
- ref={boxEl}
564
- onSizeChange={() => {
565
- if (resizeTimeout) clearTimeout(resizeTimeout)
566
- resizeTimeout = setTimeout(() => {
567
- const w = boxEl ? Math.max(20, boxEl.width ?? 24) : 24
568
- setPanelWidth(w)
569
- }, 100)
570
- }}
571
- >
572
- <text onMouseUp={() => setOpen(o => !o)}>
573
- <span style={{ fg: colors().muted }}>{open() ? "\u25bc " : "\u25b6 "}</span>
574
- <span style={{ fg: colors().primary }}><b>{t("priceTitle")}</b></span>
575
- <Show when={sharedPricing()}>
576
- <span style={{ fg: colors().muted }}> ({sharedPricing()!.models.length})</span>
577
- </Show>
578
- <span style={{ fg: colors().muted }}>{sep().slice(visualWidth((open() ? "\u25bc " : "\u25b6 ") + t("priceTitle") + (sharedPricing() ? ` (${sharedPricing()!.models.length})` : "")))}</span>
579
- </text>
580
-
581
- <Show when={open()}>
582
- <text fg={colors().muted}>
583
- {justify(t("priceModelCol"), t("priceReqs"))}
584
- </text>
585
- <text fg={colors().muted}>{sep()}</text>
586
-
587
- <Show when={priceLoading() || !sharedPricing()} fallback={
588
- <Show when={sharedPricing()} fallback={
589
- <text fg={colors().muted}>{t("priceLoading")}</text>
590
- }>
591
- {sharedPricing()!.models.filter((m) => {
592
- return cfg().focusModels.length === 0 || cfg().focusModels.includes(m.id)
593
- }).map((m) => renderLimitRow(m))}
594
- <text fg={colors().muted}>
595
- {justify(t("priceUpdated"), new Date(sharedPricing()!.fetchTime).toLocaleTimeString())}
596
- </text>
597
- <Show when={pricingStore() && pricingStore()!.history.length > 0}>
598
- <text fg={colors().warning}>
599
- {t("priceChangesSummary", {
600
- t: pricingStore()!.history[0].summary.total,
601
- a: pricingStore()!.history[0].summary.added,
602
- r: pricingStore()!.history[0].summary.removed,
603
- p: pricingStore()!.history[0].summary.priceChanges,
604
- l: pricingStore()!.history[0].summary.limitChanges,
605
- })}
606
- </text>
607
- </Show>
608
- </Show>
609
- }>
610
- <text fg={colors().muted}>{t("priceLoading")}</text>
611
- </Show>
612
- </Show>
613
- </box>
614
- )
615
- }
616
-
617
-
618
- function createSidebarSlot(api: TuiPluginApi): TuiSlotPlugin {
619
- return {
620
- order: 60,
621
- slots: {
622
- sidebar_content(ctx: TuiSlotContext, _input: { session_id: string }): JSX.Element {
623
- return <GoUsagePanel theme={ctx.theme.current} api={api} />
624
- },
625
- },
626
- }
627
- }
628
-
629
- function createPricingSlot(api: TuiPluginApi): TuiSlotPlugin {
630
- return {
631
- order: 61,
632
- slots: {
633
- sidebar_content(ctx: TuiSlotContext, _input: { session_id: string }): JSX.Element {
634
- return <PricePanel theme={ctx.theme.current} api={api} />
635
- },
636
- },
637
- }
638
- }
639
-
640
- function runConfigDialog(api: TuiPluginApi, dialog: any): void {
641
- dialog?.replace(() => (
642
- <api.ui.DialogPrompt
643
- title={t("wsPromptTitle")}
644
- description={() => (
645
- <text>{t("wsPromptDesc")}</text>
646
- )}
647
- placeholder={t("wsPlaceholder")}
648
- onConfirm={(value) => {
649
- const wsId = value.trim()
650
- if (!wsId) { dialog?.clear(); return }
651
- saveConfig({ workspace_id: wsId })
652
- dialog?.replace(() => (
653
- <api.ui.DialogPrompt
654
- title={t("cookiePromptTitle")}
655
- description={() => (
656
- <text>{t("cookiePromptDesc")}</text>
657
- )}
658
- placeholder={t("cookiePlaceholder")}
659
- onConfirm={(val) => {
660
- const cookie = val.trim()
661
- if (!cookie) { dialog?.clear(); return }
662
- saveConfig({ cookie })
663
- setConfigTick((v) => v + 1)
664
- api.ui.toast({ variant: "success", message: t("configSaved") })
665
- dialog?.clear()
666
- }}
667
- onCancel={() => dialog?.clear()}
668
- />
669
- ))
670
- }}
671
- onCancel={() => dialog?.clear()}
672
- />
673
- ))
674
- }
675
-
676
- function runLangDialog(api: TuiPluginApi, dialog: any): void {
677
- dialog?.replace(() => (
678
- <api.ui.DialogSelect
679
- title={t("langTitle")}
680
- options={LANG_META.map((m) => ({
681
- title: `${m.label}${langCode() === m.code ? " \u2713" : ""}`,
682
- value: m.code,
683
- }))}
684
- onSelect={(opt) => {
685
- const code = opt.value as LangCode
686
- setLangCode(code)
687
- kvTrySet(api, `${KV_PREFIX}.lang`, code)
688
- api.ui.toast({ message: code === "zh" ? t("langSwitchedZh") : t("langSwitchedEn") })
689
- // 语言选择后:若无配置则继续引导配置账号
690
- const cfg = loadConfig()
691
- if (!cfg.workspaceId || !cfg.authCookie) {
692
- runConfigDialog(api, dialog)
693
- } else {
694
- dialog?.clear()
695
- }
696
- }}
697
- onCancel={() => dialog?.clear()}
698
- />
699
- ))
700
- }
701
-
702
- function runSettingsDialog(api: TuiPluginApi, dialog: any): void {
703
- let lastSelected: string | undefined
704
- const renderSettingsDialog = (dialog2: any) => {
705
- const cfg = loadConfig()
706
- const options = [
707
- {
708
- title: `${t("settingsFocus")} (${cfg.focusModels.length} 个)`,
709
- value: "focus-models",
710
- },
711
- {
712
- title: `${t("settingsRefreshUsage")} (${Math.round(cfg.refreshIntervalMs / 1000)}s)`,
713
- value: "refresh-usage",
714
- },
715
- {
716
- title: `${t("settingsRefreshPricing")} (${Math.round(cfg.pricingIntervalMs / 1000)}s)`,
717
- value: "refresh-pricing",
718
- },
719
- ]
720
- dialog2?.replace(() => (
721
- <api.ui.DialogSelect
722
- title={t("settingsTitle")}
723
- options={options}
724
- onSelect={(opt) => {
725
- const value = opt.value
726
- if (value === "focus-models") {
727
- const renderFocusDialog = (dialog3: any) => {
728
- const cur = loadConfig().focusModels
729
- const allModels = sharedPricing()?.models ?? []
730
- const modelOptions = allModels.map((m) => ({
731
- title: `${cur.includes(m.id) ? "\u2611" : "\u2610"} ${m.name} (${m.pricing.input ?? "-"}/${m.pricing.output ?? "-"})`,
732
- value: m.id,
733
- }))
734
- const focusOptions = [
735
- ...modelOptions,
736
- { title: `${t("focusAll")} (${cur.length} 个已选)`, value: "select-all" },
737
- { title: t("focusNone"), value: "clear-all" },
738
- { title: `${t("focusDone")} (${t("focusSelected", { n: cur.length })})`, value: "done" },
739
- ]
740
- dialog3?.replace(() => (
741
- <api.ui.DialogSelect
742
- title={t("settingsFocus")}
743
- options={focusOptions}
744
- current={lastSelected}
745
- onSelect={(opt2) => {
746
- const v2 = opt2.value
747
- if (v2 === "done") {
748
- dialog3?.clear()
749
- renderSettingsDialog(dialog2)
750
- } else if (v2 === "select-all") {
751
- const all = allModels.map((m) => m.id)
752
- saveConfig({ focus_models: all })
753
- setConfigTick((v) => v + 1)
754
- api.ui.toast({ variant: "success", message: t("settingsSaved") })
755
- renderFocusDialog(dialog3)
756
- } else if (v2 === "clear-all") {
757
- saveConfig({ focus_models: [] })
758
- setConfigTick((v) => v + 1)
759
- api.ui.toast({ variant: "success", message: t("settingsSaved") })
760
- renderFocusDialog(dialog3)
761
- } else {
762
- const next = cur.includes(v2) ? cur.filter((id) => id !== v2) : [...cur, v2]
763
- saveConfig({ focus_models: next })
764
- setConfigTick((v) => v + 1)
765
- lastSelected = v2
766
- renderFocusDialog(dialog3)
767
- }
768
- }}
769
- onCancel={() => dialog3?.clear()}
770
- />
771
- ))
772
- }
773
- renderFocusDialog(dialog2)
774
- } else if (value === "refresh-usage") {
775
- dialog2?.replace(() => (
776
- <api.ui.DialogPrompt
777
- title={t("settingsRefreshUsage")}
778
- description={() => <text>{"输入秒数(默认 60)"}</text>}
779
- placeholder="60"
780
- onConfirm={(val) => {
781
- const sec = Number(val.trim())
782
- if (!sec || sec <= 0) { dialog2?.clear(); return }
783
- saveConfig({ refresh_interval_sec: sec })
784
- setConfigTick((v) => v + 1)
785
- api.ui.toast({ variant: "success", message: t("settingsSaved") })
786
- renderSettingsDialog(dialog2)
787
- }}
788
- onCancel={() => dialog2?.clear()}
789
- />
790
- ))
791
- } else if (value === "refresh-pricing") {
792
- dialog2?.replace(() => (
793
- <api.ui.DialogPrompt
794
- title={t("settingsRefreshPricing")}
795
- description={() => <text>{"输入秒数(默认 1800)"}</text>}
796
- placeholder="1800"
797
- onConfirm={(val) => {
798
- const sec = Number(val.trim())
799
- if (!sec || sec <= 0) { dialog2?.clear(); return }
800
- saveConfig({ pricing_interval_sec: sec })
801
- setConfigTick((v) => v + 1)
802
- api.ui.toast({ variant: "success", message: t("settingsSaved") })
803
- renderSettingsDialog(dialog2)
804
- }}
805
- onCancel={() => dialog2?.clear()}
806
- />
807
- ))
808
- }
809
- }}
810
- onCancel={() => dialog2?.clear()}
811
- />
812
- ))
813
- }
814
- renderSettingsDialog(dialog)
815
- }
816
-
817
- const tui: TuiPlugin = async (api: TuiPluginApi) => {
818
- api.slots.register(createSidebarSlot(api))
819
- api.slots.register(createPricingSlot(api))
820
-
821
- // 恢复已保存的语言;若无则按系统检测
822
- const savedLang = kvTryGet(api, `${KV_PREFIX}.lang`) as LangCode | undefined
823
- if (savedLang === "zh" || savedLang === "en") setLangCode(savedLang)
824
- else setLangCode(detectLang())
825
-
826
- // 首次启动引导:已引导过则跳过(用 kv 标记)
827
- const onboarded = kvTryGet(api, `${KV_PREFIX}.onboarded`)
828
- if (!onboarded) {
829
- kvTrySet(api, `${KV_PREFIX}.onboarded`, "1")
830
- setTimeout(() => {
831
- // 通过 trigger 触发语言选择命令,命令 onSelect 会拿到 dialog 栈并弹窗
832
- try { api.command.trigger("go-usage.lang") } catch { /* 命令未就绪时忽略 */ }
833
- }, 1500)
834
- }
835
-
836
- api.command?.register(() => [
837
- {
838
- title: t("cmdTitle"),
839
- value: "go-usage.config",
840
- description: t("cmdDesc"),
841
- slash: { name: "go-config" },
842
- onSelect: (dialog) => runConfigDialog(api, dialog),
843
- },
844
- {
845
- title: t("langCmdTitle"),
846
- value: "go-usage.lang",
847
- description: t("langCmdDesc"),
848
- slash: { name: "go-lang" },
849
- onSelect: (dialog) => runLangDialog(api, dialog),
850
- },
851
- {
852
- title: t("settingsTitle"),
853
- value: "go-usage.settings",
854
- description: t("settingsDesc"),
855
- slash: { name: "go-settings" },
856
- onSelect: (dialog) => runSettingsDialog(api, dialog),
857
- },
858
- ])
859
- }
860
-
861
- const mod: TuiPluginModule & { id: string } = {
862
- id: "opencode-go-usage-tui",
863
- tui,
864
- }
865
-
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, renameSync, existsSync } from "node:fs"
14
+ import { join, dirname } from "node:path"
15
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"
16
+ import { createT, detectLang, LANG_META } from "./i18n"
17
+ import type { LangCode } from "./i18n"
18
+ import { fetchPricing, comparePricing, loadPricingStore, updatePricingStore } from "./pricing"
19
+ import type { ModelPrice, PricingData, PricingStore } from "./pricing"
20
+
21
+ declare const process: { env: Record<string, string | undefined> } | undefined
22
+
23
+ const VERSION: string = __PLUGIN_VERSION__
24
+ const CHECK_INTERVAL = Number(process?.env?.OPENCODE_GO_CHECK_INTERVAL ?? 60000)
25
+ const WARN_THRESHOLD = Number(process?.env?.OPENCODE_GO_WARN_THRESHOLD ?? 0.8)
26
+ const CONFIG_DIR = process?.env?.OPENCODE_CONFIG_DIR
27
+ || (process?.env?.USERPROFILE ? `${process.env.USERPROFILE}\\.config\\opencode` : "")
28
+ || process?.env?.HOME + "/.config/opencode"
29
+ const CONFIG_FILE = join(CONFIG_DIR, "go-usage-config.json")
30
+ const COOKIE_ENC_FILE = join(CONFIG_DIR, "go-auth-cookie.enc")
31
+ const KEY_FILE = join(CONFIG_DIR, ".encryption-key")
32
+ const COOKIE_PLAIN_FILE = `${CONFIG_DIR}\\go-auth-cookie.txt`
33
+ const KV_PREFIX = "go-usage"
34
+
35
+ // ── AES-256-GCM 加密存储 ────────────────────────────────
36
+ // cookie 以密文存 go-auth-cookie.enc,密钥存 .encryption-key。
37
+ // 注意:Windows 无 Unix 权限位,mode 0o600 仅对 macOS/Linux 生效;
38
+ // Windows 上密钥与密文同目录,防护重点是"被动泄露"(备份/云同步/日志),
39
+ // 无法抵御能读取该目录的本地恶意程序。
40
+ function loadEncryptionKey(): Buffer {
41
+ try {
42
+ const hex = readFileSync(KEY_FILE, "utf8").trim()
43
+ if (hex) return Buffer.from(hex, "hex")
44
+ } catch { /* 无密钥则新建 */ }
45
+ const key = randomBytes(32)
46
+ mkdirSync(dirname(KEY_FILE), { recursive: true })
47
+ writeFileSync(KEY_FILE, key.toString("hex"), { encoding: "utf8", mode: 0o600 })
48
+ return key
49
+ }
50
+
51
+ function encryptText(text: string): string {
52
+ const key = loadEncryptionKey()
53
+ const iv = randomBytes(16)
54
+ const cipher = createCipheriv("aes-256-gcm", key, iv)
55
+ const data = cipher.update(text, "utf8", "hex") + cipher.final("hex")
56
+ return JSON.stringify({ v: 1, iv: iv.toString("hex"), data, tag: cipher.getAuthTag().toString("hex") })
57
+ }
58
+
59
+ function decryptText(encJson: string): string {
60
+ const { iv, data, tag } = JSON.parse(encJson)
61
+ const key = loadEncryptionKey()
62
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "hex"))
63
+ decipher.setAuthTag(Buffer.from(tag, "hex"))
64
+ return decipher.update(data, "hex", "utf8") + decipher.final("utf8")
65
+ }
66
+
67
+ function readJsonFile<T>(file: string): T | null {
68
+ try { return JSON.parse(readFileSync(file, "utf8")) as T } catch { return null }
69
+ }
70
+
71
+ interface ConfigFile { workspace_id?: string; cookie?: string; auth_cookie?: string; cookie_file?: string; ui?: { usage_panel?: boolean; price_panel?: boolean }; focus_models?: string[]; refresh_interval_sec?: number; pricing_interval_sec?: number }
72
+
73
+ interface LoadedConfig { workspaceId: string; authCookie: string; usagePanel: boolean; pricePanel: boolean; focusModels: string[]; refreshIntervalMs: number; pricingIntervalMs: number }
74
+
75
+ function loadConfig(): LoadedConfig {
76
+ const env = process?.env ?? {}
77
+ const fileCfg = readJsonFile<ConfigFile>(CONFIG_FILE) ?? {}
78
+
79
+ // 1. 环境变量优先
80
+ if (env.OPENCODE_GO_AUTH_COOKIE) {
81
+ return {
82
+ workspaceId: env.OPENCODE_GO_WORKSPACE_ID || fileCfg.workspace_id || "", authCookie: env.OPENCODE_GO_AUTH_COOKIE,
83
+ usagePanel: fileCfg.ui?.usage_panel ?? true, pricePanel: fileCfg.ui?.price_panel ?? true,
84
+ focusModels: fileCfg.focus_models ?? [], refreshIntervalMs: (fileCfg.refresh_interval_sec ?? Math.round(CHECK_INTERVAL / 1000)) * 1000,
85
+ pricingIntervalMs: (fileCfg.pricing_interval_sec ?? Math.round((CHECK_INTERVAL / 1000) * 30)) * 1000,
86
+ }
87
+ }
88
+
89
+ // 2. 加密存储
90
+ try {
91
+ if (existsSync(COOKIE_ENC_FILE)) {
92
+ const cookie = decryptText(readFileSync(COOKIE_ENC_FILE, "utf8"))
93
+ if (cookie) {
94
+ return {
95
+ workspaceId: env.OPENCODE_GO_WORKSPACE_ID || fileCfg.workspace_id || "", authCookie: cookie,
96
+ usagePanel: fileCfg.ui?.usage_panel ?? true, pricePanel: fileCfg.ui?.price_panel ?? true,
97
+ focusModels: fileCfg.focus_models ?? [], refreshIntervalMs: (fileCfg.refresh_interval_sec ?? Math.round(CHECK_INTERVAL / 1000)) * 1000,
98
+ pricingIntervalMs: (fileCfg.pricing_interval_sec ?? Math.round((CHECK_INTERVAL / 1000) * 30)) * 1000,
99
+ }
100
+ }
101
+ }
102
+ } catch { /* 密文损坏时回退到明文迁移 */ }
103
+
104
+ // 3. 兼容旧明文(config.json 内嵌 cookie / auth_cookie / 独立 txt 文件)——自动迁移
105
+ const legacyCookie = fileCfg.auth_cookie || fileCfg.cookie || (() => {
106
+ try { return readFileSync(COOKIE_PLAIN_FILE, "utf8").trim() } catch { return "" }
107
+ })()
108
+ if (legacyCookie) {
109
+ const enc = encryptText(legacyCookie)
110
+ mkdirSync(CONFIG_DIR, { recursive: true })
111
+ writeFileSync(COOKIE_ENC_FILE, enc, "utf8")
112
+ // 清理旧明文
113
+ const cleanCfg: ConfigFile = { ...fileCfg }
114
+ delete cleanCfg.cookie
115
+ delete cleanCfg.auth_cookie
116
+ try { writeFileSync(CONFIG_FILE, JSON.stringify(cleanCfg, null, 2), "utf8") } catch { /* 忽略 */ }
117
+ try { if (existsSync(COOKIE_PLAIN_FILE)) renameSync(COOKIE_PLAIN_FILE, COOKIE_PLAIN_FILE + ".bak") } catch { /* 忽略 */ }
118
+ return {
119
+ workspaceId: env.OPENCODE_GO_WORKSPACE_ID || fileCfg.workspace_id || "", authCookie: legacyCookie,
120
+ usagePanel: fileCfg.ui?.usage_panel ?? true, pricePanel: fileCfg.ui?.price_panel ?? true,
121
+ focusModels: fileCfg.focus_models ?? [], refreshIntervalMs: (fileCfg.refresh_interval_sec ?? Math.round(CHECK_INTERVAL / 1000)) * 1000,
122
+ pricingIntervalMs: (fileCfg.pricing_interval_sec ?? Math.round((CHECK_INTERVAL / 1000) * 30)) * 1000,
123
+ }
124
+ }
125
+
126
+ return {
127
+ workspaceId: env.OPENCODE_GO_WORKSPACE_ID || fileCfg.workspace_id || "", authCookie: "",
128
+ usagePanel: fileCfg.ui?.usage_panel ?? true, pricePanel: fileCfg.ui?.price_panel ?? true,
129
+ focusModels: fileCfg.focus_models ?? [], refreshIntervalMs: (fileCfg.refresh_interval_sec ?? Math.round(CHECK_INTERVAL / 1000)) * 1000,
130
+ pricingIntervalMs: (fileCfg.pricing_interval_sec ?? Math.round((CHECK_INTERVAL / 1000) * 30)) * 1000,
131
+ }
132
+ }
133
+
134
+ function saveConfig(patch: { workspace_id?: string; cookie?: string; ui?: { usage_panel?: boolean; price_panel?: boolean }; focus_models?: string[]; refresh_interval_sec?: number; pricing_interval_sec?: number }): void {
135
+ const fileCfg = readJsonFile<ConfigFile>(CONFIG_FILE) ?? {}
136
+ if (patch.workspace_id !== undefined) fileCfg.workspace_id = patch.workspace_id
137
+ if (patch.cookie !== undefined) {
138
+ // cookie 改为加密存储,不再写明文到 config.json
139
+ if (patch.cookie) {
140
+ mkdirSync(CONFIG_DIR, { recursive: true })
141
+ writeFileSync(COOKIE_ENC_FILE, encryptText(patch.cookie), "utf8")
142
+ delete fileCfg.cookie
143
+ delete fileCfg.auth_cookie
144
+ // 清理可能存在的旧明文 txt
145
+ try { if (existsSync(COOKIE_PLAIN_FILE)) renameSync(COOKIE_PLAIN_FILE, COOKIE_PLAIN_FILE + ".bak") } catch { /* 忽略 */ }
146
+ } else {
147
+ try { if (existsSync(COOKIE_ENC_FILE)) renameSync(COOKIE_ENC_FILE, COOKIE_ENC_FILE + ".bak") } catch { /* 忽略 */ }
148
+ }
149
+ }
150
+ if (patch.ui !== undefined) {
151
+ fileCfg.ui = { ...(fileCfg.ui ?? {}), ...patch.ui }
152
+ }
153
+ if (patch.focus_models !== undefined) fileCfg.focus_models = patch.focus_models
154
+ if (patch.refresh_interval_sec !== undefined) fileCfg.refresh_interval_sec = patch.refresh_interval_sec
155
+ if (patch.pricing_interval_sec !== undefined) fileCfg.pricing_interval_sec = patch.pricing_interval_sec
156
+ try {
157
+ mkdirSync(dirname(CONFIG_FILE), { recursive: true })
158
+ writeFileSync(CONFIG_FILE, JSON.stringify(fileCfg, null, 2), "utf8")
159
+ } catch { /* 写入失败忽略 */ }
160
+ }
161
+
162
+ // 模块级共享信号:/go-config 保存配置后递增,面板监听它立即刷新
163
+ const [configTick, setConfigTick] = createSignal(0)
164
+
165
+ // 模块级共享语言信号:/go-lang 或首次引导切换后,面板和命令实时响应
166
+ const [langCode, setLangCode] = createSignal<LangCode>(detectLang())
167
+ const t = createT(() => langCode())
168
+
169
+ // 模块级共享价格数据:PricePanel 抓取后写入,/go-settings 设置关注模型时读取
170
+ const [sharedPricing, setSharedPricing] = createSignal<PricingData | null>(null)
171
+
172
+ function kvTryGet(api: TuiPluginApi, key: string): string | undefined {
173
+ try { return api.kv.get<string>(key) } catch { return undefined }
174
+ }
175
+ function kvTrySet(api: TuiPluginApi, key: string, val: unknown): void {
176
+ try { api.kv.set(key, val) } catch { /* 忽略 */ }
177
+ }
178
+
179
+ interface Usage {
180
+ rollingUsage: { usagePercent: number; resetInSec: number }
181
+ weeklyUsage: { usagePercent: number; resetInSec: number }
182
+ monthlyUsage: { usagePercent: number; resetInSec: number }
183
+ error?: string
184
+ }
185
+
186
+ const REQUEST_TIMEOUT = 10000
187
+
188
+ async function fetchUsage(workspaceId: string, authCookie: string, signal?: AbortSignal): Promise<Usage | null> {
189
+ if (!workspaceId || !authCookie) return { error: "not_configured" } as Usage
190
+ try {
191
+ const res = await fetch("https://opencode.ai/_server", {
192
+ method: "POST",
193
+ headers: {
194
+ "content-type": "application/json",
195
+ "x-server-id": "c7389bd0e731f80f49593e5ee53835475f4e28594dd6bd83eb229bab753498cd",
196
+ "x-server-instance": "go-usage-tui",
197
+ "cookie": `auth=${authCookie}`,
198
+ "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
199
+ },
200
+ body: JSON.stringify({
201
+ t: { t: 9, i: 0, l: 1, a: [{ t: 1, s: workspaceId }], o: 0 },
202
+ f: 31,
203
+ m: [],
204
+ }),
205
+ signal,
206
+ })
207
+ if (!res.ok) {
208
+ if (res.status === 302) return { error: "cookie_expired" } as Usage
209
+ return null
210
+ }
211
+ const text = await res.text()
212
+ if (text.includes("/auth/authorize") || text.includes("location")) {
213
+ return { error: "cookie_expired" } as Usage
214
+ }
215
+ const extract = (key: string): { resetInSec: number; usagePercent: number } | null => {
216
+ const m = text.match(new RegExp(`${key}:[^}]*?\\{status:"[^"]*",resetInSec:(\\d+),usagePercent:(\\d+)\\}`))
217
+ if (!m) return null
218
+ return { resetInSec: Number(m[1]), usagePercent: Number(m[2]) }
219
+ }
220
+ const rollingUsage = extract("rollingUsage")
221
+ const weeklyUsage = extract("weeklyUsage")
222
+ const monthlyUsage = extract("monthlyUsage")
223
+ if (!rollingUsage || !weeklyUsage || !monthlyUsage) return null
224
+ return { rollingUsage, weeklyUsage, monthlyUsage }
225
+ } catch {
226
+ return null
227
+ }
228
+ }
229
+
230
+ function formatReset(sec: number): string {
231
+ if (sec <= 0) return t("resetDone")
232
+ const d = Math.floor(sec / 86400)
233
+ const h = Math.floor((sec % 86400) / 3600)
234
+ const m = Math.floor((sec % 3600) / 60)
235
+ if (d > 0) return t("dayHour", { d, h })
236
+ if (h > 0) return t("hourMin", { h, m })
237
+ return t("minute", { m })
238
+ }
239
+
240
+ function progressBar(percent: number, width: number): string {
241
+ const clamped = Math.max(0, Math.min(100, percent))
242
+ const filled = Math.round((clamped / 100) * width)
243
+ const empty = Math.max(0, width - filled)
244
+ return "\u2588".repeat(filled) + "\u2591".repeat(empty)
245
+ }
246
+
247
+ function visualWidth(s: string): number {
248
+ let w = 0
249
+ for (const c of s) {
250
+ const code = c.codePointAt(0) ?? 0
251
+ w += (code >= 0x1100 && code <= 0x115F) ||
252
+ (code >= 0x2E80 && code <= 0xA4CF) ||
253
+ (code >= 0xAC00 && code <= 0xD7A3) ||
254
+ (code >= 0xF900 && code <= 0xFAFF) ||
255
+ (code >= 0xFF01 && code <= 0xFF60) ||
256
+ (code >= 0xFFE0 && code <= 0xFFE6) ? 2 : 1
257
+ }
258
+ return w
259
+ }
260
+
261
+ const FALLBACK = {
262
+ primary: "#8B9DAF",
263
+ text: "#C5C5BB",
264
+ muted: "#7A7A72",
265
+ success: "#9CAF8B",
266
+ warning: "#C5B88D",
267
+ error: "#B08A8A",
268
+ border: "#6B6B63",
269
+ } as const
270
+
271
+ function hex(raw: any): string {
272
+ if (typeof raw === "string" && raw.startsWith("#") && raw.length >= 7) return raw
273
+ if (raw && typeof raw === "object" && typeof raw.r === "number") {
274
+ const r = Math.round(raw.r * 255), g = Math.round(raw.g * 255), b = Math.round(raw.b * 255)
275
+ return "#" + [r, g, b].map(v => v.toString(16).padStart(2, "0")).join("")
276
+ }
277
+ return ""
278
+ }
279
+
280
+ function GoUsagePanel(props: { theme: TuiThemeCurrent; api: TuiPluginApi }): JSX.Element {
281
+ const [usage, setUsage] = createSignal<Usage | null>(null)
282
+ const [open, setOpen] = createSignal(true)
283
+ const [error, setError] = createSignal("")
284
+ const [configured, setConfigured] = createSignal(true)
285
+ const [lastUpdated, setLastUpdated] = createSignal("")
286
+ const [panelWidth, setPanelWidth] = createSignal(24)
287
+ const [cfg, setCfg] = createSignal<LoadedConfig>(loadConfig())
288
+ createEffect(() => { void configTick(); setCfg(loadConfig()) })
289
+ let boxEl: any
290
+ let abortController: AbortController | null = null
291
+ let timeoutId: ReturnType<typeof setTimeout> | null = null
292
+ let isRefreshing = false
293
+ let resizeTimeout: ReturnType<typeof setTimeout> | null = null
294
+
295
+ function cleanupPendingRequest() {
296
+ if (timeoutId) { clearTimeout(timeoutId); timeoutId = null }
297
+ if (abortController) { abortController.abort(); abortController = null }
298
+ }
299
+
300
+ async function refresh() {
301
+ if (isRefreshing) return
302
+ const cfg = loadConfig()
303
+ if (!cfg.workspaceId || !cfg.authCookie) {
304
+ setConfigured(false)
305
+ setUsage(null)
306
+ setError("")
307
+ return
308
+ }
309
+ setConfigured(true)
310
+ cleanupPendingRequest()
311
+ abortController = new AbortController()
312
+ const signal = abortController.signal
313
+ timeoutId = setTimeout(() => abortController?.abort(), REQUEST_TIMEOUT)
314
+ isRefreshing = true
315
+ try {
316
+ const u = await fetchUsage(cfg.workspaceId, cfg.authCookie, signal)
317
+ if (signal.aborted) return
318
+ if (u === null) { setError(t("queryFailed")); return }
319
+ if (u.error === "cookie_expired") { setError(t("cookieExpired")); return }
320
+ if (u.error === "not_configured") { setConfigured(false); return }
321
+ setUsage(u)
322
+ setLastUpdated(new Date().toLocaleTimeString())
323
+ setError("")
324
+ } finally {
325
+ if (timeoutId) { clearTimeout(timeoutId); timeoutId = null }
326
+ isRefreshing = false
327
+ }
328
+ }
329
+
330
+ onMount(() => {
331
+ refresh()
332
+ const timer = setInterval(refresh, loadConfig().refreshIntervalMs)
333
+ const stopWatch = createEffect(() => {
334
+ void configTick()
335
+ refresh()
336
+ })
337
+ onCleanup(() => {
338
+ clearInterval(timer)
339
+ stopWatch()
340
+ cleanupPendingRequest()
341
+ if (resizeTimeout) { clearTimeout(resizeTimeout); resizeTimeout = null }
342
+ })
343
+ })
344
+
345
+ const [pal, setPal] = createSignal<Record<string, string>>({ ...FALLBACK })
346
+ createEffect(() => {
347
+ const th = props.theme as any
348
+ const p: Record<string, string> = { ...FALLBACK }
349
+ for (const k of Object.keys(FALLBACK)) {
350
+ const h = hex(th?.[k])
351
+ if (h) p[k] = h
352
+ }
353
+ setPal(p)
354
+ })
355
+ const colors = () => pal()
356
+
357
+ const barW = () => Math.max(4, Math.min(12, panelWidth() - 34))
358
+
359
+ const colorFor = (percent: number) => {
360
+ const c = colors()
361
+ if (percent >= WARN_THRESHOLD * 100) return c.error
362
+ if (percent >= 50) return c.warning
363
+ return c.success
364
+ }
365
+
366
+ const renderRow = (label: string, u: { usagePercent: number; resetInSec: number }) => {
367
+ const labelW = visualWidth(label)
368
+ const pad = Math.max(0, 9 - labelW)
369
+ return (
370
+ <text>
371
+ <span style={{ fg: colors().text }}>{label}</span>
372
+ <span>{" ".repeat(pad + 1)}</span>
373
+ <span style={{ fg: colorFor(u.usagePercent) }}>
374
+ {progressBar(u.usagePercent, barW())}
375
+ </span>
376
+ <span>{" "}</span>
377
+ <span style={{ fg: colorFor(u.usagePercent) }}>{u.usagePercent}%</span>
378
+ <span style={{ fg: colors().muted }}> {formatReset(u.resetInSec)}</span>
379
+ </text>
380
+ )
381
+ }
382
+
383
+ const sep = () => "\u2500".repeat(Math.max(1, panelWidth() - 4))
384
+
385
+ return (
386
+ <box
387
+ border
388
+ borderColor={colors().border}
389
+ paddingLeft={1}
390
+ paddingRight={1}
391
+ flexDirection="column"
392
+ gap={0}
393
+ ref={boxEl}
394
+ onSizeChange={() => {
395
+ if (resizeTimeout) clearTimeout(resizeTimeout)
396
+ resizeTimeout = setTimeout(() => {
397
+ const w = boxEl ? Math.max(20, boxEl.width ?? 24) : 24
398
+ setPanelWidth(w)
399
+ }, 100)
400
+ }}
401
+ >
402
+ <text selectable={false} onMouseUp={(e) => { e.preventDefault(); setOpen(o => !o) }}>
403
+ <span style={{ fg: colors().muted }}>{open() ? "\u25bc " : "\u25b6 "}</span>
404
+ <span style={{ fg: colors().primary }}><b>{t("panelTitle")}</b></span>
405
+ <span style={{ fg: colors().muted }}> v{VERSION}</span>
406
+ <Show when={!open() && usage()}>
407
+ <span style={{ fg: colorFor(usage()!.weeklyUsage.usagePercent) }}>
408
+ {" ".repeat(2)}{t("weeklyShort")} {usage()!.weeklyUsage.usagePercent}%
409
+ </span>
410
+ </Show>
411
+ </text>
412
+
413
+ <Show when={open()}>
414
+ <text fg={colors().muted}>{sep()}</text>
415
+
416
+ <Show when={!configured()}>
417
+ <text fg={colors().warning}>{t("notConfigured")}</text>
418
+ <text fg={colors().muted}>{t("notConfiguredHint")}</text>
419
+ </Show>
420
+
421
+ <Show when={configured() && error()} fallback={
422
+ <Show when={configured() && usage()} fallback={
423
+ <Show when={configured()}><text fg={colors().muted}>{t("loading")}</text></Show>
424
+ }>
425
+ {renderRow(t("rowRolling"), usage()!.rollingUsage)}
426
+ {renderRow(t("rowWeekly"), usage()!.weeklyUsage)}
427
+ {renderRow(t("rowMonthly"), usage()!.monthlyUsage)}
428
+ <text>
429
+ <span style={{ fg: colors().muted }}>{t("updatedAt")} </span>
430
+ <span style={{ fg: colors().muted }}>{lastUpdated()}</span>
431
+ </text>
432
+ </Show>
433
+ }>
434
+ <text fg={colors().error}>{error()}</text>
435
+ <text fg={colors().muted}>{t("reconfigureHint")}</text>
436
+ </Show>
437
+ </Show>
438
+ </box>
439
+ )
440
+ }
441
+
442
+ function PricePanel(props: { theme: TuiThemeCurrent; api: TuiPluginApi }): JSX.Element {
443
+ const [open, setOpen] = createSignal(true)
444
+ const [pricingStore, setPricingStore] = createSignal<PricingStore | null>(null)
445
+ const [priceError, setPriceError] = createSignal("")
446
+ const [priceLoading, setPriceLoading] = createSignal(false)
447
+ const [panelWidth, setPanelWidth] = createSignal(24)
448
+ // 记录变化的模型:modelId -> 变化类别集合(added/pricing/limits),用于行高亮与颜色区分
449
+ const [changedModels, setChangedModels] = createSignal<Map<string, Set<string>>>(new Map<string, Set<string>>())
450
+ // 行内展开:当前展开的模型 id(单选 accordion)
451
+ const [expandedId, setExpandedId] = createSignal<string | null>(null)
452
+ // 挂载后延迟校正:首帧 boxEl.width 尚未测量,布局完成后再触发一次重渲染以拿到真实宽度
453
+ const [mounted, setMounted] = createSignal(false)
454
+ const [cfg, setCfg] = createSignal<LoadedConfig>(loadConfig())
455
+ createEffect(() => { void configTick(); setCfg(loadConfig()) })
456
+ let boxEl: any
457
+ let priceController: AbortController | null = null
458
+
459
+ onMount(() => { setTimeout(() => setMounted(true), 100) })
460
+ // 强制同步 panelWidth box 真实宽度(依赖 mounted,布局完成后重跑一次)
461
+ createEffect(() => {
462
+ mounted()
463
+ if (boxEl && typeof boxEl.width === "number" && boxEl.width > 0) {
464
+ setPanelWidth(Math.max(20, boxEl.width))
465
+ }
466
+ })
467
+
468
+ async function refreshPricing() {
469
+ if (priceLoading()) return
470
+ setPriceLoading(true)
471
+ setPriceError("")
472
+ priceController?.abort()
473
+ priceController = new AbortController()
474
+ try {
475
+ const data = await fetchPricing()
476
+ const store = updatePricingStore(data)
477
+ setSharedPricing(data)
478
+ setPricingStore(store)
479
+ const prev = store.previous
480
+ if (prev && prev.fetchTime !== data.fetchTime) {
481
+ const diff = comparePricing(prev, data)
482
+ if (diff.hasChanges) {
483
+ const m = new Map<string, Set<string>>()
484
+ for (const c of diff.changes) {
485
+ if (c.type === "removed") continue
486
+ const cat = c.type === "added" ? "added" : c.type === "pricing" ? "pricing" : "limits"
487
+ if (!m.has(c.modelId)) m.set(c.modelId, new Set())
488
+ m.get(c.modelId)!.add(cat)
489
+ }
490
+ setChangedModels(m)
491
+ props.api.toast({
492
+ variant: "info",
493
+ title: t("priceChanged"),
494
+ message: t("priceChangesSummary", {
495
+ t: diff.summary.total,
496
+ a: diff.summary.added,
497
+ r: diff.summary.removed,
498
+ l: diff.summary.limitChanges,
499
+ }),
500
+ })
501
+ } else {
502
+ setChangedModels(new Map<string, Set<string>>())
503
+ }
504
+ } else {
505
+ setChangedModels(new Map<string, Set<string>>())
506
+ }
507
+ } catch {
508
+ setPriceError(t("priceError"))
509
+ } finally {
510
+ setPriceLoading(false)
511
+ priceController = null
512
+ }
513
+ }
514
+
515
+ function visualPadEnd(s: string, w: number): string {
516
+ const cur = visualWidth(s)
517
+ if (cur >= w) return s
518
+ return s + " ".repeat(w - cur)
519
+ }
520
+
521
+ function abbreviateName(name: string, maxLen: number): string {
522
+ if (visualWidth(name) <= maxLen) return name
523
+ let out = ""
524
+ let w = 0
525
+ for (const c of name) {
526
+ const cw = visualWidth(c)
527
+ if (w + cw > maxLen - 1) break
528
+ out += c
529
+ w += cw
530
+ }
531
+ return out + "…"
532
+ }
533
+
534
+ // visual-cache 风格:左标签 + 自动填充 + 右值
535
+ // 直接读取 boxEl 实时宽度(避免 panelWidth 信号滞后)
536
+ function justify(label: string, value: string): string {
537
+ const outer = boxEl && typeof boxEl.width === "number" && boxEl.width > 0 ? boxEl.width : panelWidth()
538
+ const gauge = Math.max(10, outer - 4)
539
+ const used = visualWidth(label) + visualWidth(value)
540
+ const gap = Math.max(1, gauge - used)
541
+ return label + " ".repeat(gap) + value
542
+ }
543
+
544
+ function renderLimitRow(m: ModelPrice) {
545
+ const cats = changedModels().get(m.id)
546
+ const expanded = expandedId() === m.id
547
+ const name = abbreviateName(m.name, 18)
548
+ const req = m.usageLimit != null ? `$${m.usageLimit}` : "\u2014"
549
+ // 仅用颜色区分,不改缩进,名称始终与表头对齐
550
+ // 新增=绿色;价格变化=主色;限额/用量上限变化=橙色;普通行=常规色
551
+ const fg = cats?.has("added") ? colors().success
552
+ : cats?.has("pricing") ? colors().primary
553
+ : cats?.has("limits") ? colors().warning
554
+ : colors().text
555
+ // 行尾展开指示符(右对齐,不挤占名称列)
556
+ const ind = expanded ? " \u25be" : " \u25b8"
557
+ const row = (
558
+ <text fg={fg} selectable={false} onMouseUp={(e) => { e.preventDefault(); setExpandedId(expanded ? null : m.id) }}>
559
+ {justify(name, req + ind)}
560
+ </text>
561
+ )
562
+ if (!expanded) return row
563
+ return <>{row}{renderPriceDetail(m)}</>
564
+ }
565
+
566
+ // 行内展开的价格+元数据详情(垂直清单,字段含涨跌箭头)
567
+ // 多档模型按档位逐块展示;单档模型维持紧凑四行
568
+ function renderPriceDetail(m: ModelPrice): JSX.Element[] {
569
+ const prev = pricingStore()?.previous?.models.find((x) => x.id === m.id)
570
+ const prevVariants = prev?.variants
571
+ const arrow = (cur: number | null, old: number | null) => {
572
+ if (old == null || cur == null || old === cur) return ""
573
+ return cur > old ? " \u2191" : " \u2193"
574
+ }
575
+ const fmt = (cur: number | null, old: number | null) =>
576
+ (cur == null ? "-" : `$${cur} /MTok${arrow(cur, old)}`)
577
+ const pad13 = (s: string) => visualPadEnd(s, 13)
578
+ const lines: JSX.Element[] = []
579
+ const tiers = m.variants
580
+ const hasTiers = tiers.length > 1
581
+
582
+ if (hasTiers) {
583
+ tiers.forEach((v, ti) => {
584
+ const isLastTier = ti === tiers.length - 1
585
+ lines.push(<text fg={colors().muted}>{` \u251c ${t("priceTier")}: ${v.label}`}</text>)
586
+ const inner = isLastTier ? " " : "\u2502"
587
+ const prevV = prevVariants?.find((p) => p.label === v.label) ?? prevVariants?.[ti]
588
+ const fields: [string, string][] = [
589
+ [t("priceInput"), fmt(v.pricing.input, prevV?.pricing.input)],
590
+ [t("priceOutput"), fmt(v.pricing.output, prevV?.pricing.output)],
591
+ [t("priceCacheRead"), fmt(v.pricing.cachedRead, prevV?.pricing.cachedRead)],
592
+ [t("priceCacheWrite"), fmt(v.pricing.cachedWrite, prevV?.pricing.cachedWrite)],
593
+ ]
594
+ for (const [fl, fv] of fields) {
595
+ lines.push(<text fg={colors().muted}>{pad13(` ${inner} ${fl}:`) + fv}</text>)
596
+ }
597
+ })
598
+ } else {
599
+ const v = tiers[0]
600
+ const fields: [string, string][] = [
601
+ [t("priceInput"), fmt(v.pricing.input, prev?.pricing.input)],
602
+ [t("priceOutput"), fmt(v.pricing.output, prev?.pricing.output)],
603
+ [t("priceCacheRead"), fmt(v.pricing.cachedRead, prev?.pricing.cachedRead)],
604
+ [t("priceCacheWrite"), fmt(v.pricing.cachedWrite, prev?.pricing.cachedWrite)],
605
+ ]
606
+ for (const [fl, fv] of fields) {
607
+ lines.push(<text fg={colors().muted}>{pad13(` \u251c ${fl}:`) + fv}</text>)
608
+ }
609
+ }
610
+
611
+ const fmtFull = (n: number) => (n > 0 ? n.toLocaleString("en-US") : "\u2014")
612
+ const limitFields: [string, string][] = [
613
+ [t("priceReq5h"), fmtFull(m.limits.fiveHour)],
614
+ [t("priceReqWeek"), fmtFull(m.limits.weekly)],
615
+ [t("priceReqMonth"), fmtFull(m.limits.monthly)],
616
+ ]
617
+ for (const [fl, fv] of limitFields) {
618
+ lines.push(<text fg={colors().muted}>{pad13(` \u251c ${fl}:`) + fv}</text>)
619
+ }
620
+
621
+ const meta: [string, string][] = [
622
+ [t("priceSdk"), m.sdk || "-"],
623
+ [t("priceRetention"), m.retention || "-"],
624
+ [t("priceTraining"), m.training || "-"],
625
+ ]
626
+ meta.forEach(([ml, mv], i) => {
627
+ const conn = i < meta.length - 1 ? "\u251c" : "\u2514"
628
+ lines.push(<text fg={colors().muted}>{pad13(` ${conn} ${ml}:`) + mv}</text>)
629
+ })
630
+ return lines
631
+ }
632
+
633
+ const [pal, setPal] = createSignal<Record<string, string>>({ ...FALLBACK })
634
+ createEffect(() => {
635
+ const th = props.theme as any
636
+ const p: Record<string, string> = { ...FALLBACK }
637
+ for (const k of Object.keys(FALLBACK)) {
638
+ const h = hex(th?.[k])
639
+ if (h) p[k] = h
640
+ }
641
+ setPal(p)
642
+ })
643
+ const colors = () => pal()
644
+
645
+ const sep = () => {
646
+ const outer = boxEl && typeof boxEl.width === "number" && boxEl.width > 0 ? boxEl.width : panelWidth()
647
+ return "\u2500".repeat(Math.max(1, outer - 4))
648
+ }
649
+
650
+ setTimeout(() => {
651
+ const store = loadPricingStore()
652
+ if (store.current.models.length > 0) {
653
+ setSharedPricing(store.current)
654
+ setPricingStore(store)
655
+ }
656
+ refreshPricing()
657
+ setInterval(() => refreshPricing(), loadConfig().pricingIntervalMs)
658
+ }, 2000)
659
+
660
+ return (
661
+ <box
662
+ border
663
+ borderColor={colors().border}
664
+ paddingLeft={1}
665
+ paddingRight={1}
666
+ flexDirection="column"
667
+ gap={0}
668
+ ref={boxEl}
669
+ onSizeChange={() => {
670
+ if (resizeTimeout) clearTimeout(resizeTimeout)
671
+ resizeTimeout = setTimeout(() => {
672
+ const w = boxEl ? Math.max(20, boxEl.width ?? 24) : 24
673
+ setPanelWidth(w)
674
+ }, 100)
675
+ }}
676
+ >
677
+ <text onMouseUp={() => setOpen(o => !o)}>
678
+ <span style={{ fg: colors().muted }}>{open() ? "\u25bc " : "\u25b6 "}</span>
679
+ <span style={{ fg: colors().primary }}><b>{t("priceTitle")}</b></span>
680
+ <Show when={sharedPricing()}>
681
+ <span style={{ fg: colors().muted }}> ({sharedPricing()!.models.length})</span>
682
+ </Show>
683
+ <span style={{ fg: colors().muted }}>{sep().slice(visualWidth((open() ? "\u25bc " : "\u25b6 ") + t("priceTitle") + (sharedPricing() ? ` (${sharedPricing()!.models.length})` : "")))}</span>
684
+ </text>
685
+
686
+ <Show when={open()}>
687
+ <text fg={colors().muted}>
688
+ {justify(t("priceModelCol"), t("priceReqs"))}
689
+ </text>
690
+ <text fg={colors().muted}>{sep()}</text>
691
+
692
+ <Show when={priceLoading() || !sharedPricing()} fallback={
693
+ <Show when={sharedPricing()} fallback={
694
+ <text fg={colors().muted}>{t("priceLoading")}</text>
695
+ }>
696
+ {sharedPricing()!.models.filter((m) => {
697
+ return cfg().focusModels.length === 0 || cfg().focusModels.includes(m.id)
698
+ }).map((m) => renderLimitRow(m))}
699
+ <text fg={colors().muted}>
700
+ {justify(t("priceUpdated"), new Date(sharedPricing()!.fetchTime).toLocaleTimeString())}
701
+ </text>
702
+ <Show when={pricingStore() && pricingStore()!.history.length > 0}>
703
+ <text fg={colors().warning}>
704
+ {t("priceChangesSummary", {
705
+ t: pricingStore()!.history[0].summary.total,
706
+ a: pricingStore()!.history[0].summary.added,
707
+ r: pricingStore()!.history[0].summary.removed,
708
+ l: pricingStore()!.history[0].summary.limitChanges,
709
+ })}
710
+ </text>
711
+ </Show>
712
+ <Show when={changedModels().size > 0}>
713
+ <text fg={colors().muted}>{"绿=新增 蓝=价格 橙=限额·点击展开"}</text>
714
+ </Show>
715
+ <Show when={pricingStore() && pricingStore()!.history.length > 0 && pricingStore()!.history[0].summary.total > 0}>
716
+ <text fg={colors().muted}>
717
+ {`${t("changelogTitle")} (${new Date(pricingStore()!.history[0].fetchTime).toLocaleTimeString()})`}
718
+ </text>
719
+ {pricingStore()!.history[0].changes.slice(0, 12).map((c) => {
720
+ const label = c.type === "added" ? t("typeAdded")
721
+ : c.type === "removed" ? t("typeRemoved")
722
+ : c.type === "pricing" ? t("typePricing")
723
+ : t("typeLimits")
724
+ const fg = c.type === "added" ? colors().success
725
+ : c.type === "removed" ? colors().error
726
+ : c.type === "pricing" ? colors().primary
727
+ : colors().warning
728
+ return <text fg={fg}>{` ${label} ${abbreviateName(c.modelName, 20)}`}</text>
729
+ })}
730
+ </Show>
731
+ </Show>
732
+ }>
733
+ <text fg={colors().muted}>{t("priceLoading")}</text>
734
+ </Show>
735
+ </Show>
736
+ </box>
737
+ )
738
+ }
739
+
740
+
741
+ function createSidebarSlot(api: TuiPluginApi): TuiSlotPlugin {
742
+ return {
743
+ order: 60,
744
+ slots: {
745
+ sidebar_content(ctx: TuiSlotContext, _input: { session_id: string }): JSX.Element {
746
+ return <GoUsagePanel theme={ctx.theme.current} api={api} />
747
+ },
748
+ },
749
+ }
750
+ }
751
+
752
+ function createPricingSlot(api: TuiPluginApi): TuiSlotPlugin {
753
+ return {
754
+ order: 61,
755
+ slots: {
756
+ sidebar_content(ctx: TuiSlotContext, _input: { session_id: string }): JSX.Element {
757
+ return <PricePanel theme={ctx.theme.current} api={api} />
758
+ },
759
+ },
760
+ }
761
+ }
762
+
763
+ function runConfigDialog(api: TuiPluginApi, dialog: any): void {
764
+ dialog?.replace(() => (
765
+ <api.ui.DialogPrompt
766
+ title={t("wsPromptTitle")}
767
+ description={() => (
768
+ <text>{t("wsPromptDesc")}</text>
769
+ )}
770
+ placeholder={t("wsPlaceholder")}
771
+ onConfirm={(value) => {
772
+ const wsId = value.trim()
773
+ if (!wsId) { dialog?.clear(); return }
774
+ saveConfig({ workspace_id: wsId })
775
+ dialog?.replace(() => (
776
+ <api.ui.DialogPrompt
777
+ title={t("cookiePromptTitle")}
778
+ description={() => (
779
+ <text>{t("cookiePromptDesc")}</text>
780
+ )}
781
+ placeholder={t("cookiePlaceholder")}
782
+ onConfirm={(val) => {
783
+ const cookie = val.trim()
784
+ if (!cookie) { dialog?.clear(); return }
785
+ saveConfig({ cookie })
786
+ setConfigTick((v) => v + 1)
787
+ api.ui.toast({ variant: "success", message: t("configSaved") })
788
+ dialog?.clear()
789
+ }}
790
+ onCancel={() => dialog?.clear()}
791
+ />
792
+ ))
793
+ }}
794
+ onCancel={() => dialog?.clear()}
795
+ />
796
+ ))
797
+ }
798
+
799
+ function runLangDialog(api: TuiPluginApi, dialog: any): void {
800
+ dialog?.replace(() => (
801
+ <api.ui.DialogSelect
802
+ title={t("langTitle")}
803
+ options={LANG_META.map((m) => ({
804
+ title: `${m.label}${langCode() === m.code ? " \u2713" : ""}`,
805
+ value: m.code,
806
+ }))}
807
+ onSelect={(opt) => {
808
+ const code = opt.value as LangCode
809
+ setLangCode(code)
810
+ kvTrySet(api, `${KV_PREFIX}.lang`, code)
811
+ api.ui.toast({ message: code === "zh" ? t("langSwitchedZh") : t("langSwitchedEn") })
812
+ // 语言选择后:若无配置则继续引导配置账号
813
+ const cfg = loadConfig()
814
+ if (!cfg.workspaceId || !cfg.authCookie) {
815
+ runConfigDialog(api, dialog)
816
+ } else {
817
+ dialog?.clear()
818
+ }
819
+ }}
820
+ onCancel={() => dialog?.clear()}
821
+ />
822
+ ))
823
+ }
824
+
825
+ function runSettingsDialog(api: TuiPluginApi, dialog: any): void {
826
+ let lastSelected: string | undefined
827
+ const renderSettingsDialog = (dialog2: any) => {
828
+ const cfg = loadConfig()
829
+ const options = [
830
+ {
831
+ title: `${t("settingsFocus")} (${cfg.focusModels.length} 个)`,
832
+ value: "focus-models",
833
+ },
834
+ {
835
+ title: `${t("settingsRefreshUsage")} (${Math.round(cfg.refreshIntervalMs / 1000)}s)`,
836
+ value: "refresh-usage",
837
+ },
838
+ {
839
+ title: `${t("settingsRefreshPricing")} (${Math.round(cfg.pricingIntervalMs / 1000)}s)`,
840
+ value: "refresh-pricing",
841
+ },
842
+ ]
843
+ dialog2?.replace(() => (
844
+ <api.ui.DialogSelect
845
+ title={t("settingsTitle")}
846
+ options={options}
847
+ onSelect={(opt) => {
848
+ const value = opt.value
849
+ if (value === "focus-models") {
850
+ const renderFocusDialog = (dialog3: any) => {
851
+ const cur = loadConfig().focusModels
852
+ const allModels = sharedPricing()?.models ?? []
853
+ const modelOptions = allModels.map((m) => ({
854
+ title: `${cur.includes(m.id) ? "\u2611" : "\u2610"} ${m.name} (${m.pricing.input ?? "-"}/${m.pricing.output ?? "-"})`,
855
+ value: m.id,
856
+ }))
857
+ const focusOptions = [
858
+ ...modelOptions,
859
+ { title: `${t("focusAll")} (${cur.length} 个已选)`, value: "select-all" },
860
+ { title: t("focusNone"), value: "clear-all" },
861
+ { title: `${t("focusDone")} (${t("focusSelected", { n: cur.length })})`, value: "done" },
862
+ ]
863
+ dialog3?.replace(() => (
864
+ <api.ui.DialogSelect
865
+ title={t("settingsFocus")}
866
+ options={focusOptions}
867
+ current={lastSelected}
868
+ onSelect={(opt2) => {
869
+ const v2 = opt2.value
870
+ if (v2 === "done") {
871
+ dialog3?.clear()
872
+ renderSettingsDialog(dialog2)
873
+ } else if (v2 === "select-all") {
874
+ const all = allModels.map((m) => m.id)
875
+ saveConfig({ focus_models: all })
876
+ setConfigTick((v) => v + 1)
877
+ api.ui.toast({ variant: "success", message: t("settingsSaved") })
878
+ renderFocusDialog(dialog3)
879
+ } else if (v2 === "clear-all") {
880
+ saveConfig({ focus_models: [] })
881
+ setConfigTick((v) => v + 1)
882
+ api.ui.toast({ variant: "success", message: t("settingsSaved") })
883
+ renderFocusDialog(dialog3)
884
+ } else {
885
+ const next = cur.includes(v2) ? cur.filter((id) => id !== v2) : [...cur, v2]
886
+ saveConfig({ focus_models: next })
887
+ setConfigTick((v) => v + 1)
888
+ lastSelected = v2
889
+ renderFocusDialog(dialog3)
890
+ }
891
+ }}
892
+ onCancel={() => dialog3?.clear()}
893
+ />
894
+ ))
895
+ }
896
+ renderFocusDialog(dialog2)
897
+ } else if (value === "refresh-usage") {
898
+ dialog2?.replace(() => (
899
+ <api.ui.DialogPrompt
900
+ title={t("settingsRefreshUsage")}
901
+ description={() => <text>{"输入秒数(默认 60)"}</text>}
902
+ placeholder="60"
903
+ onConfirm={(val) => {
904
+ const sec = Number(val.trim())
905
+ if (!sec || sec <= 0) { dialog2?.clear(); return }
906
+ saveConfig({ refresh_interval_sec: sec })
907
+ setConfigTick((v) => v + 1)
908
+ api.ui.toast({ variant: "success", message: t("settingsSaved") })
909
+ renderSettingsDialog(dialog2)
910
+ }}
911
+ onCancel={() => dialog2?.clear()}
912
+ />
913
+ ))
914
+ } else if (value === "refresh-pricing") {
915
+ dialog2?.replace(() => (
916
+ <api.ui.DialogPrompt
917
+ title={t("settingsRefreshPricing")}
918
+ description={() => <text>{"输入秒数(默认 1800)"}</text>}
919
+ placeholder="1800"
920
+ onConfirm={(val) => {
921
+ const sec = Number(val.trim())
922
+ if (!sec || sec <= 0) { dialog2?.clear(); return }
923
+ saveConfig({ pricing_interval_sec: sec })
924
+ setConfigTick((v) => v + 1)
925
+ api.ui.toast({ variant: "success", message: t("settingsSaved") })
926
+ renderSettingsDialog(dialog2)
927
+ }}
928
+ onCancel={() => dialog2?.clear()}
929
+ />
930
+ ))
931
+ }
932
+ }}
933
+ onCancel={() => dialog2?.clear()}
934
+ />
935
+ ))
936
+ }
937
+ renderSettingsDialog(dialog)
938
+ }
939
+
940
+ const tui: TuiPlugin = async (api: TuiPluginApi) => {
941
+ api.slots.register(createSidebarSlot(api))
942
+ api.slots.register(createPricingSlot(api))
943
+
944
+ // 恢复已保存的语言;若无则按系统检测
945
+ const savedLang = kvTryGet(api, `${KV_PREFIX}.lang`) as LangCode | undefined
946
+ if (savedLang === "zh" || savedLang === "en") setLangCode(savedLang)
947
+ else setLangCode(detectLang())
948
+
949
+ // 首次启动引导:已引导过则跳过(用 kv 标记)
950
+ const onboarded = kvTryGet(api, `${KV_PREFIX}.onboarded`)
951
+ if (!onboarded) {
952
+ kvTrySet(api, `${KV_PREFIX}.onboarded`, "1")
953
+ setTimeout(() => {
954
+ // 通过 trigger 触发语言选择命令,命令 onSelect 会拿到 dialog 栈并弹窗
955
+ try { api.command.trigger("go-usage.lang") } catch { /* 命令未就绪时忽略 */ }
956
+ }, 1500)
957
+ }
958
+
959
+ api.command?.register(() => [
960
+ {
961
+ title: t("cmdTitle"),
962
+ value: "go-usage.config",
963
+ description: t("cmdDesc"),
964
+ slash: { name: "go-config" },
965
+ onSelect: (dialog) => runConfigDialog(api, dialog),
966
+ },
967
+ {
968
+ title: t("langCmdTitle"),
969
+ value: "go-usage.lang",
970
+ description: t("langCmdDesc"),
971
+ slash: { name: "go-lang" },
972
+ onSelect: (dialog) => runLangDialog(api, dialog),
973
+ },
974
+ {
975
+ title: t("settingsTitle"),
976
+ value: "go-usage.settings",
977
+ description: t("settingsDesc"),
978
+ slash: { name: "go-settings" },
979
+ onSelect: (dialog) => runSettingsDialog(api, dialog),
980
+ },
981
+ ])
982
+ }
983
+
984
+ const mod: TuiPluginModule & { id: string } = {
985
+ id: "opencode-go-usage-tui",
986
+ tui,
987
+ }
988
+
866
989
  export default mod