opencode-go-usage-tui 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.tsx CHANGED
@@ -15,6 +15,8 @@ import { join, dirname } from "node:path"
15
15
  import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"
16
16
  import { createT, detectLang, LANG_META } from "./i18n"
17
17
  import type { LangCode } from "./i18n"
18
+ import { fetchPricing, comparePricing, loadPricingStore, updatePricingStore } from "./pricing"
19
+ import type { ModelPrice, PricingData, PricingStore } from "./pricing"
18
20
 
19
21
  declare const process: { env: Record<string, string | undefined> } | undefined
20
22
 
@@ -66,15 +68,22 @@ function readJsonFile<T>(file: string): T | null {
66
68
  try { return JSON.parse(readFileSync(file, "utf8")) as T } catch { return null }
67
69
  }
68
70
 
69
- interface ConfigFile { workspace_id?: string; cookie?: string; auth_cookie?: string; cookie_file?: string }
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 }
70
72
 
71
- function loadConfig(): { workspaceId: string; authCookie: string } {
73
+ interface LoadedConfig { workspaceId: string; authCookie: string; usagePanel: boolean; pricePanel: boolean; focusModels: string[]; refreshIntervalMs: number; pricingIntervalMs: number }
74
+
75
+ function loadConfig(): LoadedConfig {
72
76
  const env = process?.env ?? {}
73
77
  const fileCfg = readJsonFile<ConfigFile>(CONFIG_FILE) ?? {}
74
78
 
75
79
  // 1. 环境变量优先
76
80
  if (env.OPENCODE_GO_AUTH_COOKIE) {
77
- return { workspaceId: env.OPENCODE_GO_WORKSPACE_ID || fileCfg.workspace_id || "", authCookie: 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
+ }
78
87
  }
79
88
 
80
89
  // 2. 加密存储
@@ -82,7 +91,12 @@ function loadConfig(): { workspaceId: string; authCookie: string } {
82
91
  if (existsSync(COOKIE_ENC_FILE)) {
83
92
  const cookie = decryptText(readFileSync(COOKIE_ENC_FILE, "utf8"))
84
93
  if (cookie) {
85
- return { workspaceId: env.OPENCODE_GO_WORKSPACE_ID || fileCfg.workspace_id || "", authCookie: 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
+ }
86
100
  }
87
101
  }
88
102
  } catch { /* 密文损坏时回退到明文迁移 */ }
@@ -101,13 +115,23 @@ function loadConfig(): { workspaceId: string; authCookie: string } {
101
115
  delete cleanCfg.auth_cookie
102
116
  try { writeFileSync(CONFIG_FILE, JSON.stringify(cleanCfg, null, 2), "utf8") } catch { /* 忽略 */ }
103
117
  try { if (existsSync(COOKIE_PLAIN_FILE)) renameSync(COOKIE_PLAIN_FILE, COOKIE_PLAIN_FILE + ".bak") } catch { /* 忽略 */ }
104
- return { workspaceId: env.OPENCODE_GO_WORKSPACE_ID || fileCfg.workspace_id || "", authCookie: legacyCookie }
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
+ }
105
124
  }
106
125
 
107
- return { workspaceId: env.OPENCODE_GO_WORKSPACE_ID || fileCfg.workspace_id || "", authCookie: "" }
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
+ }
108
132
  }
109
133
 
110
- function saveConfig(patch: { workspace_id?: string; cookie?: string }): void {
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 {
111
135
  const fileCfg = readJsonFile<ConfigFile>(CONFIG_FILE) ?? {}
112
136
  if (patch.workspace_id !== undefined) fileCfg.workspace_id = patch.workspace_id
113
137
  if (patch.cookie !== undefined) {
@@ -123,6 +147,12 @@ function saveConfig(patch: { workspace_id?: string; cookie?: string }): void {
123
147
  try { if (existsSync(COOKIE_ENC_FILE)) renameSync(COOKIE_ENC_FILE, COOKIE_ENC_FILE + ".bak") } catch { /* 忽略 */ }
124
148
  }
125
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
126
156
  try {
127
157
  mkdirSync(dirname(CONFIG_FILE), { recursive: true })
128
158
  writeFileSync(CONFIG_FILE, JSON.stringify(fileCfg, null, 2), "utf8")
@@ -136,6 +166,9 @@ const [configTick, setConfigTick] = createSignal(0)
136
166
  const [langCode, setLangCode] = createSignal<LangCode>(detectLang())
137
167
  const t = createT(() => langCode())
138
168
 
169
+ // 模块级共享价格数据:PricePanel 抓取后写入,/go-settings 设置关注模型时读取
170
+ const [sharedPricing, setSharedPricing] = createSignal<PricingData | null>(null)
171
+
139
172
  function kvTryGet(api: TuiPluginApi, key: string): string | undefined {
140
173
  try { return api.kv.get<string>(key) } catch { return undefined }
141
174
  }
@@ -150,7 +183,9 @@ interface Usage {
150
183
  error?: string
151
184
  }
152
185
 
153
- 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> {
154
189
  if (!workspaceId || !authCookie) return { error: "not_configured" } as Usage
155
190
  try {
156
191
  const res = await fetch("https://opencode.ai/_server", {
@@ -167,6 +202,7 @@ async function fetchUsage(workspaceId: string, authCookie: string): Promise<Usag
167
202
  f: 31,
168
203
  m: [],
169
204
  }),
205
+ signal,
170
206
  })
171
207
  if (!res.ok) {
172
208
  if (res.status === 302) return { error: "cookie_expired" } as Usage
@@ -177,7 +213,7 @@ async function fetchUsage(workspaceId: string, authCookie: string): Promise<Usag
177
213
  return { error: "cookie_expired" } as Usage
178
214
  }
179
215
  const extract = (key: string): { resetInSec: number; usagePercent: number } | null => {
180
- const m = text.match(new RegExp(`${key}:[^}]*?\\{status:"ok",resetInSec:(\\d+),usagePercent:(\\d+)\\}`))
216
+ const m = text.match(new RegExp(`${key}:[^}]*?\\{status:"[^"]*",resetInSec:(\\d+),usagePercent:(\\d+)\\}`))
181
217
  if (!m) return null
182
218
  return { resetInSec: Number(m[1]), usagePercent: Number(m[2]) }
183
219
  }
@@ -248,9 +284,21 @@ function GoUsagePanel(props: { theme: TuiThemeCurrent; api: TuiPluginApi }): JSX
248
284
  const [configured, setConfigured] = createSignal(true)
249
285
  const [lastUpdated, setLastUpdated] = createSignal("")
250
286
  const [panelWidth, setPanelWidth] = createSignal(24)
287
+ const [cfg, setCfg] = createSignal<LoadedConfig>(loadConfig())
288
+ createEffect(() => { void configTick(); setCfg(loadConfig()) })
251
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
+ }
252
299
 
253
300
  async function refresh() {
301
+ if (isRefreshing) return
254
302
  const cfg = loadConfig()
255
303
  if (!cfg.workspaceId || !cfg.authCookie) {
256
304
  setConfigured(false)
@@ -259,24 +307,39 @@ function GoUsagePanel(props: { theme: TuiThemeCurrent; api: TuiPluginApi }): JSX
259
307
  return
260
308
  }
261
309
  setConfigured(true)
262
- const u = await fetchUsage(cfg.workspaceId, cfg.authCookie)
263
- if (u === null) { setError(t("queryFailed")); return }
264
- if (u.error === "cookie_expired") { setError(t("cookieExpired")); return }
265
- if (u.error === "not_configured") { setConfigured(false); return }
266
- setUsage(u)
267
- setLastUpdated(new Date().toLocaleTimeString())
268
- 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
+ }
269
328
  }
270
329
 
271
330
  onMount(() => {
272
331
  refresh()
273
- const timer = setInterval(refresh, CHECK_INTERVAL)
274
- // 监听配置变更(/go-config 保存后)立即刷新
332
+ const timer = setInterval(refresh, loadConfig().refreshIntervalMs)
275
333
  const stopWatch = createEffect(() => {
276
334
  void configTick()
277
335
  refresh()
278
336
  })
279
- onCleanup(() => { clearInterval(timer); stopWatch() })
337
+ onCleanup(() => {
338
+ clearInterval(timer)
339
+ stopWatch()
340
+ cleanupPendingRequest()
341
+ if (resizeTimeout) { clearTimeout(resizeTimeout); resizeTimeout = null }
342
+ })
280
343
  })
281
344
 
282
345
  const [pal, setPal] = createSignal<Record<string, string>>({ ...FALLBACK })
@@ -319,6 +382,188 @@ function GoUsagePanel(props: { theme: TuiThemeCurrent; api: TuiPluginApi }): JSX
319
382
 
320
383
  const sep = () => "\u2500".repeat(Math.max(1, panelWidth() - 4))
321
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
+ // 强制同步 panelWidth 与 box 真实宽度(visual-cache 模式,避免首次不触发 onSizeChange)
454
+ createEffect(() => {
455
+ if (boxEl && typeof boxEl.width === "number" && boxEl.width > 0) {
456
+ setPanelWidth(Math.max(20, boxEl.width))
457
+ }
458
+ })
459
+
460
+ async function refreshPricing() {
461
+ if (priceLoading()) return
462
+ setPriceLoading(true)
463
+ setPriceError("")
464
+ priceController?.abort()
465
+ priceController = new AbortController()
466
+ try {
467
+ const data = await fetchPricing()
468
+ const store = updatePricingStore(data)
469
+ setSharedPricing(data)
470
+ setPricingStore(store)
471
+ const prev = store.previous
472
+ if (prev && prev.fetchTime !== data.fetchTime) {
473
+ const diff = comparePricing(prev, data)
474
+ if (diff.hasChanges) {
475
+ props.api.toast({
476
+ variant: "info",
477
+ title: t("priceChanged"),
478
+ message: t("priceChangesSummary", {
479
+ t: diff.summary.total,
480
+ a: diff.summary.added,
481
+ r: diff.summary.removed,
482
+ p: diff.summary.priceChanges,
483
+ l: diff.summary.limitChanges,
484
+ }),
485
+ })
486
+ }
487
+ }
488
+ } catch {
489
+ setPriceError(t("priceError"))
490
+ } finally {
491
+ setPriceLoading(false)
492
+ priceController = null
493
+ }
494
+ }
495
+
496
+ function formatCount(n: number): string {
497
+ if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, "") + "k"
498
+ return String(n)
499
+ }
500
+
501
+ function visualPadEnd(s: string, w: number): string {
502
+ const cur = visualWidth(s)
503
+ if (cur >= w) return s
504
+ return s + " ".repeat(w - cur)
505
+ }
506
+
507
+ function abbreviateName(name: string, maxLen: number): string {
508
+ if (visualWidth(name) <= maxLen) return name
509
+ let out = ""
510
+ let w = 0
511
+ for (const c of name) {
512
+ const cw = visualWidth(c)
513
+ if (w + cw > maxLen - 1) break
514
+ out += c
515
+ w += cw
516
+ }
517
+ return out + "…"
518
+ }
519
+
520
+ // visual-cache 风格:左标签 + 自动填充 + 右值
521
+ // 直接读取 boxEl 实时宽度(避免 panelWidth 信号滞后)
522
+ function justify(label: string, value: string): string {
523
+ const outer = boxEl && typeof boxEl.width === "number" && boxEl.width > 0 ? boxEl.width : panelWidth()
524
+ const gauge = Math.max(10, outer - 4)
525
+ const used = visualWidth(label) + visualWidth(value)
526
+ const gap = Math.max(1, gauge - used)
527
+ return label + " ".repeat(gap) + value
528
+ }
529
+
530
+ function renderLimitRow(m: ModelPrice) {
531
+ const name = abbreviateName(m.name, 18)
532
+ const req = `${formatCount(m.limits.fiveHour)}/${formatCount(m.limits.weekly)}/${formatCount(m.limits.monthly)}`
533
+ return (
534
+ <text>
535
+ <span style={{ fg: colors().text }}>{justify(name, req)}</span>
536
+ </text>
537
+ )
538
+ }
539
+
540
+ const [pal, setPal] = createSignal<Record<string, string>>({ ...FALLBACK })
541
+ createEffect(() => {
542
+ const th = props.theme as any
543
+ const p: Record<string, string> = { ...FALLBACK }
544
+ for (const k of Object.keys(FALLBACK)) {
545
+ const h = hex(th?.[k])
546
+ if (h) p[k] = h
547
+ }
548
+ setPal(p)
549
+ })
550
+ const colors = () => pal()
551
+
552
+ const sep = () => {
553
+ const outer = boxEl && typeof boxEl.width === "number" && boxEl.width > 0 ? boxEl.width : panelWidth()
554
+ return "\u2500".repeat(Math.max(1, outer - 4))
555
+ }
556
+
557
+ setTimeout(() => {
558
+ const store = loadPricingStore()
559
+ if (store.current.models.length > 0) {
560
+ setSharedPricing(store.current)
561
+ setPricingStore(store)
562
+ }
563
+ refreshPricing()
564
+ setInterval(() => refreshPricing(), loadConfig().pricingIntervalMs)
565
+ }, 2000)
566
+
322
567
  return (
323
568
  <box
324
569
  border
@@ -329,50 +574,59 @@ function GoUsagePanel(props: { theme: TuiThemeCurrent; api: TuiPluginApi }): JSX
329
574
  gap={0}
330
575
  ref={boxEl}
331
576
  onSizeChange={() => {
332
- const w = boxEl ? Math.max(20, boxEl.width ?? 24) : 24
333
- setPanelWidth(w)
334
- }}
335
- >
577
+ if (resizeTimeout) clearTimeout(resizeTimeout)
578
+ resizeTimeout = setTimeout(() => {
579
+ const w = boxEl ? Math.max(20, boxEl.width ?? 24) : 24
580
+ setPanelWidth(w)
581
+ }, 100)
582
+ }}
583
+ >
336
584
  <text onMouseUp={() => setOpen(o => !o)}>
337
585
  <span style={{ fg: colors().muted }}>{open() ? "\u25bc " : "\u25b6 "}</span>
338
- <span style={{ fg: colors().primary }}><b>{t("panelTitle")}</b></span>
339
- <span style={{ fg: colors().muted }}> v{VERSION}</span>
340
- <Show when={!open() && usage()}>
341
- <span style={{ fg: colorFor(usage()!.weeklyUsage.usagePercent) }}>
342
- {" ".repeat(2)}{t("weeklyShort")} {usage()!.weeklyUsage.usagePercent}%
343
- </span>
586
+ <span style={{ fg: colors().primary }}><b>{t("priceTitle")}</b></span>
587
+ <Show when={sharedPricing()}>
588
+ <span style={{ fg: colors().muted }}> ({sharedPricing()!.models.length})</span>
344
589
  </Show>
590
+ <span style={{ fg: colors().muted }}>{sep().slice(visualWidth((open() ? "\u25bc " : "\u25b6 ") + t("priceTitle") + (sharedPricing() ? ` (${sharedPricing()!.models.length})` : "")))}</span>
345
591
  </text>
346
592
 
347
593
  <Show when={open()}>
594
+ <text fg={colors().muted}>
595
+ {justify(t("priceModelCol"), t("priceReqs"))}
596
+ </text>
348
597
  <text fg={colors().muted}>{sep()}</text>
349
598
 
350
- <Show when={!configured()}>
351
- <text fg={colors().warning}>{t("notConfigured")}</text>
352
- <text fg={colors().muted}>{t("notConfiguredHint")}</text>
353
- </Show>
354
-
355
- <Show when={configured() && error()} fallback={
356
- <Show when={configured() && usage()} fallback={
357
- <Show when={configured()}><text fg={colors().muted}>{t("loading")}</text></Show>
599
+ <Show when={priceLoading() || !sharedPricing()} fallback={
600
+ <Show when={sharedPricing()} fallback={
601
+ <text fg={colors().muted}>{t("priceLoading")}</text>
358
602
  }>
359
- {renderRow(t("rowRolling"), usage()!.rollingUsage)}
360
- {renderRow(t("rowWeekly"), usage()!.weeklyUsage)}
361
- {renderRow(t("rowMonthly"), usage()!.monthlyUsage)}
362
- <text>
363
- <span style={{ fg: colors().muted }}>{t("updatedAt")} </span>
364
- <span style={{ fg: colors().muted }}>{lastUpdated()}</span>
603
+ {sharedPricing()!.models.filter((m) => {
604
+ return cfg().focusModels.length === 0 || cfg().focusModels.includes(m.id)
605
+ }).map((m) => renderLimitRow(m))}
606
+ <text fg={colors().muted}>
607
+ {justify(t("priceUpdated"), new Date(sharedPricing()!.fetchTime).toLocaleTimeString())}
365
608
  </text>
609
+ <Show when={pricingStore() && pricingStore()!.history.length > 0}>
610
+ <text fg={colors().warning}>
611
+ {t("priceChangesSummary", {
612
+ t: pricingStore()!.history[0].summary.total,
613
+ a: pricingStore()!.history[0].summary.added,
614
+ r: pricingStore()!.history[0].summary.removed,
615
+ p: pricingStore()!.history[0].summary.priceChanges,
616
+ l: pricingStore()!.history[0].summary.limitChanges,
617
+ })}
618
+ </text>
619
+ </Show>
366
620
  </Show>
367
621
  }>
368
- <text fg={colors().error}>{error()}</text>
369
- <text fg={colors().muted}>{t("reconfigureHint")}</text>
622
+ <text fg={colors().muted}>{t("priceLoading")}</text>
370
623
  </Show>
371
624
  </Show>
372
625
  </box>
373
626
  )
374
627
  }
375
628
 
629
+
376
630
  function createSidebarSlot(api: TuiPluginApi): TuiSlotPlugin {
377
631
  return {
378
632
  order: 60,
@@ -384,6 +638,17 @@ function createSidebarSlot(api: TuiPluginApi): TuiSlotPlugin {
384
638
  }
385
639
  }
386
640
 
641
+ function createPricingSlot(api: TuiPluginApi): TuiSlotPlugin {
642
+ return {
643
+ order: 61,
644
+ slots: {
645
+ sidebar_content(ctx: TuiSlotContext, _input: { session_id: string }): JSX.Element {
646
+ return <PricePanel theme={ctx.theme.current} api={api} />
647
+ },
648
+ },
649
+ }
650
+ }
651
+
387
652
  function runConfigDialog(api: TuiPluginApi, dialog: any): void {
388
653
  dialog?.replace(() => (
389
654
  <api.ui.DialogPrompt
@@ -446,8 +711,124 @@ function runLangDialog(api: TuiPluginApi, dialog: any): void {
446
711
  ))
447
712
  }
448
713
 
714
+ function runSettingsDialog(api: TuiPluginApi, dialog: any): void {
715
+ let lastSelected: string | undefined
716
+ const renderSettingsDialog = (dialog2: any) => {
717
+ const cfg = loadConfig()
718
+ const options = [
719
+ {
720
+ title: `${t("settingsFocus")} (${cfg.focusModels.length} 个)`,
721
+ value: "focus-models",
722
+ },
723
+ {
724
+ title: `${t("settingsRefreshUsage")} (${Math.round(cfg.refreshIntervalMs / 1000)}s)`,
725
+ value: "refresh-usage",
726
+ },
727
+ {
728
+ title: `${t("settingsRefreshPricing")} (${Math.round(cfg.pricingIntervalMs / 1000)}s)`,
729
+ value: "refresh-pricing",
730
+ },
731
+ ]
732
+ dialog2?.replace(() => (
733
+ <api.ui.DialogSelect
734
+ title={t("settingsTitle")}
735
+ options={options}
736
+ onSelect={(opt) => {
737
+ const value = opt.value
738
+ if (value === "focus-models") {
739
+ const renderFocusDialog = (dialog3: any) => {
740
+ const cur = loadConfig().focusModels
741
+ const allModels = sharedPricing()?.models ?? []
742
+ const modelOptions = allModels.map((m) => ({
743
+ title: `${cur.includes(m.id) ? "\u2611" : "\u2610"} ${m.name} (${m.pricing.input ?? "-"}/${m.pricing.output ?? "-"})`,
744
+ value: m.id,
745
+ }))
746
+ const focusOptions = [
747
+ ...modelOptions,
748
+ { title: `${t("focusAll")} (${cur.length} 个已选)`, value: "select-all" },
749
+ { title: t("focusNone"), value: "clear-all" },
750
+ { title: `${t("focusDone")} (${t("focusSelected", { n: cur.length })})`, value: "done" },
751
+ ]
752
+ dialog3?.replace(() => (
753
+ <api.ui.DialogSelect
754
+ title={t("settingsFocus")}
755
+ options={focusOptions}
756
+ current={lastSelected}
757
+ onSelect={(opt2) => {
758
+ const v2 = opt2.value
759
+ if (v2 === "done") {
760
+ dialog3?.clear()
761
+ renderSettingsDialog(dialog2)
762
+ } else if (v2 === "select-all") {
763
+ const all = allModels.map((m) => m.id)
764
+ saveConfig({ focus_models: all })
765
+ setConfigTick((v) => v + 1)
766
+ api.ui.toast({ variant: "success", message: t("settingsSaved") })
767
+ renderFocusDialog(dialog3)
768
+ } else if (v2 === "clear-all") {
769
+ saveConfig({ focus_models: [] })
770
+ setConfigTick((v) => v + 1)
771
+ api.ui.toast({ variant: "success", message: t("settingsSaved") })
772
+ renderFocusDialog(dialog3)
773
+ } else {
774
+ const next = cur.includes(v2) ? cur.filter((id) => id !== v2) : [...cur, v2]
775
+ saveConfig({ focus_models: next })
776
+ setConfigTick((v) => v + 1)
777
+ lastSelected = v2
778
+ renderFocusDialog(dialog3)
779
+ }
780
+ }}
781
+ onCancel={() => dialog3?.clear()}
782
+ />
783
+ ))
784
+ }
785
+ renderFocusDialog(dialog2)
786
+ } else if (value === "refresh-usage") {
787
+ dialog2?.replace(() => (
788
+ <api.ui.DialogPrompt
789
+ title={t("settingsRefreshUsage")}
790
+ description={() => <text>{"输入秒数(默认 60)"}</text>}
791
+ placeholder="60"
792
+ onConfirm={(val) => {
793
+ const sec = Number(val.trim())
794
+ if (!sec || sec <= 0) { dialog2?.clear(); return }
795
+ saveConfig({ refresh_interval_sec: sec })
796
+ setConfigTick((v) => v + 1)
797
+ api.ui.toast({ variant: "success", message: t("settingsSaved") })
798
+ renderSettingsDialog(dialog2)
799
+ }}
800
+ onCancel={() => dialog2?.clear()}
801
+ />
802
+ ))
803
+ } else if (value === "refresh-pricing") {
804
+ dialog2?.replace(() => (
805
+ <api.ui.DialogPrompt
806
+ title={t("settingsRefreshPricing")}
807
+ description={() => <text>{"输入秒数(默认 1800)"}</text>}
808
+ placeholder="1800"
809
+ onConfirm={(val) => {
810
+ const sec = Number(val.trim())
811
+ if (!sec || sec <= 0) { dialog2?.clear(); return }
812
+ saveConfig({ pricing_interval_sec: sec })
813
+ setConfigTick((v) => v + 1)
814
+ api.ui.toast({ variant: "success", message: t("settingsSaved") })
815
+ renderSettingsDialog(dialog2)
816
+ }}
817
+ onCancel={() => dialog2?.clear()}
818
+ />
819
+ ))
820
+ }
821
+ }}
822
+ onCancel={() => dialog2?.clear()}
823
+ />
824
+ ))
825
+ }
826
+ renderSettingsDialog(dialog)
827
+ }
828
+
449
829
  const tui: TuiPlugin = async (api: TuiPluginApi) => {
450
830
  api.slots.register(createSidebarSlot(api))
831
+ api.slots.register(createPricingSlot(api))
451
832
 
452
833
  // 恢复已保存的语言;若无则按系统检测
453
834
  const savedLang = kvTryGet(api, `${KV_PREFIX}.lang`) as LangCode | undefined
@@ -479,6 +860,13 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
479
860
  slash: { name: "go-lang" },
480
861
  onSelect: (dialog) => runLangDialog(api, dialog),
481
862
  },
863
+ {
864
+ title: t("settingsTitle"),
865
+ value: "go-usage.settings",
866
+ description: t("settingsDesc"),
867
+ slash: { name: "go-settings" },
868
+ onSelect: (dialog) => runSettingsDialog(api, dialog),
869
+ },
482
870
  ])
483
871
  }
484
872