opencode-go-usage-tui 1.1.0 → 1.3.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/src/index.tsx CHANGED
@@ -10,10 +10,13 @@ import type {
10
10
  } from "@opencode-ai/plugin/tui"
11
11
  import { createSignal, createEffect, onMount, onCleanup, Show } from "solid-js"
12
12
  import type { JSX } from "@opentui/solid"
13
- import { readFileSync, writeFileSync, mkdirSync } from "node:fs"
13
+ import { readFileSync, writeFileSync, mkdirSync, renameSync, existsSync } from "node:fs"
14
14
  import { join, dirname } from "node:path"
15
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"
15
16
  import { createT, detectLang, LANG_META } from "./i18n"
16
17
  import type { LangCode } from "./i18n"
18
+ import { fetchPricing, comparePricing, loadPricingStore, updatePricingStore } from "./pricing"
19
+ import type { ModelPrice, PricingData, PricingStore } from "./pricing"
17
20
 
18
21
  declare const process: { env: Record<string, string | undefined> } | undefined
19
22
 
@@ -24,35 +27,132 @@ const CONFIG_DIR = process?.env?.OPENCODE_CONFIG_DIR
24
27
  || (process?.env?.USERPROFILE ? `${process.env.USERPROFILE}\\.config\\opencode` : "")
25
28
  || process?.env?.HOME + "/.config/opencode"
26
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`
27
33
  const KV_PREFIX = "go-usage"
28
34
 
29
- function loadConfig(): { workspaceId: string; authCookie: string } {
30
- const env = process?.env ?? {}
31
- let fileCfg: Record<string, string> = {}
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 {
32
41
  try {
33
- fileCfg = JSON.parse(readFileSync(CONFIG_FILE, "utf8"))
34
- } catch { fileCfg = {} }
35
- const cookieFile = env.OPENCODE_GO_COOKIE_FILE || fileCfg.cookie_file || `${CONFIG_DIR}\\go-auth-cookie.txt`
36
- let fileCookie = ""
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. 加密存储
37
90
  try {
38
- let p = cookieFile
39
- if (p === "~") p = (env.HOME || env.USERPROFILE) + ""
40
- else if (p.startsWith("~/")) p = (env.HOME || env.USERPROFILE || "") + p.slice(1)
41
- fileCookie = readFileSync(p, "utf8").trim()
42
- } catch { fileCookie = "" }
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
+
43
126
  return {
44
- workspaceId: env.OPENCODE_GO_WORKSPACE_ID || fileCfg.workspace_id || "",
45
- authCookie: env.OPENCODE_GO_AUTH_COOKIE || fileCfg.auth_cookie || fileCfg.cookie || fileCookie,
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,
46
131
  }
47
132
  }
48
133
 
49
- function saveConfig(patch: { workspace_id?: string; cookie?: string }): void {
50
- let fileCfg: Record<string, string> = {}
51
- try {
52
- fileCfg = JSON.parse(readFileSync(CONFIG_FILE, "utf8"))
53
- } catch { fileCfg = {} }
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) ?? {}
54
136
  if (patch.workspace_id !== undefined) fileCfg.workspace_id = patch.workspace_id
55
- if (patch.cookie !== undefined) fileCfg.cookie = patch.cookie
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
56
156
  try {
57
157
  mkdirSync(dirname(CONFIG_FILE), { recursive: true })
58
158
  writeFileSync(CONFIG_FILE, JSON.stringify(fileCfg, null, 2), "utf8")
@@ -66,6 +166,9 @@ const [configTick, setConfigTick] = createSignal(0)
66
166
  const [langCode, setLangCode] = createSignal<LangCode>(detectLang())
67
167
  const t = createT(() => langCode())
68
168
 
169
+ // 模块级共享价格数据:PricePanel 抓取后写入,/go-settings 设置关注模型时读取
170
+ const [sharedPricing, setSharedPricing] = createSignal<PricingData | null>(null)
171
+
69
172
  function kvTryGet(api: TuiPluginApi, key: string): string | undefined {
70
173
  try { return api.kv.get<string>(key) } catch { return undefined }
71
174
  }
@@ -80,7 +183,9 @@ interface Usage {
80
183
  error?: string
81
184
  }
82
185
 
83
- async function fetchUsage(workspaceId: string, authCookie: string): Promise<Usage | null> {
186
+ const REQUEST_TIMEOUT = 10000
187
+
188
+ async function fetchUsage(workspaceId: string, authCookie: string, signal?: AbortSignal): Promise<Usage | null> {
84
189
  if (!workspaceId || !authCookie) return { error: "not_configured" } as Usage
85
190
  try {
86
191
  const res = await fetch("https://opencode.ai/_server", {
@@ -97,6 +202,7 @@ async function fetchUsage(workspaceId: string, authCookie: string): Promise<Usag
97
202
  f: 31,
98
203
  m: [],
99
204
  }),
205
+ signal,
100
206
  })
101
207
  if (!res.ok) {
102
208
  if (res.status === 302) return { error: "cookie_expired" } as Usage
@@ -178,9 +284,21 @@ function GoUsagePanel(props: { theme: TuiThemeCurrent; api: TuiPluginApi }): JSX
178
284
  const [configured, setConfigured] = createSignal(true)
179
285
  const [lastUpdated, setLastUpdated] = createSignal("")
180
286
  const [panelWidth, setPanelWidth] = createSignal(24)
287
+ const [cfg, setCfg] = createSignal<LoadedConfig>(loadConfig())
288
+ createEffect(() => { void configTick(); setCfg(loadConfig()) })
181
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
+ }
182
299
 
183
300
  async function refresh() {
301
+ if (isRefreshing) return
184
302
  const cfg = loadConfig()
185
303
  if (!cfg.workspaceId || !cfg.authCookie) {
186
304
  setConfigured(false)
@@ -189,24 +307,39 @@ function GoUsagePanel(props: { theme: TuiThemeCurrent; api: TuiPluginApi }): JSX
189
307
  return
190
308
  }
191
309
  setConfigured(true)
192
- const u = await fetchUsage(cfg.workspaceId, cfg.authCookie)
193
- if (u === null) { setError(t("queryFailed")); return }
194
- if (u.error === "cookie_expired") { setError(t("cookieExpired")); return }
195
- if (u.error === "not_configured") { setConfigured(false); return }
196
- setUsage(u)
197
- setLastUpdated(new Date().toLocaleTimeString())
198
- setError("")
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
+ }
199
328
  }
200
329
 
201
330
  onMount(() => {
202
331
  refresh()
203
- const timer = setInterval(refresh, CHECK_INTERVAL)
204
- // 监听配置变更(/go-config 保存后)立即刷新
332
+ const timer = setInterval(refresh, loadConfig().refreshIntervalMs)
205
333
  const stopWatch = createEffect(() => {
206
334
  void configTick()
207
335
  refresh()
208
336
  })
209
- onCleanup(() => { clearInterval(timer); stopWatch() })
337
+ onCleanup(() => {
338
+ clearInterval(timer)
339
+ stopWatch()
340
+ cleanupPendingRequest()
341
+ if (resizeTimeout) { clearTimeout(resizeTimeout); resizeTimeout = null }
342
+ })
210
343
  })
211
344
 
212
345
  const [pal, setPal] = createSignal<Record<string, string>>({ ...FALLBACK })
@@ -249,6 +382,176 @@ function GoUsagePanel(props: { theme: TuiThemeCurrent; api: TuiPluginApi }): JSX
249
382
 
250
383
  const sep = () => "\u2500".repeat(Math.max(1, panelWidth() - 4))
251
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
+
252
555
  return (
253
556
  <box
254
557
  border
@@ -259,50 +562,59 @@ function GoUsagePanel(props: { theme: TuiThemeCurrent; api: TuiPluginApi }): JSX
259
562
  gap={0}
260
563
  ref={boxEl}
261
564
  onSizeChange={() => {
262
- const w = boxEl ? Math.max(20, boxEl.width ?? 24) : 24
263
- setPanelWidth(w)
264
- }}
265
- >
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
+ >
266
572
  <text onMouseUp={() => setOpen(o => !o)}>
267
573
  <span style={{ fg: colors().muted }}>{open() ? "\u25bc " : "\u25b6 "}</span>
268
- <span style={{ fg: colors().primary }}><b>{t("panelTitle")}</b></span>
269
- <span style={{ fg: colors().muted }}> v{VERSION}</span>
270
- <Show when={!open() && usage()}>
271
- <span style={{ fg: colorFor(usage()!.weeklyUsage.usagePercent) }}>
272
- {" ".repeat(2)}{t("weeklyShort")} {usage()!.weeklyUsage.usagePercent}%
273
- </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>
274
577
  </Show>
578
+ <span style={{ fg: colors().muted }}>{sep().slice(visualWidth((open() ? "\u25bc " : "\u25b6 ") + t("priceTitle") + (sharedPricing() ? ` (${sharedPricing()!.models.length})` : "")))}</span>
275
579
  </text>
276
580
 
277
581
  <Show when={open()}>
582
+ <text fg={colors().muted}>
583
+ {justify(t("priceModelCol"), t("priceReqs"))}
584
+ </text>
278
585
  <text fg={colors().muted}>{sep()}</text>
279
586
 
280
- <Show when={!configured()}>
281
- <text fg={colors().warning}>{t("notConfigured")}</text>
282
- <text fg={colors().muted}>{t("notConfiguredHint")}</text>
283
- </Show>
284
-
285
- <Show when={configured() && error()} fallback={
286
- <Show when={configured() && usage()} fallback={
287
- <Show when={configured()}><text fg={colors().muted}>{t("loading")}</text></Show>
587
+ <Show when={priceLoading() || !sharedPricing()} fallback={
588
+ <Show when={sharedPricing()} fallback={
589
+ <text fg={colors().muted}>{t("priceLoading")}</text>
288
590
  }>
289
- {renderRow(t("rowRolling"), usage()!.rollingUsage)}
290
- {renderRow(t("rowWeekly"), usage()!.weeklyUsage)}
291
- {renderRow(t("rowMonthly"), usage()!.monthlyUsage)}
292
- <text>
293
- <span style={{ fg: colors().muted }}>{t("updatedAt")} </span>
294
- <span style={{ fg: colors().muted }}>{lastUpdated()}</span>
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())}
295
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>
296
608
  </Show>
297
609
  }>
298
- <text fg={colors().error}>{error()}</text>
299
- <text fg={colors().muted}>{t("reconfigureHint")}</text>
610
+ <text fg={colors().muted}>{t("priceLoading")}</text>
300
611
  </Show>
301
612
  </Show>
302
613
  </box>
303
614
  )
304
615
  }
305
616
 
617
+
306
618
  function createSidebarSlot(api: TuiPluginApi): TuiSlotPlugin {
307
619
  return {
308
620
  order: 60,
@@ -314,6 +626,17 @@ function createSidebarSlot(api: TuiPluginApi): TuiSlotPlugin {
314
626
  }
315
627
  }
316
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
+
317
640
  function runConfigDialog(api: TuiPluginApi, dialog: any): void {
318
641
  dialog?.replace(() => (
319
642
  <api.ui.DialogPrompt
@@ -376,8 +699,124 @@ function runLangDialog(api: TuiPluginApi, dialog: any): void {
376
699
  ))
377
700
  }
378
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
+
379
817
  const tui: TuiPlugin = async (api: TuiPluginApi) => {
380
818
  api.slots.register(createSidebarSlot(api))
819
+ api.slots.register(createPricingSlot(api))
381
820
 
382
821
  // 恢复已保存的语言;若无则按系统检测
383
822
  const savedLang = kvTryGet(api, `${KV_PREFIX}.lang`) as LangCode | undefined
@@ -409,6 +848,13 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
409
848
  slash: { name: "go-lang" },
410
849
  onSelect: (dialog) => runLangDialog(api, dialog),
411
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
+ },
412
858
  ])
413
859
  }
414
860