opencode-go-usage-tui 1.0.1 → 1.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-go-usage-tui",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "description": "OpenCode TUI plugin displaying OpenCode Go usage in the sidebar",
5
5
  "type": "module",
6
6
  "exports": {
@@ -15,7 +15,8 @@
15
15
  "dist",
16
16
  "src",
17
17
  "build.tui.mjs",
18
- "README.md"
18
+ "README.md",
19
+ "README_EN.md"
19
20
  ],
20
21
  "scripts": {
21
22
  "build": "bun run build.tui.mjs",
package/src/i18n.ts ADDED
@@ -0,0 +1,108 @@
1
+ export type LangCode = "zh" | "en"
2
+
3
+ const ZH_T = {
4
+ panelTitle: "Go 用量",
5
+ rowRolling: "5小时用量",
6
+ rowWeekly: "每周用量",
7
+ rowMonthly: "每月用量",
8
+ weeklyShort: "周",
9
+ updatedAt: "最近更新",
10
+ notConfigured: "未配置账号",
11
+ notConfiguredHint: "输入 /go-config 设置",
12
+ loading: "加载中...",
13
+ queryFailed: "查询失败",
14
+ cookieExpired: "cookie 已过期",
15
+ reconfigureHint: "输入 /go-config 重新配置",
16
+ resetDone: "已重置",
17
+ dayHour: "{d}天{h}小时",
18
+ hourMin: "{h}小时{m}分钟",
19
+ minute: "{m}分钟",
20
+ cmdTitle: "Go Usage: Configure",
21
+ cmdDesc: "设置 OpenCode Go workspace ID 和 auth cookie",
22
+ wsPromptTitle: "输入 workspace_id",
23
+ wsPromptDesc: "在 opencode.ai 控制台 URL 中获取(wrk_ 开头)",
24
+ wsPlaceholder: "wrk_xxxxxxxxxxxx",
25
+ cookiePromptTitle: "输入 auth cookie",
26
+ cookiePromptDesc: "登录 opencode.ai 后,浏览器 F12 → Application → Cookies → opencode.ai → 复制 auth 的值",
27
+ cookiePlaceholder: "Fe26.2*...",
28
+ configSaved: "Go 用量配置已保存",
29
+ langTitle: "显示语言",
30
+ langCmdTitle: "Go Usage: Language",
31
+ langCmdDesc: "切换显示语言 | Switch display language",
32
+ langSwitchedZh: "已切换为中文",
33
+ langSwitchedEn: "Switched to English",
34
+ onboardLangTitle: "Welcome to Go Usage / 欢迎使用 Go 用量",
35
+ onboardLangDesc: "Choose display language / 选择显示语言",
36
+ onboardConfigTitle: "Welcome to Go Usage / 欢迎使用 Go 用量",
37
+ onboardConfigDesc: "First-time setup / 首次使用需配置账号",
38
+ setupCanceled: "已取消配置,输入 /go-config 可重新设置",
39
+ } as const
40
+
41
+ export type Translation = { [K in keyof typeof ZH_T]: string }
42
+
43
+ const EN_T: Translation = {
44
+ panelTitle: "Go Usage",
45
+ rowRolling: "5h usage",
46
+ rowWeekly: "Weekly",
47
+ rowMonthly: "Monthly",
48
+ weeklyShort: "Wk",
49
+ updatedAt: "Updated",
50
+ notConfigured: "Not configured",
51
+ notConfiguredHint: "Run /go-config to set up",
52
+ loading: "Loading...",
53
+ queryFailed: "Query failed",
54
+ cookieExpired: "Cookie expired",
55
+ reconfigureHint: "Run /go-config to reconfigure",
56
+ resetDone: "Reset",
57
+ dayHour: "{d}d {h}h",
58
+ hourMin: "{h}h {m}m",
59
+ minute: "{m}m",
60
+ cmdTitle: "Go Usage: Configure",
61
+ cmdDesc: "Set OpenCode Go workspace ID and auth cookie",
62
+ wsPromptTitle: "Enter workspace_id",
63
+ wsPromptDesc: "Get it from the opencode.ai console URL (starts with wrk_)",
64
+ wsPlaceholder: "wrk_xxxxxxxxxxxx",
65
+ cookiePromptTitle: "Enter auth cookie",
66
+ cookiePromptDesc: "After logging into opencode.ai, press F12 → Application → Cookies → opencode.ai → copy the auth value",
67
+ cookiePlaceholder: "Fe26.2*...",
68
+ configSaved: "Go usage config saved",
69
+ langTitle: "Display language",
70
+ langCmdTitle: "Go Usage: Language",
71
+ langCmdDesc: "切换显示语言 | Switch display language",
72
+ langSwitchedZh: "已切换为中文",
73
+ langSwitchedEn: "Switched to English",
74
+ onboardLangTitle: "Welcome to Go Usage / 欢迎使用 Go 用量",
75
+ onboardLangDesc: "Choose display language / 选择显示语言",
76
+ onboardConfigTitle: "Welcome to Go Usage / 欢迎使用 Go 用量",
77
+ onboardConfigDesc: "First-time setup / 首次使用需配置账号",
78
+ setupCanceled: "Setup canceled, run /go-config to reconfigure",
79
+ }
80
+
81
+ export const LANGS: Record<LangCode, Translation> = { zh: ZH_T, en: EN_T }
82
+
83
+ export const LANG_META: { code: LangCode; label: string }[] = [
84
+ { code: "zh", label: "中文" },
85
+ { code: "en", label: "English" },
86
+ ]
87
+
88
+ export function applyParams(tpl: string, params?: Record<string, string | number>): string {
89
+ if (!params) return tpl
90
+ return tpl.replace(/\{(\w+)\}/g, (m, k: string) =>
91
+ k in params ? String(params[k]) : m,
92
+ )
93
+ }
94
+
95
+ export function createT(getCode: () => LangCode) {
96
+ return (key: keyof Translation, params?: Record<string, string | number>): string =>
97
+ applyParams(LANGS[getCode()][key], params)
98
+ }
99
+
100
+ export function detectLang(): LangCode {
101
+ try {
102
+ const loc = Intl.DateTimeFormat().resolvedOptions().locale.toLowerCase()
103
+ if (loc.startsWith("zh")) return "zh"
104
+ return "en"
105
+ } catch {
106
+ return "en"
107
+ }
108
+ }
package/src/index.tsx CHANGED
@@ -10,45 +10,119 @@ 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"
16
+ import { createT, detectLang, LANG_META } from "./i18n"
17
+ import type { LangCode } from "./i18n"
15
18
 
16
19
  declare const process: { env: Record<string, string | undefined> } | undefined
17
20
 
21
+ const VERSION: string = __PLUGIN_VERSION__
18
22
  const CHECK_INTERVAL = Number(process?.env?.OPENCODE_GO_CHECK_INTERVAL ?? 60000)
19
23
  const WARN_THRESHOLD = Number(process?.env?.OPENCODE_GO_WARN_THRESHOLD ?? 0.8)
20
24
  const CONFIG_DIR = process?.env?.OPENCODE_CONFIG_DIR
21
25
  || (process?.env?.USERPROFILE ? `${process.env.USERPROFILE}\\.config\\opencode` : "")
22
26
  || process?.env?.HOME + "/.config/opencode"
23
27
  const CONFIG_FILE = join(CONFIG_DIR, "go-usage-config.json")
28
+ const COOKIE_ENC_FILE = join(CONFIG_DIR, "go-auth-cookie.enc")
29
+ const KEY_FILE = join(CONFIG_DIR, ".encryption-key")
30
+ const COOKIE_PLAIN_FILE = `${CONFIG_DIR}\\go-auth-cookie.txt`
31
+ const KV_PREFIX = "go-usage"
32
+
33
+ // ── AES-256-GCM 加密存储 ────────────────────────────────
34
+ // cookie 以密文存 go-auth-cookie.enc,密钥存 .encryption-key。
35
+ // 注意:Windows 无 Unix 权限位,mode 0o600 仅对 macOS/Linux 生效;
36
+ // Windows 上密钥与密文同目录,防护重点是"被动泄露"(备份/云同步/日志),
37
+ // 无法抵御能读取该目录的本地恶意程序。
38
+ function loadEncryptionKey(): Buffer {
39
+ try {
40
+ const hex = readFileSync(KEY_FILE, "utf8").trim()
41
+ if (hex) return Buffer.from(hex, "hex")
42
+ } catch { /* 无密钥则新建 */ }
43
+ const key = randomBytes(32)
44
+ mkdirSync(dirname(KEY_FILE), { recursive: true })
45
+ writeFileSync(KEY_FILE, key.toString("hex"), { encoding: "utf8", mode: 0o600 })
46
+ return key
47
+ }
48
+
49
+ function encryptText(text: string): string {
50
+ const key = loadEncryptionKey()
51
+ const iv = randomBytes(16)
52
+ const cipher = createCipheriv("aes-256-gcm", key, iv)
53
+ const data = cipher.update(text, "utf8", "hex") + cipher.final("hex")
54
+ return JSON.stringify({ v: 1, iv: iv.toString("hex"), data, tag: cipher.getAuthTag().toString("hex") })
55
+ }
56
+
57
+ function decryptText(encJson: string): string {
58
+ const { iv, data, tag } = JSON.parse(encJson)
59
+ const key = loadEncryptionKey()
60
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "hex"))
61
+ decipher.setAuthTag(Buffer.from(tag, "hex"))
62
+ return decipher.update(data, "hex", "utf8") + decipher.final("utf8")
63
+ }
64
+
65
+ function readJsonFile<T>(file: string): T | null {
66
+ try { return JSON.parse(readFileSync(file, "utf8")) as T } catch { return null }
67
+ }
68
+
69
+ interface ConfigFile { workspace_id?: string; cookie?: string; auth_cookie?: string; cookie_file?: string }
24
70
 
25
71
  function loadConfig(): { workspaceId: string; authCookie: string } {
26
72
  const env = process?.env ?? {}
27
- let fileCfg: Record<string, string> = {}
28
- try {
29
- fileCfg = JSON.parse(readFileSync(CONFIG_FILE, "utf8"))
30
- } catch { fileCfg = {} }
31
- const cookieFile = env.OPENCODE_GO_COOKIE_FILE || fileCfg.cookie_file || `${CONFIG_DIR}\\go-auth-cookie.txt`
32
- let fileCookie = ""
73
+ const fileCfg = readJsonFile<ConfigFile>(CONFIG_FILE) ?? {}
74
+
75
+ // 1. 环境变量优先
76
+ if (env.OPENCODE_GO_AUTH_COOKIE) {
77
+ return { workspaceId: env.OPENCODE_GO_WORKSPACE_ID || fileCfg.workspace_id || "", authCookie: env.OPENCODE_GO_AUTH_COOKIE }
78
+ }
79
+
80
+ // 2. 加密存储
33
81
  try {
34
- let p = cookieFile
35
- if (p === "~") p = (env.HOME || env.USERPROFILE) + ""
36
- else if (p.startsWith("~/")) p = (env.HOME || env.USERPROFILE || "") + p.slice(1)
37
- fileCookie = readFileSync(p, "utf8").trim()
38
- } catch { fileCookie = "" }
39
- return {
40
- workspaceId: env.OPENCODE_GO_WORKSPACE_ID || fileCfg.workspace_id || "",
41
- authCookie: env.OPENCODE_GO_AUTH_COOKIE || fileCfg.auth_cookie || fileCfg.cookie || fileCookie,
82
+ if (existsSync(COOKIE_ENC_FILE)) {
83
+ const cookie = decryptText(readFileSync(COOKIE_ENC_FILE, "utf8"))
84
+ if (cookie) {
85
+ return { workspaceId: env.OPENCODE_GO_WORKSPACE_ID || fileCfg.workspace_id || "", authCookie: cookie }
86
+ }
87
+ }
88
+ } catch { /* 密文损坏时回退到明文迁移 */ }
89
+
90
+ // 3. 兼容旧明文(config.json 内嵌 cookie / auth_cookie / 独立 txt 文件)——自动迁移
91
+ const legacyCookie = fileCfg.auth_cookie || fileCfg.cookie || (() => {
92
+ try { return readFileSync(COOKIE_PLAIN_FILE, "utf8").trim() } catch { return "" }
93
+ })()
94
+ if (legacyCookie) {
95
+ const enc = encryptText(legacyCookie)
96
+ mkdirSync(CONFIG_DIR, { recursive: true })
97
+ writeFileSync(COOKIE_ENC_FILE, enc, "utf8")
98
+ // 清理旧明文
99
+ const cleanCfg: ConfigFile = { ...fileCfg }
100
+ delete cleanCfg.cookie
101
+ delete cleanCfg.auth_cookie
102
+ try { writeFileSync(CONFIG_FILE, JSON.stringify(cleanCfg, null, 2), "utf8") } catch { /* 忽略 */ }
103
+ 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 }
42
105
  }
106
+
107
+ return { workspaceId: env.OPENCODE_GO_WORKSPACE_ID || fileCfg.workspace_id || "", authCookie: "" }
43
108
  }
44
109
 
45
110
  function saveConfig(patch: { workspace_id?: string; cookie?: string }): void {
46
- let fileCfg: Record<string, string> = {}
47
- try {
48
- fileCfg = JSON.parse(readFileSync(CONFIG_FILE, "utf8"))
49
- } catch { fileCfg = {} }
111
+ const fileCfg = readJsonFile<ConfigFile>(CONFIG_FILE) ?? {}
50
112
  if (patch.workspace_id !== undefined) fileCfg.workspace_id = patch.workspace_id
51
- if (patch.cookie !== undefined) fileCfg.cookie = patch.cookie
113
+ if (patch.cookie !== undefined) {
114
+ // cookie 改为加密存储,不再写明文到 config.json
115
+ if (patch.cookie) {
116
+ mkdirSync(CONFIG_DIR, { recursive: true })
117
+ writeFileSync(COOKIE_ENC_FILE, encryptText(patch.cookie), "utf8")
118
+ delete fileCfg.cookie
119
+ delete fileCfg.auth_cookie
120
+ // 清理可能存在的旧明文 txt
121
+ try { if (existsSync(COOKIE_PLAIN_FILE)) renameSync(COOKIE_PLAIN_FILE, COOKIE_PLAIN_FILE + ".bak") } catch { /* 忽略 */ }
122
+ } else {
123
+ try { if (existsSync(COOKIE_ENC_FILE)) renameSync(COOKIE_ENC_FILE, COOKIE_ENC_FILE + ".bak") } catch { /* 忽略 */ }
124
+ }
125
+ }
52
126
  try {
53
127
  mkdirSync(dirname(CONFIG_FILE), { recursive: true })
54
128
  writeFileSync(CONFIG_FILE, JSON.stringify(fileCfg, null, 2), "utf8")
@@ -58,6 +132,17 @@ function saveConfig(patch: { workspace_id?: string; cookie?: string }): void {
58
132
  // 模块级共享信号:/go-config 保存配置后递增,面板监听它立即刷新
59
133
  const [configTick, setConfigTick] = createSignal(0)
60
134
 
135
+ // 模块级共享语言信号:/go-lang 或首次引导切换后,面板和命令实时响应
136
+ const [langCode, setLangCode] = createSignal<LangCode>(detectLang())
137
+ const t = createT(() => langCode())
138
+
139
+ function kvTryGet(api: TuiPluginApi, key: string): string | undefined {
140
+ try { return api.kv.get<string>(key) } catch { return undefined }
141
+ }
142
+ function kvTrySet(api: TuiPluginApi, key: string, val: unknown): void {
143
+ try { api.kv.set(key, val) } catch { /* 忽略 */ }
144
+ }
145
+
61
146
  interface Usage {
62
147
  rollingUsage: { usagePercent: number; resetInSec: number }
63
148
  weeklyUsage: { usagePercent: number; resetInSec: number }
@@ -107,13 +192,13 @@ async function fetchUsage(workspaceId: string, authCookie: string): Promise<Usag
107
192
  }
108
193
 
109
194
  function formatReset(sec: number): string {
110
- if (sec <= 0) return "已重置"
195
+ if (sec <= 0) return t("resetDone")
111
196
  const d = Math.floor(sec / 86400)
112
197
  const h = Math.floor((sec % 86400) / 3600)
113
198
  const m = Math.floor((sec % 3600) / 60)
114
- if (d > 0) return `${d}天${h}小时`
115
- if (h > 0) return `${h}小时${m}分钟`
116
- return `${m}分钟`
199
+ if (d > 0) return t("dayHour", { d, h })
200
+ if (h > 0) return t("hourMin", { h, m })
201
+ return t("minute", { m })
117
202
  }
118
203
 
119
204
  function progressBar(percent: number, width: number): string {
@@ -175,8 +260,8 @@ function GoUsagePanel(props: { theme: TuiThemeCurrent; api: TuiPluginApi }): JSX
175
260
  }
176
261
  setConfigured(true)
177
262
  const u = await fetchUsage(cfg.workspaceId, cfg.authCookie)
178
- if (u === null) { setError("查询失败"); return }
179
- if (u.error === "cookie_expired") { setError("cookie 已过期"); return }
263
+ if (u === null) { setError(t("queryFailed")); return }
264
+ if (u.error === "cookie_expired") { setError(t("cookieExpired")); return }
180
265
  if (u.error === "not_configured") { setConfigured(false); return }
181
266
  setUsage(u)
182
267
  setLastUpdated(new Date().toLocaleTimeString())
@@ -196,10 +281,10 @@ function GoUsagePanel(props: { theme: TuiThemeCurrent; api: TuiPluginApi }): JSX
196
281
 
197
282
  const [pal, setPal] = createSignal<Record<string, string>>({ ...FALLBACK })
198
283
  createEffect(() => {
199
- const t = props.theme as any
284
+ const th = props.theme as any
200
285
  const p: Record<string, string> = { ...FALLBACK }
201
286
  for (const k of Object.keys(FALLBACK)) {
202
- const h = hex(t?.[k])
287
+ const h = hex(th?.[k])
203
288
  if (h) p[k] = h
204
289
  }
205
290
  setPal(p)
@@ -250,10 +335,11 @@ function GoUsagePanel(props: { theme: TuiThemeCurrent; api: TuiPluginApi }): JSX
250
335
  >
251
336
  <text onMouseUp={() => setOpen(o => !o)}>
252
337
  <span style={{ fg: colors().muted }}>{open() ? "\u25bc " : "\u25b6 "}</span>
253
- <span style={{ fg: colors().primary }}><b>Go 用量</b></span>
338
+ <span style={{ fg: colors().primary }}><b>{t("panelTitle")}</b></span>
339
+ <span style={{ fg: colors().muted }}> v{VERSION}</span>
254
340
  <Show when={!open() && usage()}>
255
341
  <span style={{ fg: colorFor(usage()!.weeklyUsage.usagePercent) }}>
256
- {" ".repeat(2)} {usage()!.weeklyUsage.usagePercent}%
342
+ {" ".repeat(2)}{t("weeklyShort")} {usage()!.weeklyUsage.usagePercent}%
257
343
  </span>
258
344
  </Show>
259
345
  </text>
@@ -262,25 +348,25 @@ function GoUsagePanel(props: { theme: TuiThemeCurrent; api: TuiPluginApi }): JSX
262
348
  <text fg={colors().muted}>{sep()}</text>
263
349
 
264
350
  <Show when={!configured()}>
265
- <text fg={colors().warning}>未配置账号</text>
266
- <text fg={colors().muted}>输入 /go-config 设置</text>
351
+ <text fg={colors().warning}>{t("notConfigured")}</text>
352
+ <text fg={colors().muted}>{t("notConfiguredHint")}</text>
267
353
  </Show>
268
354
 
269
355
  <Show when={configured() && error()} fallback={
270
356
  <Show when={configured() && usage()} fallback={
271
- <Show when={configured()}><text fg={colors().muted}>加载中...</text></Show>
357
+ <Show when={configured()}><text fg={colors().muted}>{t("loading")}</text></Show>
272
358
  }>
273
- {renderRow("5小时用量", usage()!.rollingUsage)}
274
- {renderRow("每周用量", usage()!.weeklyUsage)}
275
- {renderRow("每月用量", usage()!.monthlyUsage)}
359
+ {renderRow(t("rowRolling"), usage()!.rollingUsage)}
360
+ {renderRow(t("rowWeekly"), usage()!.weeklyUsage)}
361
+ {renderRow(t("rowMonthly"), usage()!.monthlyUsage)}
276
362
  <text>
277
- <span style={{ fg: colors().muted }}>最近更新 </span>
363
+ <span style={{ fg: colors().muted }}>{t("updatedAt")} </span>
278
364
  <span style={{ fg: colors().muted }}>{lastUpdated()}</span>
279
365
  </text>
280
366
  </Show>
281
367
  }>
282
368
  <text fg={colors().error}>{error()}</text>
283
- <text fg={colors().muted}>输入 /go-config 重新配置</text>
369
+ <text fg={colors().muted}>{t("reconfigureHint")}</text>
284
370
  </Show>
285
371
  </Show>
286
372
  </box>
@@ -298,50 +384,100 @@ function createSidebarSlot(api: TuiPluginApi): TuiSlotPlugin {
298
384
  }
299
385
  }
300
386
 
301
- const tui: TuiPlugin = async (api: TuiPluginApi) => {
302
- api.slots.register(createSidebarSlot(api))
303
-
304
- api.command?.register(() => [
305
- {
306
- title: "Go Usage: Configure",
307
- value: "go-usage.config",
308
- description: "设置 OpenCode Go workspace ID 和 auth cookie",
309
- slash: { name: "go-config" },
310
- onSelect: (dialog) => {
387
+ function runConfigDialog(api: TuiPluginApi, dialog: any): void {
388
+ dialog?.replace(() => (
389
+ <api.ui.DialogPrompt
390
+ title={t("wsPromptTitle")}
391
+ description={() => (
392
+ <text>{t("wsPromptDesc")}</text>
393
+ )}
394
+ placeholder={t("wsPlaceholder")}
395
+ onConfirm={(value) => {
396
+ const wsId = value.trim()
397
+ if (!wsId) { dialog?.clear(); return }
398
+ saveConfig({ workspace_id: wsId })
311
399
  dialog?.replace(() => (
312
400
  <api.ui.DialogPrompt
313
- title="输入 workspace_id"
401
+ title={t("cookiePromptTitle")}
314
402
  description={() => (
315
- <text>在 opencode.ai 控制台 URL 中获取(wrk_ 开头)</text>
403
+ <text>{t("cookiePromptDesc")}</text>
316
404
  )}
317
- placeholder="wrk_xxxxxxxxxxxx"
318
- onConfirm={(value) => {
319
- const wsId = value.trim()
320
- if (!wsId) { dialog?.clear(); return }
321
- saveConfig({ workspace_id: wsId })
322
- dialog?.replace(() => (
323
- <api.ui.DialogPrompt
324
- title="输入 auth cookie"
325
- description={() => (
326
- <text>登录 opencode.ai 后,浏览器 F12 → Application → Cookies → opencode.ai → 复制 auth 的值</text>
327
- )}
328
- placeholder="Fe26.2*..."
329
- onConfirm={(val) => {
330
- const cookie = val.trim()
331
- if (!cookie) { dialog?.clear(); return }
332
- saveConfig({ cookie })
333
- setConfigTick((v) => v + 1)
334
- api.ui.toast({ variant: "success", message: "Go 用量配置已保存" })
335
- dialog?.clear()
336
- }}
337
- onCancel={() => dialog?.clear()}
338
- />
339
- ))
405
+ placeholder={t("cookiePlaceholder")}
406
+ onConfirm={(val) => {
407
+ const cookie = val.trim()
408
+ if (!cookie) { dialog?.clear(); return }
409
+ saveConfig({ cookie })
410
+ setConfigTick((v) => v + 1)
411
+ api.ui.toast({ variant: "success", message: t("configSaved") })
412
+ dialog?.clear()
340
413
  }}
341
414
  onCancel={() => dialog?.clear()}
342
415
  />
343
416
  ))
344
- },
417
+ }}
418
+ onCancel={() => dialog?.clear()}
419
+ />
420
+ ))
421
+ }
422
+
423
+ function runLangDialog(api: TuiPluginApi, dialog: any): void {
424
+ dialog?.replace(() => (
425
+ <api.ui.DialogSelect
426
+ title={t("langTitle")}
427
+ options={LANG_META.map((m) => ({
428
+ title: `${m.label}${langCode() === m.code ? " \u2713" : ""}`,
429
+ value: m.code,
430
+ }))}
431
+ onSelect={(opt) => {
432
+ const code = opt.value as LangCode
433
+ setLangCode(code)
434
+ kvTrySet(api, `${KV_PREFIX}.lang`, code)
435
+ api.ui.toast({ message: code === "zh" ? t("langSwitchedZh") : t("langSwitchedEn") })
436
+ // 语言选择后:若无配置则继续引导配置账号
437
+ const cfg = loadConfig()
438
+ if (!cfg.workspaceId || !cfg.authCookie) {
439
+ runConfigDialog(api, dialog)
440
+ } else {
441
+ dialog?.clear()
442
+ }
443
+ }}
444
+ onCancel={() => dialog?.clear()}
445
+ />
446
+ ))
447
+ }
448
+
449
+ const tui: TuiPlugin = async (api: TuiPluginApi) => {
450
+ api.slots.register(createSidebarSlot(api))
451
+
452
+ // 恢复已保存的语言;若无则按系统检测
453
+ const savedLang = kvTryGet(api, `${KV_PREFIX}.lang`) as LangCode | undefined
454
+ if (savedLang === "zh" || savedLang === "en") setLangCode(savedLang)
455
+ else setLangCode(detectLang())
456
+
457
+ // 首次启动引导:已引导过则跳过(用 kv 标记)
458
+ const onboarded = kvTryGet(api, `${KV_PREFIX}.onboarded`)
459
+ if (!onboarded) {
460
+ kvTrySet(api, `${KV_PREFIX}.onboarded`, "1")
461
+ setTimeout(() => {
462
+ // 通过 trigger 触发语言选择命令,命令 onSelect 会拿到 dialog 栈并弹窗
463
+ try { api.command.trigger("go-usage.lang") } catch { /* 命令未就绪时忽略 */ }
464
+ }, 1500)
465
+ }
466
+
467
+ api.command?.register(() => [
468
+ {
469
+ title: t("cmdTitle"),
470
+ value: "go-usage.config",
471
+ description: t("cmdDesc"),
472
+ slash: { name: "go-config" },
473
+ onSelect: (dialog) => runConfigDialog(api, dialog),
474
+ },
475
+ {
476
+ title: t("langCmdTitle"),
477
+ value: "go-usage.lang",
478
+ description: t("langCmdDesc"),
479
+ slash: { name: "go-lang" },
480
+ onSelect: (dialog) => runLangDialog(api, dialog),
345
481
  },
346
482
  ])
347
483
  }