From 1252b10f8d3f5fa827f676b0e40d5f3d631b4461 Mon Sep 17 00:00:00 2001 From: Hotakus Date: Tue, 11 Aug 2026 00:32:23 +0800 Subject: [PATCH 01/13] =?UTF-8?q?feat(prompt):=20=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E6=A1=86=E6=8F=90=E7=A4=BA=E8=A1=8C=E9=9B=86=E6=88=90=E5=91=BD?= =?UTF-8?q?=E4=B8=AD=E7=8E=87/=E4=BD=99=E9=A2=9D/Tokens=20=E4=B8=89?= =?UTF-8?q?=E5=90=88=E4=B8=80=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.tsx | 209 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) diff --git a/src/index.tsx b/src/index.tsx index f235f2c..d91dc78 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -9,6 +9,7 @@ import type { TuiPluginModule, TuiThemeCurrent, TuiDialogStack, + TuiPromptRef, } from "@opencode-ai/plugin/tui" import type { UserMessage, AssistantMessage, Message } from "@opencode-ai/sdk" import type { @@ -123,6 +124,9 @@ const ZH_T = { balErr403: "余额查询被拒绝", balErrEmpty:"未获取到余额数据", balErrTimeout: "查询超时", + barHit: "命中率", + barBal: "余额", + barTok: "Tokens", } as const const EN_T = { @@ -163,6 +167,9 @@ const EN_T = { balErr403: "Balance request rejected", balErrEmpty:"No balance data", balErrTimeout: "Request timed out", + barHit: "Hit", + barBal: "Balance", + barTok: "Tokens", } as const // ── color helpers ──────────────────────────────────────────────── @@ -401,6 +408,31 @@ function balanceSymbol(currency: string): string { return sym ?? currency + " " } +/** 紧凑数字缩写(底部状态栏用):1234 → "1.2K",1234567 → "1.2M"。 */ +function fmtCompact(n: number): string { + if (n >= 1e6) return (n / 1e6).toFixed(1) + "M" + if (n >= 1e3) return (n / 1e3).toFixed(1) + "K" + return String(Math.round(n)) +} + +/** + * 将余额列表格式化为单行文本。 + * 优先直接显示偏好币种(CNY/USD…);偏好币种为换算币种时按汇率折算第一条余额。 + */ +function formatBalanceText(list: BalanceEntry[], pref: string, rate: number): string { + const native = pref ? list.find((x) => x.currency === pref) : undefined + if (native) return balanceSymbol(native.currency) + native.total + const base = list[0] + const baseAmt = parseFloat(base.total) + const converted = Number.isFinite(baseAmt) + ? convertBalance(pref || base.currency, rate, baseAmt, base.currency) + : baseAmt + const shown = pref && base.currency !== pref + ? converted.toLocaleString("en-US", { maximumFractionDigits: 2 }) + : base.total + return balanceSymbol(pref || base.currency) + shown +} + // --------------------------------------------------------------------------- // Sidebar component // --------------------------------------------------------------------------- @@ -1235,6 +1267,153 @@ function TokenCachePanel(props: { // Plugin entry // --------------------------------------------------------------------------- +/** + * 输入框 hint 行(session_prompt slot 的 hint):单行显示 路径 · 命中率 · 余额 · Tokens。 + * 通过 ui.Prompt 的 hint prop 注入——宿主右侧的 token/commands 提示自动保留, + * 三合一信息与路径同行显示在中间位置。 + */ +function BottomStatusBar(props: { api: TuiPluginApi; signals: PanelSignals; sessionId: string }): JSX.Element { + const KV_PREFIX = "cache_panel" + const t = createMemo(() => (props.signals.langZH() ? ZH_T : EN_T)) + + const sid = props.sessionId + + // ── 命中率 + token 汇总(复用侧边栏口径:最后一条有 token 的 assistant 消息)── + const stats = createMemo(() => { + const id = sid + if (!id) return null + const msgs = props.api.state.session.messages(id) as Message[] + const session = typeof props.api.state.session.get === "function" + ? props.api.state.session.get(id) + : undefined + let input = session?.tokens?.input ?? 0 + let read = session?.tokens?.cache?.read ?? 0 + let output = session?.tokens?.output ?? 0 + let hitRate = -1 + for (let i = msgs.length - 1; i >= 0; i--) { + const m = msgs[i] + if (m.role !== "assistant") continue + const tk = (m as AssistantMessage).tokens + if (!tk) continue + const mit = num(tk.input) + num(tk.cache?.read) + const mrt = num(tk.cache?.read) + if (mit > 0) { hitRate = (mrt / mit) * 100; break } + } + return { hitRate, input, read, output } + }) + + // ── 余额查询(独立轮询,共享 provider/key 逻辑)── + const [balanceState, setBalanceState] = createSignal({ + status: "idle", data: null, lastFetch: 0, + }) + let balanceSeq = 0 + + const pollBalance = async () => { + const provider = getBalanceProvider(props.signals.balanceProviderId()) + const key = props.api.kv.get(`${KV_PREFIX}.balance.${provider.id}.key`, "") + || findOpencodeKey(props.api, provider) + if (!key) { setBalanceState({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined }); return } + const now = Date.now() + const prev = balanceState() + if (prev.status === "ok" && prev.key === key && now - prev.lastFetch < BALANCE_POLL_MS) return + const seq = ++balanceSeq + setBalanceState({ ...prev, status: "loading", error: undefined, key }) + const controller = new AbortController() + let timedOut = false + const timer = setTimeout(() => { timedOut = true; controller.abort() }, 10_000) + try { + const data = await provider.fetchBalance(key, controller.signal) + clearTimeout(timer) + if (seq !== balanceSeq) return + setBalanceState({ status: "ok", data, lastFetch: Date.now(), error: undefined, key }) + } catch (err) { + clearTimeout(timer) + if (seq !== balanceSeq) return + const code = timedOut ? "TIMEOUT" : (err instanceof Error ? err.message : "") + setBalanceState({ status: "error", data: null, lastFetch: 0, error: code, key }) + } + } + + createEffect(() => { + void props.signals.balanceRefresh() + untrack(() => { void pollBalance() }) + }) + + // 自动切换 provider(跟随当前会话模型;幂等,与侧边栏共享信号) + createEffect(() => { + if (!props.signals.autoBalance()) return + const id = sid + if (!id) return + const msgs = props.api.state.session.messages(id) as Message[] + let pid = "" + for (let i = msgs.length - 1; i >= 0; i--) { + const m = msgs[i] + if (m.role === "assistant" && (m as AssistantMessage).providerID) { pid = (m as AssistantMessage).providerID; break } + } + if (!pid) { + try { pid = props.api.state.session.get(id)?.model?.providerID ?? "" } catch {} + } + if (!pid) return + const hit = matchBalanceProvider(pid) + if (hit && hit.id !== props.signals.balanceProviderId()) { + props.signals.setBalanceProviderId(hit.id) + props.signals.setBalanceRefresh(props.signals.balanceRefresh() + 1) + } + }) + + // ── 主题色(与侧边栏同口径)── + const pal = createMemo(() => { + const th = props.api.theme.current as Record + const sat = (k: string, fb: string) => desaturateTo(th[k], MAX_SAT, fb) + return { + text: sat("text", FALLBACK.text), + muted: sat("textMuted", FALLBACK.muted), + success: sat("success", FALLBACK.success), + warning: sat("warning", FALLBACK.warning), + error: sat("error", FALLBACK.error), + } + }) + + const hitColor = createMemo(() => { + const r = stats()?.hitRate ?? -1 + if (r >= 85) return pal().success + if (r >= 70) return pal().warning + return pal().error + }) + + const balanceText = createMemo(() => { + const s = balanceState() + if (s.status === "ok" && s.data) return formatBalanceText(s.data, props.signals.balanceCurrency(), props.signals.exchangeRate()) + if (s.status === "loading") return "\u2026" + if (s.status === "error") return "\u26a0" + return "-" + }) + + // 路径显示(替换宿主默认 hint 左侧的 cwd 文本) + const directory = createMemo(() => { + try { return props.api.state.path.directory } catch { return "" } + }) + + return ( + + {directory()} + + + {t().barHit} + {(stats()?.hitRate ?? -1) >= 0 ? (Math.floor(stats()!.hitRate * 10) / 10).toFixed(1) + "%" : "--"} + {" \u00b7 " + t().barTok + " "} + + {stats() ? fmtCompact(stats()!.input + stats()!.read + stats()!.output) : "--"} + + {" \u00b7 " + t().barBal + " "} + {balanceText()} + + {" \u00b7 "} + + + ) +} + function createSidebarSlot(api: TuiPluginApi, signals: PanelSignals): TuiSlotPlugin { let lastSlotSid = "" return { @@ -1298,6 +1477,36 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { api.slots.register(createSidebarSlot(api, signals)) + // 输入框 hint 行(session_prompt slot,replace 模式): + // 用宿主同一 Prompt 组件重渲染输入框,仅替换 hint 行左侧—— + // 在路径与右侧 token/commands 提示之间插入 命中率 · 余额 · Tokens。 + api.slots.register({ + order: 55, + slots: { + session_prompt( + _ctx: TuiSlotContext, + input: { + session_id: string + visible?: boolean + disabled?: boolean + on_submit?: () => void + ref?: (ref: TuiPromptRef | undefined) => void + }, + ): JSX.Element { + return ( + } + /> + ) + }, + }, + }) + // ── slash commands for runtime config ── const KV_PREFIX = "cache_panel" From 08296920ef9efc0dc84e2b8d51c7db4999de48cd Mon Sep 17 00:00:00 2001 From: Hotakus Date: Tue, 11 Aug 2026 01:13:20 +0800 Subject: [PATCH 02/13] =?UTF-8?q?feat(prompt):=20=E5=91=BD=E4=B8=AD?= =?UTF-8?q?=E7=8E=87=E6=94=B9=E5=8D=95=E6=9D=A1=E5=8F=A3=E5=BE=84=E5=B9=B6?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E8=B6=8B=E5=8A=BF=EF=BC=8C=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=BE=A7=E8=BE=B9=E6=A0=8F=E8=B6=8B=E5=8A=BF=E9=98=88=E5=80=BC?= =?UTF-8?q?=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.tsx | 45 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index d91dc78..9b712ac 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -945,7 +945,9 @@ function TokenCachePanel(props: { const sep = createMemo(() => "\u2500".repeat(Math.max(1, panelWidth() - gutter()))) function trendLabel(t: number): string { - return (t > 0 ? "\u2191" : t < 0 ? "\u2193" : "-") + (t !== 0 ? Math.abs(t).toFixed(1) + "%" : "") + // |t| < 0.05 视为无变化:避免显示 "↑0.0%" 的矛盾(箭头存在但数值截断为零) + if (Math.abs(t) < 0.05) return "-" + return (t > 0 ? "\u2191" : "\u2193") + Math.abs(t).toFixed(1) + "%" } const barW = createMemo(() => { @@ -1007,7 +1009,7 @@ function TokenCachePanel(props: { {" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(t().title) - visualWidth(pct() + " " + t().hitFolded + " " + trendLabel(data().trend))))} {pct()} {t().hitFolded} - 0 ? pal().success : pal().error) : pal().text }}> + = 0.05 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }}> {" "}{trendLabel(data().trend)} @@ -1050,7 +1052,7 @@ function TokenCachePanel(props: { [{bar()}] {pct()} - 0 ? pal().success : pal().error) : pal().text }}> + = 0.05 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }}> {" "}{trendLabel(data().trend)} @@ -1278,7 +1280,7 @@ function BottomStatusBar(props: { api: TuiPluginApi; signals: PanelSignals; sess const sid = props.sessionId - // ── 命中率 + token 汇总(复用侧边栏口径:最后一条有 token 的 assistant 消息)── + // ── 命中率(单条口径:最后一条有 token 的 assistant 消息)+ token 汇总 ── const stats = createMemo(() => { const id = sid if (!id) return null @@ -1289,7 +1291,19 @@ function BottomStatusBar(props: { api: TuiPluginApi; signals: PanelSignals; sess let input = session?.tokens?.input ?? 0 let read = session?.tokens?.cache?.read ?? 0 let output = session?.tokens?.output ?? 0 - let hitRate = -1 + // 旧 SDK 无 session 聚合字段 → 遍历消息累加(与侧边栏 fallback 一致) + if (session?.tokens == null) { + for (const m of msgs) { + if (m.role !== "assistant") continue + const tk = (m as AssistantMessage).tokens + if (!tk) continue + input += num(tk.input) + read += num(tk.cache?.read) + output += num(tk.output) + } + } + // 从后往前取最后两条有 token 数据的 assistant 消息 → 单条命中率 + 趋势 + let hitRate = -1, prevHitRate = -1 for (let i = msgs.length - 1; i >= 0; i--) { const m = msgs[i] if (m.role !== "assistant") continue @@ -1297,9 +1311,13 @@ function BottomStatusBar(props: { api: TuiPluginApi; signals: PanelSignals; sess if (!tk) continue const mit = num(tk.input) + num(tk.cache?.read) const mrt = num(tk.cache?.read) - if (mit > 0) { hitRate = (mrt / mit) * 100; break } + if (mit <= 0) continue + const rate = (mrt / mit) * 100 + if (hitRate < 0) { hitRate = rate; continue } + prevHitRate = rate + break } - return { hitRate, input, read, output } + return { hitRate, prevHitRate, input, read, output } }) // ── 余额查询(独立轮询,共享 provider/key 逻辑)── @@ -1381,6 +1399,14 @@ function BottomStatusBar(props: { api: TuiPluginApi; signals: PanelSignals; sess return pal().error }) + // 命中率趋势:最后一条与上一条的差值;|Δ| < 0.05 视为无变化(null = 不显示) + const trend = createMemo(() => { + const s = stats() + if (!s || s.prevHitRate < 0 || s.hitRate < 0) return null + const d = s.hitRate - s.prevHitRate + return Math.abs(d) < 0.05 ? null : d + }) + const balanceText = createMemo(() => { const s = balanceState() if (s.status === "ok" && s.data) return formatBalanceText(s.data, props.signals.balanceCurrency(), props.signals.exchangeRate()) @@ -1401,6 +1427,11 @@ function BottomStatusBar(props: { api: TuiPluginApi; signals: PanelSignals; sess {t().barHit} {(stats()?.hitRate ?? -1) >= 0 ? (Math.floor(stats()!.hitRate * 10) / 10).toFixed(1) + "%" : "--"} + + 0 ? pal().success : pal().error }}> + {" " + (trend()! > 0 ? "\u2191" : "\u2193") + Math.abs(trend()!).toFixed(1) + "%"} + + {" \u00b7 " + t().barTok + " "} {stats() ? fmtCompact(stats()!.input + stats()!.read + stats()!.output) : "--"} From 9076516d59897767a95b5d702e1e2126176d06e1 Mon Sep 17 00:00:00 2001 From: Hotakus Date: Tue, 11 Aug 2026 01:18:04 +0800 Subject: [PATCH 03/13] =?UTF-8?q?feat(balance):=20=E4=BD=99=E9=A2=9D?= =?UTF-8?q?=E8=87=AA=E9=80=82=E5=BA=94=E7=B2=BE=E5=BA=A6=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=EF=BC=8C=E4=BE=A7=E8=BE=B9=E6=A0=8F=E5=A4=8D=E7=94=A8=E5=85=B1?= =?UTF-8?q?=E4=BA=AB=E6=A0=BC=E5=BC=8F=E5=8C=96=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.tsx | 42 +++++++++++++----------------------------- 1 file changed, 13 insertions(+), 29 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 9b712ac..ce4221b 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -415,13 +415,21 @@ function fmtCompact(n: number): string { return String(Math.round(n)) } +/** 余额数值格式化:≥1 或 0 显示 2 位小数;小额(<1)保留精度(最多 6 位),避免抹成 0.00。 */ +function formatBalanceAmount(total: string): string { + const n = parseFloat(total) + if (!Number.isFinite(n)) return total + if (n === 0 || n >= 1) return n.toLocaleString("en-US", { maximumFractionDigits: 2 }) + return n.toLocaleString("en-US", { maximumFractionDigits: 6 }) +} + /** * 将余额列表格式化为单行文本。 * 优先直接显示偏好币种(CNY/USD…);偏好币种为换算币种时按汇率折算第一条余额。 */ function formatBalanceText(list: BalanceEntry[], pref: string, rate: number): string { const native = pref ? list.find((x) => x.currency === pref) : undefined - if (native) return balanceSymbol(native.currency) + native.total + if (native) return balanceSymbol(native.currency) + formatBalanceAmount(native.total) const base = list[0] const baseAmt = parseFloat(base.total) const converted = Number.isFinite(baseAmt) @@ -429,7 +437,7 @@ function formatBalanceText(list: BalanceEntry[], pref: string, rate: number): st : baseAmt const shown = pref && base.currency !== pref ? converted.toLocaleString("en-US", { maximumFractionDigits: 2 }) - : base.total + : formatBalanceAmount(base.total) return balanceSymbol(pref || base.currency) + shown } @@ -1230,33 +1238,9 @@ function TokenCachePanel(props: { - {(() => { - const list = balanceState().data! - const pref = balanceCurrency() - // 偏好币种是 DeepSeek 原生返回的(CNY/USD)→ 直接显示 - const native = pref ? list.find(x => x.currency === pref) : undefined - if (native) { - return ( - - {justify(t().balTotal, balanceSymbol(native.currency) + native.total)} - - ) - } - // 非原生币种(EUR/JPY/GBP/KRW…)→ 取第一条余额按汇率换算 - const base = list[0] - const baseAmt = parseFloat(base.total) - const converted = Number.isFinite(baseAmt) - ? convertBalance(pref || base.currency, exchangeRate(), baseAmt, base.currency) - : baseAmt - const shown = pref && base.currency !== pref - ? converted.toLocaleString("en-US", { maximumFractionDigits: 2 }) - : base.total - return ( - - {justify(t().balTotal, balanceSymbol(pref || base.currency) + shown)} - - ) - })()} + + {justify(t().balTotal, formatBalanceText(balanceState().data!, balanceCurrency(), exchangeRate()))} + From f5e5c132e18f41dc96222751e9715b09232795e0 Mon Sep 17 00:00:00 2001 From: Hotakus Date: Tue, 11 Aug 2026 01:30:46 +0800 Subject: [PATCH 04/13] =?UTF-8?q?feat(section):=20/cache-section=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=BA=95=E9=83=A8=E7=8A=B6=E6=80=81=E6=A0=8F?= =?UTF-8?q?=E6=98=BE=E9=9A=90=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.tsx | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index ce4221b..14a1952 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -465,6 +465,9 @@ interface PanelSignals { setSectionSkills: (v: boolean) => void sectionBalance: () => boolean setSectionBalance: (v: boolean) => void + /** Bottom status bar (prompt hint line) visibility. */ + sectionBottom: () => boolean + setSectionBottom: (v: boolean) => void /** Increment to force a balance re-fetch. */ balanceRefresh: () => number setBalanceRefresh: (v: number) => void @@ -1404,9 +1407,18 @@ function BottomStatusBar(props: { api: TuiPluginApi; signals: PanelSignals; sess try { return props.api.state.path.directory } catch { return "" } }) + // 恢复显隐偏好(默认显示);关闭时回退为仅显示路径,与宿主默认 hint 行一致 + onMount(() => { + try { + const v = props.api.kv.get(`${KV_PREFIX}.section.bottom`, true) + props.signals.setSectionBottom(v !== false) + } catch {} + }) + return ( - - {directory()} + {directory()}}> + + {directory()} {t().barHit} @@ -1424,8 +1436,9 @@ function BottomStatusBar(props: { api: TuiPluginApi; signals: PanelSignals; sess {balanceText()} {" \u00b7 "} + - + ) } @@ -1465,6 +1478,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { const [sectionDist, setSectionDist] = createSignal(true) const [sectionSkills, setSectionSkills] = createSignal(true) const [sectionBalance, setSectionBalance] = createSignal(true) + const [sectionBottom, setSectionBottom] = createSignal(true) const [balanceRefresh, setBalanceRefresh] = createSignal(0) const [balanceProviderId, setBalanceProviderId] = createSignal("deepseek") const [autoBalance, setAutoBalance] = createSignal(true) @@ -1482,6 +1496,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { sectionDist, setSectionDist, sectionSkills, setSectionSkills, sectionBalance, setSectionBalance, + sectionBottom, setSectionBottom, balanceRefresh, setBalanceRefresh, balanceProviderId, setBalanceProviderId, autoBalance, setAutoBalance, @@ -1640,6 +1655,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { const distOn = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true)) const skillsOn = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true)) const balanceOn = Boolean(api.kv.get(`${KV_PREFIX}.section.balance`, true)) + const bottomOn = Boolean(api.kv.get(`${KV_PREFIX}.section.bottom`, true)) const borderOn = Boolean(api.kv.get(`${KV_PREFIX}.border`, true)) dialog?.replace(() => ( { { title: `Token Dist. [${distOn ? "ON" : "OFF"}]`, value: "dist" }, { title: `Loaded Skills [${skillsOn ? "ON" : "OFF"}]`, value: "skills" }, { title: `Balance [${balanceOn ? "ON" : "OFF"}]`, value: "balance" }, + { title: `Bottom Bar [${bottomOn ? "ON" : "OFF"}]`, value: "bottom" }, { title: `Panel Border [${borderOn ? "ON" : "OFF"}]`, value: "border" }, ]} onSelect={(opt) => { @@ -1667,6 +1684,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { if (opt.value === "dist") signals.setSectionDist(!cur) if (opt.value === "skills") signals.setSectionSkills(!cur) if (opt.value === "balance") signals.setSectionBalance(!cur) + if (opt.value === "bottom") signals.setSectionBottom(!cur) api.ui.toast({ message: `${opt.value} section ${!cur ? "shown" : "hidden"}` }) } dialog?.clear() @@ -1688,9 +1706,10 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { const dist = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true)) const skills = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true)) const balance = Boolean(api.kv.get(`${KV_PREFIX}.section.balance`, true)) + const bottom = Boolean(api.kv.get(`${KV_PREFIX}.section.bottom`, true)) api.ui.toast({ title: "Cache Panel Config", - message: `Currency: ${sym} | Rate: ${rate} | Detail: ${detail ? "ON" : "OFF"} | Model: ${model ? "ON" : "OFF"} | Dist: ${dist ? "ON" : "OFF"} | Skills: ${skills ? "ON" : "OFF"} | Balance: ${balance ? "ON" : "OFF"}`, + message: `Currency: ${sym} | Rate: ${rate} | Detail: ${detail ? "ON" : "OFF"} | Model: ${model ? "ON" : "OFF"} | Dist: ${dist ? "ON" : "OFF"} | Skills: ${skills ? "ON" : "OFF"} | Balance: ${balance ? "ON" : "OFF"} | Bottom: ${bottom ? "ON" : "OFF"}`, duration: 8000, }) dialog?.clear() From 2cd21dc912b4f20aadb1e4598a3e2a8153797093 Mon Sep 17 00:00:00 2001 From: Hotakus Date: Tue, 11 Aug 2026 01:44:36 +0800 Subject: [PATCH 05/13] =?UTF-8?q?feat(balance):=20=E4=B8=8D=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E4=BD=99=E9=A2=9D=E6=9F=A5=E8=AF=A2=E7=9A=84=E6=8F=90?= =?UTF-8?q?=E4=BE=9B=E5=95=86=E6=98=BE=E7=A4=BA=E6=8F=90=E7=A4=BA=E5=B9=B6?= =?UTF-8?q?=E9=9A=90=E8=97=8F=E4=BD=99=E9=A2=9D=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.tsx | 102 +++++++++++++++++++++++++++++++++----------------- 1 file changed, 68 insertions(+), 34 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 14a1952..036a8c2 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -124,6 +124,7 @@ const ZH_T = { balErr403: "余额查询被拒绝", balErrEmpty:"未获取到余额数据", balErrTimeout: "查询超时", + balUnsupported: "当前提供商不支持余额查询", barHit: "命中率", barBal: "余额", barTok: "Tokens", @@ -167,6 +168,7 @@ const EN_T = { balErr403: "Balance request rejected", balErrEmpty:"No balance data", balErrTimeout: "Request timed out", + balUnsupported: "Balance query unsupported", barHit: "Hit", barBal: "Balance", barTok: "Tokens", @@ -477,6 +479,9 @@ interface PanelSignals { /** Auto-switch to the session's provider for balance display. Manual switch disables it. */ autoBalance: () => boolean setAutoBalance: (v: boolean) => void + /** True when the session's provider has no balance adapter (auto mode). Suppresses balance polling. */ + balanceUnsupported: () => boolean + setBalanceUnsupported: (v: boolean) => void /** Preferred currency code for balance display (CNY / USD / …). Empty = first entry. */ balanceCurrency: () => string setBalanceCurrency: (v: string) => void @@ -535,6 +540,7 @@ function TokenCachePanel(props: { balanceRefresh, balanceProviderId, setBalanceProviderId, autoBalance, setAutoBalance, + balanceUnsupported, setBalanceUnsupported, balanceCurrency, setBalanceCurrency, borderVisible, setBorderVisible, } = props.signals @@ -584,6 +590,7 @@ function TokenCachePanel(props: { // 手动配置的 key 优先;缺失时自动复用 OpenCode 已认证的 key(auth.json / config) const key = props.api.kv.get(`${KV_PREFIX}.balance.${provider.id}.key`, "") || findOpencodeKey(props.api, provider) + if (balanceUnsupported()) { setBalanceState({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined }); return } if (!key) { setBalanceState({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined }); return } const now = Date.now() const prev = balanceState() @@ -642,9 +649,15 @@ function TokenCachePanel(props: { } if (!pid) return const hit = matchBalanceProvider(pid) - if (hit && hit.id !== balanceProviderId()) { - setBalanceProviderId(hit.id) - props.signals.setBalanceRefresh(props.signals.balanceRefresh() + 1) + if (hit) { + setBalanceUnsupported(false) + if (hit.id !== balanceProviderId()) { + setBalanceProviderId(hit.id) + props.signals.setBalanceRefresh(props.signals.balanceRefresh() + 1) + } + } else { + // 当前提供商没有余额适配器 → 标记不支持,余额显示 N/A 并停止轮询 + setBalanceUnsupported(true) } }) @@ -851,6 +864,7 @@ function TokenCachePanel(props: { const savedProvider = props.api.kv.get(`${KV_PREFIX}.balance.provider`) if (typeof savedProvider === "string" && balanceProviders.some((p) => p.id === savedProvider)) { setBalanceProviderId(savedProvider) + setBalanceUnsupported(false) } // Restore auto-switch (default on) const savedAuto = props.api.kv.get(`${KV_PREFIX}.balance.auto`) @@ -1212,38 +1226,46 @@ function TokenCachePanel(props: { - {/* ── DeepSeek balance (single line) ── */} + {/* ── provider balance (single line) ── */} {sep()} - - - {"> "} - {t().balNoKey.replace("{p}", providerName())} - - - + {"> "} - {t().balLoading} + {t().balUnsupported} - - - {"> "} - {(() => { - const code = balanceState().error - if (code === "401") return t().balErr401 - if (code === "403") return t().balErr403 - if (code === "EMPTY") return t().balErrEmpty - if (code === "TIMEOUT") return t().balErrTimeout - return t().balError + (code ? ` (${code})` : "") - })()} - - - - - {justify(t().balTotal, formatBalanceText(balanceState().data!, balanceCurrency(), exchangeRate()))} - + + + + {"> "} + {t().balNoKey.replace("{p}", providerName())} + + + + + {"> "} + {t().balLoading} + + + + + {"> "} + {(() => { + const code = balanceState().error + if (code === "401") return t().balErr401 + if (code === "403") return t().balErr403 + if (code === "EMPTY") return t().balErrEmpty + if (code === "TIMEOUT") return t().balErrTimeout + return t().balError + (code ? ` (${code})` : "") + })()} + + + + + {justify(t().balTotal, formatBalanceText(balanceState().data!, balanceCurrency(), exchangeRate()))} + + @@ -1317,6 +1339,7 @@ function BottomStatusBar(props: { api: TuiPluginApi; signals: PanelSignals; sess const provider = getBalanceProvider(props.signals.balanceProviderId()) const key = props.api.kv.get(`${KV_PREFIX}.balance.${provider.id}.key`, "") || findOpencodeKey(props.api, provider) + if (props.signals.balanceUnsupported()) { setBalanceState({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined }); return } if (!key) { setBalanceState({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined }); return } const now = Date.now() const prev = balanceState() @@ -1360,9 +1383,15 @@ function BottomStatusBar(props: { api: TuiPluginApi; signals: PanelSignals; sess } if (!pid) return const hit = matchBalanceProvider(pid) - if (hit && hit.id !== props.signals.balanceProviderId()) { - props.signals.setBalanceProviderId(hit.id) - props.signals.setBalanceRefresh(props.signals.balanceRefresh() + 1) + if (hit) { + props.signals.setBalanceUnsupported(false) + if (hit.id !== props.signals.balanceProviderId()) { + props.signals.setBalanceProviderId(hit.id) + props.signals.setBalanceRefresh(props.signals.balanceRefresh() + 1) + } + } else { + // 当前提供商没有余额适配器 → 标记不支持,余额显示 N/A 并停止轮询 + props.signals.setBalanceUnsupported(true) } }) @@ -1432,8 +1461,10 @@ function BottomStatusBar(props: { api: TuiPluginApi; signals: PanelSignals; sess {stats() ? fmtCompact(stats()!.input + stats()!.read + stats()!.output) : "--"} - {" \u00b7 " + t().barBal + " "} - {balanceText()} + + {" \u00b7 " + t().barBal + " "} + {balanceText()} + {" \u00b7 "} @@ -1482,6 +1513,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { const [balanceRefresh, setBalanceRefresh] = createSignal(0) const [balanceProviderId, setBalanceProviderId] = createSignal("deepseek") const [autoBalance, setAutoBalance] = createSignal(true) + const [balanceUnsupported, setBalanceUnsupported] = createSignal(false) const [balanceCurrency, setBalanceCurrency] = createSignal("") const [borderVisible, setBorderVisible] = createSignal(true) const [langZH, setLangZH] = createSignal(LANG_ZH) @@ -1500,6 +1532,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { balanceRefresh, setBalanceRefresh, balanceProviderId, setBalanceProviderId, autoBalance, setAutoBalance, + balanceUnsupported, setBalanceUnsupported, balanceCurrency, setBalanceCurrency, borderVisible, setBorderVisible, overrideSessionId, setOverrideSessionId, @@ -1779,6 +1812,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { api.kv.set(`${KV_PREFIX}.balance.auto`, false) signals.setBalanceProviderId(provider.id) signals.setAutoBalance(false) + signals.setBalanceUnsupported(false) // 切换后立即按新 provider 刷新显示(无 key 时显示 idle,避免残留上一 provider 余额) signals.setBalanceRefresh(signals.balanceRefresh() + 1) const hasKey = !!api.kv.get(`${KV_PREFIX}.balance.${provider.id}.key`, "") From 9c52919ecef5d7de48efa913036a7c3276162da5 Mon Sep 17 00:00:00 2001 From: Hotakus Date: Tue, 11 Aug 2026 02:37:36 +0800 Subject: [PATCH 06/13] =?UTF-8?q?refactor(balance):=20=E4=BD=99=E9=A2=9D?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=8F=90=E5=8D=87=E4=B8=BA=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E5=B1=82=EF=BC=8C=E6=B6=88=E9=99=A4=E5=8F=8C=E4=BB=BD=E8=BD=AE?= =?UTF-8?q?=E8=AF=A2=E4=B8=8E=E6=98=BE=E7=A4=BA=E4=B8=8D=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.tsx | 169 +++++++++++++++++++++----------------------------- 1 file changed, 72 insertions(+), 97 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 036a8c2..82cefa3 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -417,11 +417,11 @@ function fmtCompact(n: number): string { return String(Math.round(n)) } -/** 余额数值格式化:≥1 或 0 显示 2 位小数;小额(<1)保留精度(最多 6 位),避免抹成 0.00。 */ +/** 余额数值格式化:≥1 或 0 显示固定 2 位小数;小额(<1)保留精度(最多 6 位),避免抹成 0.00。 */ function formatBalanceAmount(total: string): string { const n = parseFloat(total) if (!Number.isFinite(n)) return total - if (n === 0 || n >= 1) return n.toLocaleString("en-US", { maximumFractionDigits: 2 }) + if (n === 0 || n >= 1) return n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) return n.toLocaleString("en-US", { maximumFractionDigits: 6 }) } @@ -482,6 +482,8 @@ interface PanelSignals { /** True when the session's provider has no balance adapter (auto mode). Suppresses balance polling. */ balanceUnsupported: () => boolean setBalanceUnsupported: (v: boolean) => void + /** Shared balance query state — single source of truth for sidebar and bottom bar. */ + balanceState: () => BalanceState /** Preferred currency code for balance display (CNY / USD / …). Empty = first entry. */ balanceCurrency: () => string setBalanceCurrency: (v: string) => void @@ -541,6 +543,7 @@ function TokenCachePanel(props: { balanceProviderId, setBalanceProviderId, autoBalance, setAutoBalance, balanceUnsupported, setBalanceUnsupported, + balanceState, balanceCurrency, setBalanceCurrency, borderVisible, setBorderVisible, } = props.signals @@ -575,55 +578,9 @@ function TokenCachePanel(props: { }) const [refreshTick, setRefreshTick] = createSignal(0) - // ── balance state + polling ────────────────────────────────── - const [balanceState, setBalanceState] = createSignal({ - status: "idle", data: null, lastFetch: 0, - }) - // 请求序号:防止定时轮询与手动刷新并发时,慢的旧请求覆盖新结果 - let balanceSeq = 0 - - // 当前 provider 显示名 + // 当前 provider 显示名(余额查询状态为共享信号,见 PanelSignals.balanceState) const providerName = createMemo(() => getBalanceProvider(balanceProviderId()).name) - const pollBalance = async () => { - const provider = getBalanceProvider(balanceProviderId()) - // 手动配置的 key 优先;缺失时自动复用 OpenCode 已认证的 key(auth.json / config) - const key = props.api.kv.get(`${KV_PREFIX}.balance.${provider.id}.key`, "") - || findOpencodeKey(props.api, provider) - if (balanceUnsupported()) { setBalanceState({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined }); return } - if (!key) { setBalanceState({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined }); return } - const now = Date.now() - const prev = balanceState() - // key 已更换(重新输入)→ 强制重新查询,绕过缓存 - if (prev.status === "ok" && prev.key === key && now - prev.lastFetch < BALANCE_POLL_MS) return // cache still fresh - const seq = ++balanceSeq - setBalanceState({ ...prev, status: "loading", error: undefined, key }) - const controller = new AbortController() - let timedOut = false - const timer = setTimeout(() => { timedOut = true; controller.abort() }, 10_000) - try { - const data = await provider.fetchBalance(key, controller.signal) - clearTimeout(timer) - if (seq !== balanceSeq) return // 已被更新的请求取代,丢弃过期结果 - setBalanceState({ status: "ok", data, lastFetch: Date.now(), error: undefined, key }) - } catch (err) { - clearTimeout(timer) - if (seq !== balanceSeq) return - const code = timedOut ? "TIMEOUT" : (err instanceof Error ? err.message : "") - // 失败时清空旧数据,避免显示过期余额 - setBalanceState({ status: "error", data: null, lastFetch: 0, error: code, key }) - } - } - - // Re-fetch when the API key is (re)configured via /cache-balance-key. - // 注意:pollBalance 内部读写 balanceState 信号,若不做 untrack 包裹, - // effect 会追踪 balanceState 的变化并与 pollBalance 的 setBalanceState - // 形成无限循环(每次重跑都发起新的 fetch 请求)。 - createEffect(() => { - void balanceRefresh() - untrack(() => { void pollBalance() }) - }) - // 自动切换当前会话的 provider(前缀匹配)。手动切换会关闭此行为。 // 直接追踪 messages 取最后一条 assistant 消息的 providerID—— // 不依赖 session.model 的响应式更新(模型切换时该链路可能不触发重算)。 @@ -703,7 +660,7 @@ function TokenCachePanel(props: { for (const msg of msgs) { if (msg.role !== "assistant") continue const t = (msg as AssistantMessage).tokens; if (!t) continue - const mit = num(t.input) + num(t.cache?.read), mrt = num(t.cache?.read) + const mit = num(t.input) + num(t.cache?.read) + num(t.cache?.write), mrt = num(t.cache?.read) if (mit > 0) { prevMsgHitRate = lastMsgHitRate; lastMsgHitRate = (mrt / mit) * 100 } if (fallbackTokens) { input += num(t.input); read += num(t.cache?.read); write += num(t.cache?.write); output += num(t.output) @@ -724,7 +681,8 @@ function TokenCachePanel(props: { break } const hitRate = lastMsgHitRate >= 0 ? lastMsgHitRate : 0 - const freshTotal = input + read, sessionHitRate = freshTotal > 0 ? (read / freshTotal) * 100 : 0 + // 总命中率分母含缓存写(业界口径:read / (input+read+write)) + const freshTotal = input + read + write, sessionHitRate = freshTotal > 0 ? (read / freshTotal) * 100 : 0 const model = mid.split("/").pop() ?? mid, hasPricing = inputRate > 0 || cacheReadRate > 0 || cacheWriteRate > 0 const hasTrendData = prevMsgHitRate >= 0 && lastMsgHitRate >= 0 const trend = hasTrendData ? lastMsgHitRate - prevMsgHitRate : 0, providerName = pid || "" @@ -937,8 +895,7 @@ function TokenCachePanel(props: { const unsubMsg = props.api.event.on("message.updated", () => { bumpPartVersion(); setRefreshTick(v => v + 1) }) const unsubSession = props.api.event.on("session.updated", () => { setRefreshTick(v => v + 1) }) setRefreshTick(v => v + 1) - const balanceTimer = setInterval(pollBalance, BALANCE_POLL_MS) - onCleanup(() => { clearTimeout(partTimer); clearInterval(balanceTimer); unsubPart(); unsubMsg(); unsubSession() }) + onCleanup(() => { clearTimeout(partTimer); unsubPart(); unsubMsg(); unsubSession() }) }) // ── colours ── @@ -1107,8 +1064,9 @@ function TokenCachePanel(props: { {justify(t().write, fmt(data().write), t().tok)} + {/* 未命中 = 新鲜输入 + 缓存写(两者都未从缓存命中) */} - {justify(t().miss, fmt(data().freshInput), t().tok)} + {justify(t().miss, fmt(data().freshInput + data().write), t().tok)} {justify(t().out, fmt(data().output), t().tok)} @@ -1299,7 +1257,7 @@ function BottomStatusBar(props: { api: TuiPluginApi; signals: PanelSignals; sess : undefined let input = session?.tokens?.input ?? 0 let read = session?.tokens?.cache?.read ?? 0 - let output = session?.tokens?.output ?? 0 + let write = session?.tokens?.cache?.write ?? 0 // 旧 SDK 无 session 聚合字段 → 遍历消息累加(与侧边栏 fallback 一致) if (session?.tokens == null) { for (const m of msgs) { @@ -1308,17 +1266,18 @@ function BottomStatusBar(props: { api: TuiPluginApi; signals: PanelSignals; sess if (!tk) continue input += num(tk.input) read += num(tk.cache?.read) - output += num(tk.output) + write += num(tk.cache?.write) } } // 从后往前取最后两条有 token 数据的 assistant 消息 → 单条命中率 + 趋势 + // 分母含缓存写(业界口径:read / (input+read+write)) let hitRate = -1, prevHitRate = -1 for (let i = msgs.length - 1; i >= 0; i--) { const m = msgs[i] if (m.role !== "assistant") continue const tk = (m as AssistantMessage).tokens if (!tk) continue - const mit = num(tk.input) + num(tk.cache?.read) + const mit = num(tk.input) + num(tk.cache?.read) + num(tk.cache?.write) const mrt = num(tk.cache?.read) if (mit <= 0) continue const rate = (mrt / mit) * 100 @@ -1326,46 +1285,10 @@ function BottomStatusBar(props: { api: TuiPluginApi; signals: PanelSignals; sess prevHitRate = rate break } - return { hitRate, prevHitRate, input, read, output } + return { hitRate, prevHitRate, input, read, write } }) - // ── 余额查询(独立轮询,共享 provider/key 逻辑)── - const [balanceState, setBalanceState] = createSignal({ - status: "idle", data: null, lastFetch: 0, - }) - let balanceSeq = 0 - - const pollBalance = async () => { - const provider = getBalanceProvider(props.signals.balanceProviderId()) - const key = props.api.kv.get(`${KV_PREFIX}.balance.${provider.id}.key`, "") - || findOpencodeKey(props.api, provider) - if (props.signals.balanceUnsupported()) { setBalanceState({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined }); return } - if (!key) { setBalanceState({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined }); return } - const now = Date.now() - const prev = balanceState() - if (prev.status === "ok" && prev.key === key && now - prev.lastFetch < BALANCE_POLL_MS) return - const seq = ++balanceSeq - setBalanceState({ ...prev, status: "loading", error: undefined, key }) - const controller = new AbortController() - let timedOut = false - const timer = setTimeout(() => { timedOut = true; controller.abort() }, 10_000) - try { - const data = await provider.fetchBalance(key, controller.signal) - clearTimeout(timer) - if (seq !== balanceSeq) return - setBalanceState({ status: "ok", data, lastFetch: Date.now(), error: undefined, key }) - } catch (err) { - clearTimeout(timer) - if (seq !== balanceSeq) return - const code = timedOut ? "TIMEOUT" : (err instanceof Error ? err.message : "") - setBalanceState({ status: "error", data: null, lastFetch: 0, error: code, key }) - } - } - - createEffect(() => { - void props.signals.balanceRefresh() - untrack(() => { void pollBalance() }) - }) + // 余额查询状态为共享信号(PanelSignals.balanceState),由 tui() 统一轮询 // 自动切换 provider(跟随当前会话模型;幂等,与侧边栏共享信号) createEffect(() => { @@ -1424,7 +1347,7 @@ function BottomStatusBar(props: { api: TuiPluginApi; signals: PanelSignals; sess }) const balanceText = createMemo(() => { - const s = balanceState() + const s = props.signals.balanceState() if (s.status === "ok" && s.data) return formatBalanceText(s.data, props.signals.balanceCurrency(), props.signals.exchangeRate()) if (s.status === "loading") return "\u2026" if (s.status === "error") return "\u26a0" @@ -1459,7 +1382,7 @@ function BottomStatusBar(props: { api: TuiPluginApi; signals: PanelSignals; sess {" \u00b7 " + t().barTok + " "} - {stats() ? fmtCompact(stats()!.input + stats()!.read + stats()!.output) : "--"} + {stats() ? fmtCompact(stats()!.input + stats()!.read + stats()!.write) : "--"} {" \u00b7 " + t().barBal + " "} @@ -1519,6 +1442,14 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { const [langZH, setLangZH] = createSignal(LANG_ZH) const [overrideSessionId, setOverrideSessionId] = createSignal(undefined) + // ── 余额查询状态(共享):侧边栏与底部栏读同一份数据, + // 避免重复请求导致两处余额不一致 ── + const [balanceState, setBalanceState] = createSignal({ + status: "idle", data: null, lastFetch: 0, + }) + // 请求序号:防止定时轮询与手动刷新并发时,慢的旧请求覆盖新结果 + let balanceSeq = 0 + const signals: PanelSignals = { currencySymbol, setCurrencySymbol, exchangeRate, setExchangeRate, @@ -1533,6 +1464,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { balanceProviderId, setBalanceProviderId, autoBalance, setAutoBalance, balanceUnsupported, setBalanceUnsupported, + balanceState, balanceCurrency, setBalanceCurrency, borderVisible, setBorderVisible, overrideSessionId, setOverrideSessionId, @@ -1573,6 +1505,49 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { // ── slash commands for runtime config ── const KV_PREFIX = "cache_panel" + const pollBalance = async () => { + const provider = getBalanceProvider(balanceProviderId()) + // 手动配置的 key 优先;缺失时自动复用 OpenCode 已认证的 key(auth.json / config) + const key = api.kv.get(`${KV_PREFIX}.balance.${provider.id}.key`, "") + || findOpencodeKey(api, provider) + if (balanceUnsupported()) { setBalanceState({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined }); return } + if (!key) { setBalanceState({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined }); return } + const now = Date.now() + const prev = balanceState() + // key 已更换(重新输入)→ 强制重新查询,绕过缓存 + if (prev.status === "ok" && prev.key === key && now - prev.lastFetch < BALANCE_POLL_MS) return // cache still fresh + const seq = ++balanceSeq + setBalanceState({ ...prev, status: "loading", error: undefined, key }) + const controller = new AbortController() + let timedOut = false + const timer = setTimeout(() => { timedOut = true; controller.abort() }, 10_000) + try { + const data = await provider.fetchBalance(key, controller.signal) + clearTimeout(timer) + if (seq !== balanceSeq) return // 已被更新的请求取代,丢弃过期结果 + setBalanceState({ status: "ok", data, lastFetch: Date.now(), error: undefined, key }) + } catch (err) { + clearTimeout(timer) + if (seq !== balanceSeq) return + const code = timedOut ? "TIMEOUT" : (err instanceof Error ? err.message : "") + // 失败时清空旧数据,避免显示过期余额 + setBalanceState({ status: "error", data: null, lastFetch: 0, error: code, key }) + } + } + + // Re-fetch when the API key is (re)configured via /cache-balance-key. + // 注意:pollBalance 内部读写 balanceState 信号,若不做 untrack 包裹, + // effect 会追踪 balanceState 的变化并与 pollBalance 的 setBalanceState + // 形成无限循环(每次重跑都发起新的 fetch 请求)。 + createEffect(() => { + void balanceRefresh() + untrack(() => { void pollBalance() }) + }) + + // 定时轮询(5 分钟);随插件生命周期清理 + const balanceTimer = setInterval(pollBalance, BALANCE_POLL_MS) + api.lifecycle.onDispose(() => clearInterval(balanceTimer)) + /** 菜单中 provider 选项标题:标注 key 来源(手动配置 / OpenCode 自动复用 / 未配置)。 */ const providerOptionTitle = (p: BalanceProvider, current?: string) => { const zh = langZH() From bd494205e7c5ae80e22008e6fd2838ba98420388 Mon Sep 17 00:00:00 2001 From: Hotakus Date: Tue, 11 Aug 2026 02:42:08 +0800 Subject: [PATCH 07/13] =?UTF-8?q?fix(balance):=20=E5=88=86=E5=B8=83?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E6=80=BB=E8=BE=93=E5=85=A5=E8=A1=A5=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E5=86=99=EF=BC=8C=E7=BB=9F=E4=B8=80=20total=20input?= =?UTF-8?q?=20=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 82cefa3..97c737c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -354,7 +354,7 @@ interface TokenDist { toolResult: number // ToolPart completed output / error output: number // AssistantMessage.tokens.output (fallback) apiOutput: number // StepFinishPart.tokens.output (API exact, preferred) - apiInput: number // StepFinishPart.tokens.input (API exact total context) + apiInput: number // API exact total input context (input + cache read + cache write) stepCost: number } @@ -746,10 +746,10 @@ function TokenCachePanel(props: { for (let i = msgs.length - 1; i >= 0; i--) { if (msgs[i].role !== "assistant") continue const t = (msgs[i] as AssistantMessage).tokens - if (t && (t.input > 0 || (t.cache?.read ?? 0) > 0)) { lastAssMsg = msgs[i] as AssistantMessage; break } + if (t && ((t.input ?? 0) > 0 || (t.cache?.read ?? 0) > 0 || (t.cache?.write ?? 0) > 0)) { lastAssMsg = msgs[i] as AssistantMessage; break } } - // 取最后一条有数据消息的总输入(含缓存读)作为当前 context 大小 - dist.apiInput = num(lastAssMsg?.tokens?.input) + num(lastAssMsg?.tokens?.cache?.read) + // 取最后一条有数据消息的总输入(含缓存读/写)作为当前 context 大小 + dist.apiInput = num(lastAssMsg?.tokens?.input) + num(lastAssMsg?.tokens?.cache?.read) + num(lastAssMsg?.tokens?.cache?.write) dist.apiOutput = num(lastAssMsg?.tokens?.output) hasDistData = dist.system + dist.user + dist.agent + dist.toolCall + dist.toolResult > 0 || dist.apiOutput > 0 || dist.apiInput > 0 } catch {} From 70ae7362415661f8af19456b26c16925b54f5829 Mon Sep 17 00:00:00 2001 From: Hotakus Date: Tue, 11 Aug 2026 03:11:40 +0800 Subject: [PATCH 08/13] =?UTF-8?q?refactor(i18n):=20toast=20=E4=B8=8E=20sec?= =?UTF-8?q?tion=20=E9=9D=A2=E6=9D=BF=E6=96=87=E6=A1=88=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E8=87=B3=20i18n=20=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.tsx | 114 +++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 90 insertions(+), 24 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 97c737c..eadf908 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -128,6 +128,29 @@ const ZH_T = { barHit: "命中率", barBal: "余额", barTok: "Tokens", + // ── /cache-section 面板 ── + secToggle: "切换区块", + secBalance: "余额", + secBottom: "底部状态栏", + secBorder: "面板边框", + // ── toast ── + keySaved: "API Key 已保存,正在查询余额...", + keyCleared: "API Key 已清除", + currencySet: "币种: {v} ({s}), 汇率: {r}", + rateSet: "汇率已设为 {r}", + panelConfigTitle: "缓存面板配置", + panelConfigMsg: "币种: {c} | 汇率: {r} | 明细: {d} | 模型: {m} | 分布: {t} | 技能: {k} | 余额: {b} | 底部: {f}", + borderShown: "面板边框 已显示", + borderHidden: "面板边框 已隐藏", + sectionShown: "{s} 已显示", + sectionHidden: "{s} 已隐藏", + langSwitched: "语言已切换为中文", + autoSwitchOn: "自动切换余额提供商: 开", + autoSwitchOff: "自动切换余额提供商: 关", + providerManual: "余额提供商: {p}(自动切换已关闭)", + runInSession: "请在会话内运行此命令", + backToMain: "已切回主会话", + subAgentSwitched: "已切换至子代理: {s}", } as const const EN_T = { @@ -172,6 +195,29 @@ const EN_T = { barHit: "Hit", barBal: "Balance", barTok: "Tokens", + // ── /cache-section panel ── + secToggle: "Toggle Section", + secBalance: "Balance", + secBottom: "Bottom Bar", + secBorder: "Panel Border", + // ── toasts ── + keySaved: "API Key saved, fetching balance...", + keyCleared: "API Key cleared", + currencySet: "Currency: {v} ({s}), rate: {r}", + rateSet: "Exchange rate set to {r}", + panelConfigTitle: "Cache Panel Config", + panelConfigMsg: "Currency: {c} | Rate: {r} | Detail: {d} | Model: {m} | Dist: {t} | Skills: {k} | Balance: {b} | Bottom: {f}", + borderShown: "Panel border shown", + borderHidden: "Panel border hidden", + sectionShown: "{s} section shown", + sectionHidden: "{s} section hidden", + langSwitched: "Switched to English", + autoSwitchOn: "Auto-switch balance provider: ON", + autoSwitchOff: "Auto-switch balance provider: OFF", + providerManual: "Balance provider: {p} (auto-switch off)", + runInSession: "Please run this command inside a session", + backToMain: "Switched to main session", + subAgentSwitched: "Showing sub-agent: {s}", } as const // ── color helpers ──────────────────────────────────────────────── @@ -1564,6 +1610,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { /** 弹出指定 provider 的 API Key 输入框(脱敏预填;空清除 / 含 * 保留原 key / 新 key 实时刷新)。 */ const promptBalanceKey = (dialog: TuiDialogStack | undefined, provider: BalanceProvider) => { const zh = langZH() + const T = zh ? ZH_T : EN_T const current = api.kv.get(`${KV_PREFIX}.balance.${provider.id}.key`, "") const masked = maskKey(current) dialog?.replace(() => ( @@ -1585,9 +1632,9 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { api.kv.set(`${KV_PREFIX}.balance.${provider.id}.key`, key) setBalanceRefresh(v => v + 1) if (key) { - api.ui.toast({ message: zh ? "API Key 已保存,正在查询余额..." : "API Key saved, fetching balance..." }) + api.ui.toast({ message: T.keySaved }) } else { - api.ui.toast({ message: zh ? "API Key 已清除" : "API Key cleared" }) + api.ui.toast({ message: T.keyCleared }) } dialog?.clear() }} @@ -1620,7 +1667,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { signals.setBalanceCurrency(opt.value) signals.setCurrencySymbol(sym) signals.setExchangeRate(defRate) - api.ui.toast({ message: `Currency: ${opt.value} (${sym}), rate: ${defRate}` }) + api.ui.toast({ message: (langZH() ? ZH_T : EN_T).currencySet.replace("{v}", opt.value).replace("{s}", sym).replace("{r}", String(defRate)) }) dialog?.clear() }} /> @@ -1644,7 +1691,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { if (n > 0) { api.kv.set(`${KV_PREFIX}.rate`, n) signals.setExchangeRate(n) - api.ui.toast({ message: `Exchange rate set to ${n}` }) + api.ui.toast({ message: (langZH() ? ZH_T : EN_T).rateSet.replace("{r}", String(n)) }) } dialog?.clear() }} @@ -1658,6 +1705,8 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { description: "Show or hide a sidebar section", slash: { name: "cache-section" }, onSelect: (dialog) => { + const zh = langZH() + const T = zh ? ZH_T : EN_T const detailOn = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true)) const modelOn = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true)) const distOn = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true)) @@ -1665,24 +1714,34 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { const balanceOn = Boolean(api.kv.get(`${KV_PREFIX}.section.balance`, true)) const bottomOn = Boolean(api.kv.get(`${KV_PREFIX}.section.bottom`, true)) const borderOn = Boolean(api.kv.get(`${KV_PREFIX}.border`, true)) + const labels: Record = { + detail: T.secDetail, + model: T.secModel, + dist: T.distTitle, + skills: T.secSkills, + balance: T.secBalance, + bottom: T.secBottom, + border: T.secBorder, + } + const optTitle = (label: string, on: boolean) => `${visualPadEnd(label, 15)}[${on ? "ON" : "OFF"}]` dialog?.replace(() => ( { if (opt.value === "border") { const cur = Boolean(api.kv.get(`${KV_PREFIX}.border`, true)) api.kv.set(`${KV_PREFIX}.border`, !cur) signals.setBorderVisible(!cur) - api.ui.toast({ message: `Panel border ${!cur ? "shown" : "hidden"}` }) + api.ui.toast({ message: !cur ? T.borderShown : T.borderHidden }) } else { const key = `${KV_PREFIX}.section.${opt.value}` const cur = Boolean(api.kv.get(key, true)) @@ -1693,7 +1752,8 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { if (opt.value === "skills") signals.setSectionSkills(!cur) if (opt.value === "balance") signals.setSectionBalance(!cur) if (opt.value === "bottom") signals.setSectionBottom(!cur) - api.ui.toast({ message: `${opt.value} section ${!cur ? "shown" : "hidden"}` }) + const name = labels[opt.value] ?? opt.value + api.ui.toast({ message: (!cur ? T.sectionShown : T.sectionHidden).replace("{s}", name) }) } dialog?.clear() }} @@ -1707,6 +1767,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { description: "Display the current plugin configuration", slash: { name: "cache-config" }, onSelect: (dialog) => { + const T = langZH() ? ZH_T : EN_T const sym = api.kv.get(`${KV_PREFIX}.currency`) ?? "$" const rate = api.kv.get(`${KV_PREFIX}.rate`) ?? 1 const detail = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true)) @@ -1715,9 +1776,14 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { const skills = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true)) const balance = Boolean(api.kv.get(`${KV_PREFIX}.section.balance`, true)) const bottom = Boolean(api.kv.get(`${KV_PREFIX}.section.bottom`, true)) + const on = (v: boolean) => v ? "ON" : "OFF" api.ui.toast({ - title: "Cache Panel Config", - message: `Currency: ${sym} | Rate: ${rate} | Detail: ${detail ? "ON" : "OFF"} | Model: ${model ? "ON" : "OFF"} | Dist: ${dist ? "ON" : "OFF"} | Skills: ${skills ? "ON" : "OFF"} | Balance: ${balance ? "ON" : "OFF"} | Bottom: ${bottom ? "ON" : "OFF"}`, + title: T.panelConfigTitle, + message: T.panelConfigMsg + .replace("{c}", sym).replace("{r}", String(rate)) + .replace("{d}", on(detail)).replace("{m}", on(model)) + .replace("{t}", on(dist)).replace("{k}", on(skills)) + .replace("{b}", on(balance)).replace("{f}", on(bottom)), duration: 8000, }) dialog?.clear() @@ -1741,7 +1807,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { const zh = opt.value === "zh" api.kv.set(`${KV_PREFIX}.lang`, opt.value) setLangZH(zh) - api.ui.toast({ message: zh ? "语言已切换为中文" : "Switched to English" }) + api.ui.toast({ message: zh ? ZH_T.langSwitched : EN_T.langSwitched }) dialog?.clear() }} /> @@ -1778,7 +1844,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { const next = !auto api.kv.set(`${KV_PREFIX}.balance.auto`, next) signals.setAutoBalance(next) - api.ui.toast({ message: zh ? `自动切换余额提供商: ${next ? "开" : "关"}` : `Auto-switch balance provider: ${next ? "ON" : "OFF"}` }) + api.ui.toast({ message: next ? (zh ? ZH_T.autoSwitchOn : EN_T.autoSwitchOn) : (zh ? ZH_T.autoSwitchOff : EN_T.autoSwitchOff) }) dialog?.clear() } else { const provider = getBalanceProvider(opt.value) @@ -1795,7 +1861,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { // 未配置 key → 进入设置流程(对话框保持打开等待输入) promptBalanceKey(dialog, provider) } else { - api.ui.toast({ message: zh ? `余额提供商: ${provider.name}(自动切换已关闭)` : `Balance provider: ${provider.name} (auto-switch off)` }) + api.ui.toast({ message: (zh ? ZH_T : EN_T).providerManual.replace("{p}", provider.name) }) dialog?.clear() } } @@ -1843,7 +1909,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { onSelect: () => { const rt = api.route.current if (rt.name !== "session" || !rt.params) { - api.ui.toast({ message: "Please run this command inside a session", variant: "warning" }) + api.ui.toast({ message: (langZH() ? ZH_T : EN_T).runInSession, variant: "warning" }) return } const sid = String(rt.params.sessionID) @@ -1943,11 +2009,11 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { if (opt.value === backValue) { signals.setOverrideSessionId(undefined) api.kv.set(`${KV_PREFIX}.session`, "") - api.ui.toast({ message: zh ? "已切回主会话" : "Switched to main session" }) + api.ui.toast({ message: (zh ? ZH_T : EN_T).backToMain }) } else { signals.setOverrideSessionId(opt.value) api.kv.set(`${KV_PREFIX}.session`, opt.value) - api.ui.toast({ message: (zh ? "已切换至子代理: " : "Showing sub-agent: ") + opt.value.slice(0, 24) + "\u2026" }) + api.ui.toast({ message: (zh ? ZH_T : EN_T).subAgentSwitched.replace("{s}", opt.value.slice(0, 24) + "\u2026") }) } dialog?.clear() }} @@ -1967,7 +2033,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { if (sid) { signals.setOverrideSessionId(sid) api.kv.set(`${KV_PREFIX}.session`, sid) - api.ui.toast({ message: (langZH() ? "已切换至子代理: " : "Showing sub-agent: ") + sid.slice(0, 24) + "\u2026" }) + api.ui.toast({ message: (langZH() ? ZH_T : EN_T).subAgentSwitched.replace("{s}", sid.slice(0, 24) + "\u2026") }) } dialog?.clear() }} @@ -1985,7 +2051,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { onSelect: (dialog) => { signals.setOverrideSessionId(undefined) api.kv.set(`${KV_PREFIX}.session`, "") - api.ui.toast({ message: langZH() ? "已切回主会话" : "Switched to main session" }) + api.ui.toast({ message: (langZH() ? ZH_T : EN_T).backToMain }) dialog?.clear() }, }, From a4e04196c63497b0429c4647d1b1f871c504f9ac Mon Sep 17 00:00:00 2001 From: Hotakus Date: Tue, 11 Aug 2026 03:14:53 +0800 Subject: [PATCH 09/13] =?UTF-8?q?docs:=20=E8=A1=A5=E5=85=85=E5=BA=95?= =?UTF-8?q?=E9=83=A8=E7=8A=B6=E6=80=81=E6=A0=8F=E4=B8=8E=E4=BD=99=E9=A2=9D?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E5=8F=A3=E5=BE=84=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 +++++++- README_EN.md | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f8dd350..30c96f9 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ - **斜杠命令**:`/cache-session` `/cache-session-back` `/cache-rate` `/cache-section` `/cache-config` `/cache-lang` 动态配置面板 - **子代理缓存查看**:`/cache-session` 自动扫描并列出子代理,选择一个即可切换面板显示其缓存统计,支持 `/cache-session-back` 返回主会话 - **已加载技能**:检测 session 中 LLM 调用 `skill` tool 的记录,展示已加载技能名及估算 Token 占用 +- **底部状态栏**:输入框提示行单行显示 命中率(含趋势)· Tokens · 余额,关闭侧边栏也能随时看到缓存统计,可经 `/cache-section` 隐藏 --- @@ -106,7 +107,7 @@ npm install -g opencode-visual-cache@latest | `/cache-session-back` | 返回主会话统计 | 从子代理缓存视图切回主会话 | | `/cache-currency` | 切换货币单位 | 从列表选择货币(USD / CNY / EUR / JPY / GBP / KRW),自动填入默认汇率 | | `/cache-rate` | 调整汇率乘数 | 输入自定义汇率(如 `7.2`),用于费用换算 | -| `/cache-section` | 开关区块与边框 | 独立控制 Token 明细 / 模型与定价 / 估算 Token 分布 / 已加载技能 / 余额 / 面板边框的显隐 | +| `/cache-section` | 开关区块与边框 | 独立控制 Token 明细 / 模型与定价 / 估算 Token 分布 / 已加载技能 / 余额 / 底部状态栏 / 面板边框的显隐 | | `/cache-config` | 查看当前配置 | 弹出当前货币、汇率、区块可见性状态 | | `/cache-lang` | 切换显示语言 | 从列表选择中文或 English,界面即时切换,无需重启 | | `/cache-balance` | 余额查询设置 | 选择余额提供商(菜单标注 Key 来源:用户 key / OpenCode / 未配置)/ 开关自动切换 | @@ -145,6 +146,7 @@ npm install -g opencode-visual-cache@latest - **估算 Token 分布**:按角色拆分的 Token 估算 - **已加载技能**:session 中 LLM 实际调用过的 Skill 名及估算 Token 占用 - **余额**:当前提供商账户余额(多提供商 + 自动切换) +- **底部状态栏**:输入框提示行的 命中率 · Tokens · 余额 单行统计 通过 `/cache-section` 切换后即时生效,无需重启。此外,该命令还可以开关面板的**外边框**——关闭后内容会顶格显示,释放额外空间。 @@ -172,6 +174,10 @@ npm install -g opencode-visual-cache@latest > **自动切换**:默认开启;手动选择提供商后自动关闭,可在 `/cache-balance` 中重新开启。自动切换按当前会话的模型提供商匹配,未配置 Key 的提供商被选中时显示「未配置」提示。 > > **希望支持**:已调研确认具备可行性的候选提供商,尚未实现。智谱 GLM 仅有社区逆向的非官方端点(无稳定性保障)。 +> +> **统计口径**:命中率 = 缓存读 /(新鲜输入 + 缓存读 + 缓存写),与业界(OpenAI / Anthropic / Bedrock)口径一致;明细中「未命中」= 新鲜输入 + 缓存写。底部栏的 Tokens 为输入侧总量(不含输出)。未单独报告缓存写的提供商(如 DeepSeek)自动退化为 hit/miss 口径。 +> +> **余额显示**:侧边栏与底部栏共享同一份余额数据,两处显示一致。当前提供商不支持余额查询时,侧边栏显示提示、底部栏隐藏余额项。 --- diff --git a/README_EN.md b/README_EN.md index d339cb4..c86a908 100644 --- a/README_EN.md +++ b/README_EN.md @@ -54,6 +54,7 @@ Interested in sub-agent monitoring? Check out [opencode-subagent-magazine](https - **Slash Commands**: `/cache-session` `/cache-session-back` `/cache-rate` `/cache-section` `/cache-config` `/cache-lang` for live panel configuration - **Sub-Agent Cache View**: `/cache-session` auto-scans and lists sub-agents; select one to switch the panel stats. Use `/cache-session-back` to return to the main session - **Loaded Skills**: Detects `skill` tool calls in the session and displays loaded skill names with estimated token footprint +- **Bottom Status Bar**: single-line hit rate (with trend) · Tokens · Balance in the prompt hint row — visible even with the sidebar closed; hide it anytime via `/cache-section` --- @@ -106,7 +107,7 @@ The plugin supports slash commands and command palette (`Ctrl + P`) for runtime | `/cache-session-back` | Return to main session | Switch back to main session from sub-agent cache view | | `/cache-currency` | Switch currency | Pick from a list (USD / CNY / EUR / JPY / GBP / KRW); default exchange rate auto-filled | | `/cache-rate` | Adjust exchange rate | Enter a custom rate (e.g. `7.2` for CNY) | -| `/cache-section` | Toggle sections & border | Independently show/hide Detail, Model & Pricing, Token Distribution, Loaded Skills, Balance, or the panel border | +| `/cache-section` | Toggle sections & border | Independently show/hide Detail, Model & Pricing, Token Distribution, Loaded Skills, Balance, Bottom Bar, or the panel border | | `/cache-config` | View current config | Displays currency, rate, and section visibility | | `/cache-lang` | Switch display language | Pick Chinese or English from the dialog — takes effect immediately, no restart needed | | `/cache-balance` | Balance query settings | Pick a balance provider (menu shows key source: user key / OpenCode / not set) / toggle auto-switch | @@ -145,6 +146,7 @@ Three sub-sections can be toggled independently to save sidebar space: - **Estimated Token Dist.**: per-role token breakdown - **Loaded Skills**: skill names the LLM actually loaded via the `skill` tool, with estimated token counts - **Balance**: the selected provider's account balance (multi-provider with auto-switch) +- **Bottom Status Bar**: the single-line hit rate · Tokens · Balance stats in the prompt hint row Toggled via `/cache-section` — takes effect instantly, no restart required. The same command also toggles the panel **border**; turning it off removes the outline and padding so content fills the full width. @@ -170,6 +172,10 @@ Supported balance providers: > **Auto-switch**: enabled by default; picking a provider manually disables it — re-enable anytime via `/cache-balance`. Auto-switch matches the current session's model provider; a provider without a key shows a "not set" hint when selected. > > **Planned**: candidates confirmed feasible by research, not yet implemented. Zhipu GLM only has a community-reversed unofficial endpoint (no stability guarantee). +> +> **Metric semantics**: hit rate = cache read / (fresh input + cache read + cache write), consistent with the industry (OpenAI / Anthropic / Bedrock). "Miss" in the detail view = fresh input + cache write. The bottom-bar Tokens is the input-side total (output excluded). Providers that do not report cache writes separately (e.g. DeepSeek) automatically fall back to the hit/miss formula. +> +> **Balance display**: the sidebar and bottom bar share the same balance data, so both show identical values. When the current provider has no balance adapter, the sidebar shows a hint and the bottom bar hides the balance segment. --- From bf858c3aa20d78eb55ec6bf3a7fbb8da99aa157f Mon Sep 17 00:00:00 2001 From: Hotakus Date: Tue, 11 Aug 2026 03:52:53 +0800 Subject: [PATCH 10/13] =?UTF-8?q?refactor(i18n):=20=E7=BF=BB=E8=AF=91?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E4=B8=BA=20t()=20=E6=9F=A5=E6=89=BE=E5=B9=B6?= =?UTF-8?q?=E6=8F=90=E5=8F=96=E7=8B=AC=E7=AB=8B=20i18n=20=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/i18n.ts | 211 ++++++++++++++++++++++++++ src/index.tsx | 399 +++++++++++++++++--------------------------------- 2 files changed, 342 insertions(+), 268 deletions(-) create mode 100644 src/i18n.ts diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 0000000..2a7a270 --- /dev/null +++ b/src/i18n.ts @@ -0,0 +1,211 @@ +// --------------------------------------------------------------------------- +// i18n — centralized translations. Add a language by appending a table that +// satisfies `Translation`; the compiler enforces key completeness. +// --------------------------------------------------------------------------- + +export type LangCode = "zh" | "en" + +const ZH_T = { + title: "缓存统计", + hit: "命中率", + totalHit: "总命中:", + read: "缓存读:", + write: "缓存写:", + miss: "未命中:", + out: "输出:", + cost: "费用:", + saved: "累计节省:", + model: "模型:", + provider: "提供商:", + rate: "单价:", + hitFolded: "命中", + inputRate: "输入", + cacheRate: "缓存", + writeRate: "写入", + noData: "等待缓存数据...", + tok: "tok", + distTitle: "估算 Token 分布", + distSys: "系统提示:", + distUser: "用户:", + distAgent: "Agent 指令:", + distTool: "Tool 调用:", + distRes: "Tool 结果:", + distTotal: "总计:", + distOut: "输出:", + secDetail: "明细", + secModel: "模型", + secSkills: "已加载技能", + balTotal: "总余额:", + balNoKey: "未配置 {p} API Key", + balLoading: "查询中...", + balError: "查询失败", + balErr401: "API Key 无效", + balErr403: "余额查询被拒绝", + balErrEmpty:"未获取到余额数据", + balErrTimeout: "查询超时", + balUnsupported: "当前提供商不支持余额查询", + barHit: "命中率", + barBal: "余额", + barTok: "Tokens", + // ── /cache-section 面板 ── + secToggle: "切换区块", + secBalance: "余额", + secBottom: "底部状态栏", + secBorder: "面板边框", + // ── toast ── + keySaved: "API Key 已保存,正在查询余额...", + keyCleared: "API Key 已清除", + currencySet: "币种: {v} ({s}), 汇率: {r}", + rateSet: "汇率已设为 {r}", + panelConfigTitle: "缓存面板配置", + panelConfigMsg: "币种: {c} | 汇率: {r} | 明细: {d} | 模型: {m} | 分布: {t} | 技能: {k} | 余额: {b} | 底部: {f}", + borderShown: "面板边框 已显示", + borderHidden: "面板边框 已隐藏", + sectionShown: "{s} 已显示", + sectionHidden: "{s} 已隐藏", + langSwitched: "语言已切换为中文", + autoSwitchOn: "自动切换余额提供商: 开", + autoSwitchOff: "自动切换余额提供商: 关", + providerManual: "余额提供商: {p}(自动切换已关闭)", + runInSession: "请在会话内运行此命令", + backToMain: "已切回主会话", + subAgentSwitched: "已切换至子代理: {s}", + // ── 对话框 / 菜单 ── + langTitle: "显示语言", + subPrefix: "子代理: ", + keyUser: "(用户 key)", + keyOpenCode: "(OpenCode)", + keyNotSet: "(未配置)", + balKeyPrompt: "输入 {p} API Key 以显示账户余额(留空清除)", + balProvTitle: "余额提供商 / 自动切换", + autoSwitchOpt: "自动切换提供商", + balSelectTitle: "选择余额提供商", + backToMainTitle: "回到主会话", + subSelectTitle: "选择子代理", + subSwitchTitle: "切换子代理", + subViewTitle: "查看子代理缓存", + subNoFound: "未找到子代理,请手动粘贴 Session ID", +} as const + +/** 结构约束:值放宽为 string,键集合来自中文表(新增语言缺 key 会编译报错)。 */ +export type Translation = { [K in keyof typeof ZH_T]: string } + +const EN_T: Translation = { + title: "Token Cache", + hit: "Hit", + totalHit: "Total Hit:", + read: "Read:", + write: "Write:", + miss: "Miss:", + out: "Out:", + cost: "Cost:", + saved: "Total Saved:", + model: "Model:", + provider: "Provider:", + rate: "Rate:", + hitFolded: "hit", + inputRate: "in", + cacheRate: "cache", + writeRate: "write", + noData: "Waiting for cache data...", + tok: "tok", + distTitle: "Estimated Token Dist.", + distSys: "System:", + distUser: "User:", + distAgent: "Agent Instr:", + distTool: "Tool Call:", + distRes: "Tool Result:", + distTotal: "Total:", + distOut: "Output:", + secDetail: "Detail", + secModel: "Model", + secSkills: "Loaded Skills", + balTotal: "Total:", + balNoKey: "{p} API Key not set", + balLoading: "Fetching...", + balError: "Fetch failed", + balErr401: "Invalid API Key", + balErr403: "Balance request rejected", + balErrEmpty:"No balance data", + balErrTimeout: "Request timed out", + balUnsupported: "Balance query unsupported", + barHit: "Hit", + barBal: "Balance", + barTok: "Tokens", + // ── /cache-section panel ── + secToggle: "Toggle Section", + secBalance: "Balance", + secBottom: "Bottom Bar", + secBorder: "Panel Border", + // ── toasts ── + keySaved: "API Key saved, fetching balance...", + keyCleared: "API Key cleared", + currencySet: "Currency: {v} ({s}), rate: {r}", + rateSet: "Exchange rate set to {r}", + panelConfigTitle: "Cache Panel Config", + panelConfigMsg: "Currency: {c} | Rate: {r} | Detail: {d} | Model: {m} | Dist: {t} | Skills: {k} | Balance: {b} | Bottom: {f}", + borderShown: "Panel border shown", + borderHidden: "Panel border hidden", + sectionShown: "{s} section shown", + sectionHidden: "{s} section hidden", + langSwitched: "Switched to English", + autoSwitchOn: "Auto-switch balance provider: ON", + autoSwitchOff: "Auto-switch balance provider: OFF", + providerManual: "Balance provider: {p} (auto-switch off)", + runInSession: "Please run this command inside a session", + backToMain: "Switched to main session", + subAgentSwitched: "Showing sub-agent: {s}", + // ── dialogs / menus ── + langTitle: "Display Language", + subPrefix: "Sub: ", + keyUser: " (user key)", + keyOpenCode: " (OpenCode)", + keyNotSet: " (not set)", + balKeyPrompt: "Enter your {p} API key to show account balance (leave empty to clear)", + balProvTitle: "Balance Provider / Auto-switch", + autoSwitchOpt: "Auto-switch provider", + balSelectTitle: "Select Balance Provider", + backToMainTitle: "Back to Main", + subSelectTitle: "Select Sub-Agent", + subSwitchTitle: "Switch Sub", + subViewTitle: "View Sub Cache", + subNoFound: "No sub-agents found. Paste a Session ID manually", +} + +export const LANGS: Record = { zh: ZH_T, en: EN_T } + +/** 语言元数据:/cache-lang 选项与自动检测共用。 */ +export const LANG_META: { code: LangCode; label: string }[] = [ + { code: "zh", label: "中文" }, + { code: "en", label: "English" }, +] + +/** + * 模板参数替换:`{key}` 占位符统一在此处理。 + * 未提供的参数保留原占位符,避免静默丢失。 + */ +export function applyParams(tpl: string, params?: Record): string { + if (!params) return tpl + return tpl.replace(/\{(\w+)\}/g, (m, k: string) => + k in params ? String(params[k]) : m, + ) +} + +/** + * 翻译查找函数工厂:`t("key")` 返回当前语言的文本,`t("key", { p })` 附带模板参数。 + * `getCode` 读取语言信号——在 SolidJS 渲染/memo 上下文中调用时自动建立响应式依赖。 + */ +export function createT(getCode: () => LangCode) { + return (key: keyof Translation, params?: Record): string => + applyParams(LANGS[getCode()][key], params) +} + +/** 按系统 locale 自动检测语言(zh 前缀 → 中文,其余 → 英文)。 */ +export function detectLang(): LangCode { + try { + const loc = Intl.DateTimeFormat().resolvedOptions().locale.toLowerCase() + return loc.startsWith("zh") ? "zh" : "en" + } catch { + return "en" + } +} diff --git a/src/index.tsx b/src/index.tsx index eadf908..de34513 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -22,6 +22,7 @@ import type { import { createMemo, createSignal, createEffect, onMount, onCleanup, Show, untrack } from "solid-js" import { PLUGIN_VERSION } from "./_version" import { balanceProviders, getBalanceProvider, maskKey, matchBalanceProvider, type BalanceEntry, type BalanceProvider } from "./balance-providers" +import { LANG_META, createT, detectLang, type LangCode } from "./i18n" // --------------------------------------------------------------------------- // Helpers @@ -74,151 +75,11 @@ function truncateVisual(s: string, maxCols: number): string { return result } -// ── language override (env: CACHE_TUI_LANG) ── -const DEBUG_LANG = typeof process !== "undefined" ? process.env?.CACHE_TUI_LANG : undefined - // ── language ────────────────────────────────────────────────────── +// 语言初始化:环境变量 CACHE_TUI_LANG 覆盖 → 否则按系统 locale 自动检测 -const LANG_ZH = DEBUG_LANG - ? DEBUG_LANG === "zh" - : (() => { - try { return Intl.DateTimeFormat().resolvedOptions().locale.startsWith("zh") } - catch { return false } - })() - -const ZH_T = { - title: "缓存统计", - hit: "命中率", - totalHit: "总命中:", - read: "缓存读:", - write: "缓存写:", - miss: "未命中:", - out: "输出:", - cost: "费用:", - saved: "累计节省:", - model: "模型:", - provider: "提供商:", - rate: "单价:", - hitFolded: "命中", - inputRate: "输入", - cacheRate: "缓存", - writeRate: "写入", - noData: "等待缓存数据...", - tok: "tok", - distTitle: "估算 Token 分布", - distSys: "系统提示:", - distUser: "用户:", - distAgent: "Agent 指令:", - distTool: "Tool 调用:", - distRes: "Tool 结果:", - distTotal: "总计:", - distOut: "输出:", - secDetail: "明细", - secModel: "模型", - secSkills: "已加载技能", - balTotal: "总余额:", - balNoKey: "未配置 {p} API Key", - balLoading: "查询中...", - balError: "查询失败", - balErr401: "API Key 无效", - balErr403: "余额查询被拒绝", - balErrEmpty:"未获取到余额数据", - balErrTimeout: "查询超时", - balUnsupported: "当前提供商不支持余额查询", - barHit: "命中率", - barBal: "余额", - barTok: "Tokens", - // ── /cache-section 面板 ── - secToggle: "切换区块", - secBalance: "余额", - secBottom: "底部状态栏", - secBorder: "面板边框", - // ── toast ── - keySaved: "API Key 已保存,正在查询余额...", - keyCleared: "API Key 已清除", - currencySet: "币种: {v} ({s}), 汇率: {r}", - rateSet: "汇率已设为 {r}", - panelConfigTitle: "缓存面板配置", - panelConfigMsg: "币种: {c} | 汇率: {r} | 明细: {d} | 模型: {m} | 分布: {t} | 技能: {k} | 余额: {b} | 底部: {f}", - borderShown: "面板边框 已显示", - borderHidden: "面板边框 已隐藏", - sectionShown: "{s} 已显示", - sectionHidden: "{s} 已隐藏", - langSwitched: "语言已切换为中文", - autoSwitchOn: "自动切换余额提供商: 开", - autoSwitchOff: "自动切换余额提供商: 关", - providerManual: "余额提供商: {p}(自动切换已关闭)", - runInSession: "请在会话内运行此命令", - backToMain: "已切回主会话", - subAgentSwitched: "已切换至子代理: {s}", -} as const - -const EN_T = { - title: "Token Cache", - hit: "Hit", - totalHit: "Total Hit:", - read: "Read:", - write: "Write:", - miss: "Miss:", - out: "Out:", - cost: "Cost:", - saved: "Total Saved:", - model: "Model:", - provider: "Provider:", - rate: "Rate:", - hitFolded: "hit", - inputRate: "in", - cacheRate: "cache", - writeRate: "write", - noData: "Waiting for cache data...", - tok: "tok", - distTitle: "Estimated Token Dist.", - distSys: "System:", - distUser: "User:", - distAgent: "Agent Instr:", - distTool: "Tool Call:", - distRes: "Tool Result:", - distTotal: "Total:", - distOut: "Output:", - secDetail: "Detail", - secModel: "Model", - secSkills: "Loaded Skills", - balTotal: "Total:", - balNoKey: "{p} API Key not set", - balLoading: "Fetching...", - balError: "Fetch failed", - balErr401: "Invalid API Key", - balErr403: "Balance request rejected", - balErrEmpty:"No balance data", - balErrTimeout: "Request timed out", - balUnsupported: "Balance query unsupported", - barHit: "Hit", - barBal: "Balance", - barTok: "Tokens", - // ── /cache-section panel ── - secToggle: "Toggle Section", - secBalance: "Balance", - secBottom: "Bottom Bar", - secBorder: "Panel Border", - // ── toasts ── - keySaved: "API Key saved, fetching balance...", - keyCleared: "API Key cleared", - currencySet: "Currency: {v} ({s}), rate: {r}", - rateSet: "Exchange rate set to {r}", - panelConfigTitle: "Cache Panel Config", - panelConfigMsg: "Currency: {c} | Rate: {r} | Detail: {d} | Model: {m} | Dist: {t} | Skills: {k} | Balance: {b} | Bottom: {f}", - borderShown: "Panel border shown", - borderHidden: "Panel border hidden", - sectionShown: "{s} section shown", - sectionHidden: "{s} section hidden", - langSwitched: "Switched to English", - autoSwitchOn: "Auto-switch balance provider: ON", - autoSwitchOff: "Auto-switch balance provider: OFF", - providerManual: "Balance provider: {p} (auto-switch off)", - runInSession: "Please run this command inside a session", - backToMain: "Switched to main session", - subAgentSwitched: "Showing sub-agent: {s}", -} as const +const DEBUG_LANG = typeof process !== "undefined" ? process.env?.CACHE_TUI_LANG : undefined +const INIT_LANG: LangCode = DEBUG_LANG === "zh" || DEBUG_LANG === "en" ? DEBUG_LANG : detectLang() // ── color helpers ──────────────────────────────────────────────── @@ -279,7 +140,7 @@ function desaturateTo(raw: unknown, maxSat: number, fallback: string): string { * converges to within a fraction of an 8‑bit step, eliminating * colour banding in edge cases. */ - // BT.601 luma (perceptual brightness used as the grey anchor) + // Bt.601 luma (perceptual brightness used as the grey anchor) const luma = c.r * 0.299 + c.g * 0.587 + c.b * 0.114 let lo = 0, hi = 1 for (let i = 0; i < 12; i++) { @@ -501,8 +362,8 @@ interface PanelSignals { setCurrencySymbol: (v: string) => void exchangeRate: () => number setExchangeRate: (v: number) => void - langZH: () => boolean - setLangZH: (v: boolean) => void + langCode: () => LangCode + setLangCode: (v: LangCode) => void sectionDetail: () => boolean setSectionDetail: (v: boolean) => void sectionModel: () => boolean @@ -579,7 +440,7 @@ function TokenCachePanel(props: { const { currencySymbol, setCurrencySymbol, exchangeRate, setExchangeRate, - langZH, setLangZH, + langCode, setLangCode, sectionDetail, setSectionDetail, sectionModel, setSectionModel, sectionDist, setSectionDist, @@ -594,8 +455,8 @@ function TokenCachePanel(props: { borderVisible, setBorderVisible, } = props.signals - // ── reactive translation (follows langZH signal) ── - const t = createMemo(() => langZH() ? ZH_T : EN_T) + // ── reactive translation (follows langCode signal) ── + const t = createT(() => langCode()) // ── scan session messages reactively ── // SolidJS createMemo re-evaluates whenever the underlying @@ -705,11 +566,11 @@ function TokenCachePanel(props: { let prevMsgHitRate = -1, lastMsgHitRate = -1 for (const msg of msgs) { if (msg.role !== "assistant") continue - const t = (msg as AssistantMessage).tokens; if (!t) continue - const mit = num(t.input) + num(t.cache?.read) + num(t.cache?.write), mrt = num(t.cache?.read) + const tok = (msg as AssistantMessage).tokens; if (!tok) continue + const mit = num(tok.input) + num(tok.cache?.read) + num(tok.cache?.write), mrt = num(tok.cache?.read) if (mit > 0) { prevMsgHitRate = lastMsgHitRate; lastMsgHitRate = (mrt / mit) * 100 } if (fallbackTokens) { - input += num(t.input); read += num(t.cache?.read); write += num(t.cache?.write); output += num(t.output) + input += num(tok.input); read += num(tok.cache?.read); write += num(tok.cache?.write); output += num(tok.output) } if (fallbackCost) { cost += num((msg as AssistantMessage).cost) @@ -791,8 +652,8 @@ function TokenCachePanel(props: { // 从后往前找最后一条有 token 数据的 assistant 消息(避免取到 streaming 中未填充的消息) for (let i = msgs.length - 1; i >= 0; i--) { if (msgs[i].role !== "assistant") continue - const t = (msgs[i] as AssistantMessage).tokens - if (t && ((t.input ?? 0) > 0 || (t.cache?.read ?? 0) > 0 || (t.cache?.write ?? 0) > 0)) { lastAssMsg = msgs[i] as AssistantMessage; break } + const tok = (msgs[i] as AssistantMessage).tokens + if (tok && ((tok.input ?? 0) > 0 || (tok.cache?.read ?? 0) > 0 || (tok.cache?.write ?? 0) > 0)) { lastAssMsg = msgs[i] as AssistantMessage; break } } // 取最后一条有数据消息的总输入(含缓存读/写)作为当前 context 大小 dist.apiInput = num(lastAssMsg?.tokens?.input) + num(lastAssMsg?.tokens?.cache?.read) + num(lastAssMsg?.tokens?.cache?.write) @@ -892,7 +753,7 @@ function TokenCachePanel(props: { // Restore language preference const savedLang = props.api.kv.get(`${KV_PREFIX}.lang`) if (savedLang === "zh" || savedLang === "en") { - setLangZH(savedLang === "zh") + setLangCode(savedLang) } // Restore distribution snapshot so the token distribution block // doesn't blank out while api.state.part() re-hydrates. @@ -980,7 +841,7 @@ function TokenCachePanel(props: { const barW = createMemo(() => { const trendSpace = data().hasTrendData ? LABEL_GAP + visualWidth(trendLabel(data().trend)) : 0 - const overhead = visualWidth(t().hit) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + gutter() + const overhead = visualWidth(t("hit")) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + gutter() return Math.max(3, panelWidth() - overhead) }) const bar = createMemo(() => progressBar(data().hitRate, barW())) @@ -1026,7 +887,7 @@ function TokenCachePanel(props: { setOpen((o) => { const n = !o; persistFold("open", n); return n })}> {open() ? "\u25bc " : "\u25b6 "} - {t().title} + {t("title")} v{PLUGIN_VERSION} @@ -1034,18 +895,18 @@ function TokenCachePanel(props: { - {" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(t().title) - visualWidth(pct() + " " + t().hitFolded + " " + trendLabel(data().trend))))} + {" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(t("title")) - visualWidth(pct() + " " + t("hitFolded") + " " + trendLabel(data().trend))))} - {pct()} {t().hitFolded} + {pct()} {t("hitFolded")} = 0.05 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }}> {" "}{trendLabel(data().trend)} - {" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(t().title) - visualWidth(pct() + " " + t().hitFolded)))} + {" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(t("title")) - visualWidth(pct() + " " + t("hitFolded"))))} - {pct()} {t().hitFolded} + {pct()} {t("hitFolded")} @@ -1053,7 +914,7 @@ function TokenCachePanel(props: { {(() => { - const prefix = " \u21b3 " + (langZH() ? "\u5B50\u4EE3\u7406: " : "Sub: ") + const prefix = " \u21b3 " + t("subPrefix") const maxSidW = Math.max(6, panelWidth() - visualWidth(prefix)) return ( @@ -1068,7 +929,7 @@ function TokenCachePanel(props: { {sep()} {"> "} - {t().noData} + {t("noData")} }> @@ -1076,7 +937,7 @@ function TokenCachePanel(props: { {/* hit rate + bar — inline to avoid box spacing */} - {t().hit} + {t("hit")} [{bar()}] {pct()} @@ -1088,39 +949,39 @@ function TokenCachePanel(props: { {/* session cumulative hit rate */} - {justify(t().totalHit, (Math.floor(data().sessionHitRate * 10) / 10).toFixed(1) + "%")} + {justify(t("totalHit"), (Math.floor(data().sessionHitRate * 10) / 10).toFixed(1) + "%")} {/* ── detail section (collapsible, default open) ── */} setDetailOpen((o) => { const n = !o; persistFold("detail", n); return n })}> {detailOpen() ? "\u25bc " : "\u25b6 "} - {t().secDetail} - {sep().slice(visualWidth((detailOpen() ? "\u25bc " : "\u25b6 ") + t().secDetail))} + {t("secDetail")} + {sep().slice(visualWidth((detailOpen() ? "\u25bc " : "\u25b6 ") + t("secDetail")))} 0}> - {justify(t().read, fmt(data().read), t().tok)} + {justify(t("read"), fmt(data().read), t("tok"))} 0}> - {justify(t().write, fmt(data().write), t().tok)} + {justify(t("write"), fmt(data().write), t("tok"))} {/* 未命中 = 新鲜输入 + 缓存写(两者都未从缓存命中) */} - {justify(t().miss, fmt(data().freshInput + data().write), t().tok)} + {justify(t("miss"), fmt(data().freshInput + data().write), t("tok"))} - {justify(t().out, fmt(data().output), t().tok)} + {justify(t("out"), fmt(data().output), t("tok"))} 0}> - {t().saved} - {" ".repeat(Math.max(1, panelWidth() - gutter() - visualWidth(t().saved) - visualWidth("~" + fmtCost(data().saved, currencySymbol(), exchangeRate()))))} + {t("saved")} + {" ".repeat(Math.max(1, panelWidth() - gutter() - visualWidth(t("saved")) - visualWidth("~" + fmtCost(data().saved, currencySymbol(), exchangeRate()))))} ~{fmtCost(data().saved, currencySymbol(), exchangeRate())} @@ -1131,34 +992,34 @@ function TokenCachePanel(props: { { setModelOpen((o) => { const n = !o; persistFold("model", n); return n })}> {modelOpen() ? "\u25bc " : "\u25b6 "} - {t().secModel} - {sep().slice(visualWidth((modelOpen() ? "\u25bc " : "\u25b6 ") + t().secModel))} + {t("secModel")} + {sep().slice(visualWidth((modelOpen() ? "\u25bc " : "\u25b6 ") + t("secModel")))} } - {justify(t().cost, fmtCost(data().cost, currencySymbol(), exchangeRate()))} + {justify(t("cost"), fmtCost(data().cost, currencySymbol(), exchangeRate()))} - {justify(t().provider, data().providerName)} + {justify(t("provider"), data().providerName)} - {justify(t().model, data().model)} + {justify(t("model"), data().model)} - {justify(t().rate, currencySymbol() + (data().inputRate * exchangeRate()).toFixed(2) + "/M " + t().inputRate)} + {justify(t("rate"), currencySymbol() + (data().inputRate * exchangeRate()).toFixed(2) + "/M " + t("inputRate"))} 0}> - {justify("", currencySymbol() + (data().cacheReadRate * exchangeRate()).toFixed(2) + "/M " + t().cacheRate)} + {justify("", currencySymbol() + (data().cacheReadRate * exchangeRate()).toFixed(2) + "/M " + t("cacheRate"))} 0}> - {justify("", currencySymbol() + (data().cacheWriteRate * exchangeRate()).toFixed(2) + "/M " + t().writeRate)} + {justify("", currencySymbol() + (data().cacheWriteRate * exchangeRate()).toFixed(2) + "/M " + t("writeRate"))} @@ -1170,37 +1031,37 @@ function TokenCachePanel(props: { { setDistOpen((o) => { const n = !o; persistFold("dist", n); return n })}> {distOpen() ? "\u25bc " : "\u25b6 "} - {t().distTitle} - {sep().slice(visualWidth((distOpen() ? "\u25bc " : "\u25b6 ") + t().distTitle))} + {t("distTitle")} + {sep().slice(visualWidth((distOpen() ? "\u25bc " : "\u25b6 ") + t("distTitle")))} } 0}> - {justify(t().distSys, fmt(data().dist.system), t().tok)} + {justify(t("distSys"), fmt(data().dist.system), t("tok"))} 0}> - {justify(t().distUser, fmt(data().dist.user), t().tok)} + {justify(t("distUser"), fmt(data().dist.user), t("tok"))} 0}> - {justify(t().distAgent, fmt(data().dist.agent), t().tok)} + {justify(t("distAgent"), fmt(data().dist.agent), t("tok"))} 0}> - {justify(t().distTool, fmt(data().dist.toolCall), t().tok)} + {justify(t("distTool"), fmt(data().dist.toolCall), t("tok"))} 0}> - {justify(t().distRes, fmt(data().dist.toolResult), t().tok)} + {justify(t("distRes"), fmt(data().dist.toolResult), t("tok"))} - {justify(t().distTotal, fmt(data().dist.apiInput), t().tok)} + {justify(t("distTotal"), fmt(data().dist.apiInput), t("tok"))} @@ -1211,18 +1072,18 @@ function TokenCachePanel(props: { { setSkillsOpen((o) => { const n = !o; persistFold("skills", n); return n })}> {skillsOpen() ? "\u25bc " : "\u25b6 "} - {t().secSkills} + {t("secSkills")} ({data().skills.length}) - {sep().slice(visualWidth((skillsOpen() ? "\u25bc " : "\u25b6 ") + t().secSkills + ` (${data().skills.length})`))} + {sep().slice(visualWidth((skillsOpen() ? "\u25bc " : "\u25b6 ") + t("secSkills") + ` (${data().skills.length})`))} } {data().skills.map((sk: { name: string; tokens: number }) => { - const rightW = visualWidth(fmt(sk.tokens)) + UNIT_GAP + visualWidth(t().tok) + const rightW = visualWidth(fmt(sk.tokens)) + UNIT_GAP + visualWidth(t("tok")) const maxLabel = Math.max(4, panelWidth() - gutter() - rightW - 1) const label = truncateVisual(sk.name, maxLabel) return ( - {justify(label, fmt(sk.tokens), t().tok)} + {justify(label, fmt(sk.tokens), t("tok"))} ) })} @@ -1236,20 +1097,20 @@ function TokenCachePanel(props: { {"> "} - {t().balUnsupported} + {t("balUnsupported")} {"> "} - {t().balNoKey.replace("{p}", providerName())} + {t("balNoKey", { p: providerName() })} {"> "} - {t().balLoading} + {t("balLoading")} @@ -1257,17 +1118,17 @@ function TokenCachePanel(props: { {"> "} {(() => { const code = balanceState().error - if (code === "401") return t().balErr401 - if (code === "403") return t().balErr403 - if (code === "EMPTY") return t().balErrEmpty - if (code === "TIMEOUT") return t().balErrTimeout - return t().balError + (code ? ` (${code})` : "") + if (code === "401") return t("balErr401") + if (code === "403") return t("balErr403") + if (code === "EMPTY") return t("balErrEmpty") + if (code === "TIMEOUT") return t("balErrTimeout") + return t("balError") + (code ? ` (${code})` : "") })()} - {justify(t().balTotal, formatBalanceText(balanceState().data!, balanceCurrency(), exchangeRate()))} + {justify(t("balTotal"), formatBalanceText(balanceState().data!, balanceCurrency(), exchangeRate()))} @@ -1289,7 +1150,7 @@ function TokenCachePanel(props: { */ function BottomStatusBar(props: { api: TuiPluginApi; signals: PanelSignals; sessionId: string }): JSX.Element { const KV_PREFIX = "cache_panel" - const t = createMemo(() => (props.signals.langZH() ? ZH_T : EN_T)) + const t = createT(() => props.signals.langCode()) const sid = props.sessionId @@ -1419,19 +1280,19 @@ function BottomStatusBar(props: { api: TuiPluginApi; signals: PanelSignals; sess {directory()} - {t().barHit} + {t("barHit")} {(stats()?.hitRate ?? -1) >= 0 ? (Math.floor(stats()!.hitRate * 10) / 10).toFixed(1) + "%" : "--"} 0 ? pal().success : pal().error }}> {" " + (trend()! > 0 ? "\u2191" : "\u2193") + Math.abs(trend()!).toFixed(1) + "%"} - {" \u00b7 " + t().barTok + " "} + {" \u00b7 " + t("barTok") + " "} {stats() ? fmtCompact(stats()!.input + stats()!.read + stats()!.write) : "--"} - {" \u00b7 " + t().barBal + " "} + {" \u00b7 " + t("barBal") + " "} {balanceText()} @@ -1485,7 +1346,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { const [balanceUnsupported, setBalanceUnsupported] = createSignal(false) const [balanceCurrency, setBalanceCurrency] = createSignal("") const [borderVisible, setBorderVisible] = createSignal(true) - const [langZH, setLangZH] = createSignal(LANG_ZH) + const [langCode, setLangCode] = createSignal(INIT_LANG) const [overrideSessionId, setOverrideSessionId] = createSignal(undefined) // ── 余额查询状态(共享):侧边栏与底部栏读同一份数据, @@ -1499,7 +1360,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { const signals: PanelSignals = { currencySymbol, setCurrencySymbol, exchangeRate, setExchangeRate, - langZH, setLangZH, + langCode, setLangCode, sectionDetail, setSectionDetail, sectionModel, setSectionModel, sectionDist, setSectionDist, @@ -1596,27 +1457,26 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { /** 菜单中 provider 选项标题:标注 key 来源(手动配置 / OpenCode 自动复用 / 未配置)。 */ const providerOptionTitle = (p: BalanceProvider, current?: string) => { - const zh = langZH() + const t = createT(() => langCode()) const hasManual = !!api.kv.get(`${KV_PREFIX}.balance.${p.id}.key`, "") const hasAuto = !hasManual && !!findOpencodeKey(api, p) const mark = hasManual - ? (zh ? "(用户 key)" : " (user key)") + ? t("keyUser") : hasAuto - ? (zh ? "(OpenCode)" : " (OpenCode)") - : (zh ? "(未配置)" : " (not set)") + ? t("keyOpenCode") + : t("keyNotSet") return p.name + mark + (current && p.id === current ? " *" : "") } /** 弹出指定 provider 的 API Key 输入框(脱敏预填;空清除 / 含 * 保留原 key / 新 key 实时刷新)。 */ const promptBalanceKey = (dialog: TuiDialogStack | undefined, provider: BalanceProvider) => { - const zh = langZH() - const T = zh ? ZH_T : EN_T + const t = createT(() => langCode()) const current = api.kv.get(`${KV_PREFIX}.balance.${provider.id}.key`, "") const masked = maskKey(current) dialog?.replace(() => ( {zh ? `输入 ${provider.name} API Key 以显示账户余额(留空清除)` : `Enter your ${provider.name} API key to show account balance (leave empty to clear)`}} + description={() => {t("balKeyPrompt", { p: provider.name })}} placeholder={provider.keyPlaceholder ?? "sk-..."} value={masked} onConfirm={(val) => { @@ -1632,9 +1492,9 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { api.kv.set(`${KV_PREFIX}.balance.${provider.id}.key`, key) setBalanceRefresh(v => v + 1) if (key) { - api.ui.toast({ message: T.keySaved }) + api.ui.toast({ message: t("keySaved") }) } else { - api.ui.toast({ message: T.keyCleared }) + api.ui.toast({ message: t("keyCleared") }) } dialog?.clear() }} @@ -1658,6 +1518,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { value: code, }))} onSelect={(opt) => { + const t = createT(() => langCode()) const sym = CURRENCIES[opt.value] ?? "$" const defRate = DEFAULT_RATES[opt.value] ?? 1 api.kv.set(`${KV_PREFIX}.currency`, sym) @@ -1667,7 +1528,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { signals.setBalanceCurrency(opt.value) signals.setCurrencySymbol(sym) signals.setExchangeRate(defRate) - api.ui.toast({ message: (langZH() ? ZH_T : EN_T).currencySet.replace("{v}", opt.value).replace("{s}", sym).replace("{r}", String(defRate)) }) + api.ui.toast({ message: t("currencySet", { v: opt.value, s: sym, r: defRate }) }) dialog?.clear() }} /> @@ -1687,11 +1548,12 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { placeholder="1.0" value={String(api.kv.get(`${KV_PREFIX}.rate`, 1))} onConfirm={(val) => { + const t = createT(() => langCode()) const n = parseFloat(val) if (n > 0) { api.kv.set(`${KV_PREFIX}.rate`, n) signals.setExchangeRate(n) - api.ui.toast({ message: (langZH() ? ZH_T : EN_T).rateSet.replace("{r}", String(n)) }) + api.ui.toast({ message: t("rateSet", { r: n }) }) } dialog?.clear() }} @@ -1705,8 +1567,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { description: "Show or hide a sidebar section", slash: { name: "cache-section" }, onSelect: (dialog) => { - const zh = langZH() - const T = zh ? ZH_T : EN_T + const t = createT(() => langCode()) const detailOn = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true)) const modelOn = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true)) const distOn = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true)) @@ -1715,18 +1576,18 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { const bottomOn = Boolean(api.kv.get(`${KV_PREFIX}.section.bottom`, true)) const borderOn = Boolean(api.kv.get(`${KV_PREFIX}.border`, true)) const labels: Record = { - detail: T.secDetail, - model: T.secModel, - dist: T.distTitle, - skills: T.secSkills, - balance: T.secBalance, - bottom: T.secBottom, - border: T.secBorder, + detail: t("secDetail"), + model: t("secModel"), + dist: t("distTitle"), + skills: t("secSkills"), + balance: t("secBalance"), + bottom: t("secBottom"), + border: t("secBorder"), } const optTitle = (label: string, on: boolean) => `${visualPadEnd(label, 15)}[${on ? "ON" : "OFF"}]` dialog?.replace(() => ( { const cur = Boolean(api.kv.get(`${KV_PREFIX}.border`, true)) api.kv.set(`${KV_PREFIX}.border`, !cur) signals.setBorderVisible(!cur) - api.ui.toast({ message: !cur ? T.borderShown : T.borderHidden }) + api.ui.toast({ message: !cur ? t("borderShown") : t("borderHidden") }) } else { const key = `${KV_PREFIX}.section.${opt.value}` const cur = Boolean(api.kv.get(key, true)) @@ -1753,7 +1614,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { if (opt.value === "balance") signals.setSectionBalance(!cur) if (opt.value === "bottom") signals.setSectionBottom(!cur) const name = labels[opt.value] ?? opt.value - api.ui.toast({ message: (!cur ? T.sectionShown : T.sectionHidden).replace("{s}", name) }) + api.ui.toast({ message: t(!cur ? "sectionShown" : "sectionHidden", { s: name }) }) } dialog?.clear() }} @@ -1767,7 +1628,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { description: "Display the current plugin configuration", slash: { name: "cache-config" }, onSelect: (dialog) => { - const T = langZH() ? ZH_T : EN_T + const t = createT(() => langCode()) const sym = api.kv.get(`${KV_PREFIX}.currency`) ?? "$" const rate = api.kv.get(`${KV_PREFIX}.rate`) ?? 1 const detail = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true)) @@ -1778,12 +1639,13 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { const bottom = Boolean(api.kv.get(`${KV_PREFIX}.section.bottom`, true)) const on = (v: boolean) => v ? "ON" : "OFF" api.ui.toast({ - title: T.panelConfigTitle, - message: T.panelConfigMsg - .replace("{c}", sym).replace("{r}", String(rate)) - .replace("{d}", on(detail)).replace("{m}", on(model)) - .replace("{t}", on(dist)).replace("{k}", on(skills)) - .replace("{b}", on(balance)).replace("{f}", on(bottom)), + title: t("panelConfigTitle"), + message: t("panelConfigMsg", { + c: sym, r: rate, + d: on(detail), m: on(model), + t: on(dist), k: on(skills), + b: on(balance), f: on(bottom), + }), duration: 8000, }) dialog?.clear() @@ -1795,19 +1657,20 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { description: "Switch between Chinese and English display", slash: { name: "cache-lang" }, onSelect: (dialog) => { - const cur = langZH() + const t = createT(() => langCode()) + const cur = langCode() dialog?.replace(() => ( ({ + title: `${visualPadEnd(m.label, 9)}${cur === m.code ? "\u2713" : ""}`, + value: m.code, + }))} onSelect={(opt) => { - const zh = opt.value === "zh" - api.kv.set(`${KV_PREFIX}.lang`, opt.value) - setLangZH(zh) - api.ui.toast({ message: zh ? ZH_T.langSwitched : EN_T.langSwitched }) + const code = opt.value as LangCode + api.kv.set(`${KV_PREFIX}.lang`, code) + setLangCode(code) + api.ui.toast({ message: t("langSwitched") }) dialog?.clear() }} /> @@ -1820,15 +1683,13 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { description: "切换余额提供商 / 自动切换当前会话提供商 | Switch balance provider / auto-switch session provider", slash: { name: "cache-balance" }, onSelect: (dialog) => { - const zh = langZH() + const t = createT(() => langCode()) const current = signals.balanceProviderId() const auto = signals.autoBalance() - const autoLabel = auto - ? (zh ? "自动切换提供商 [开]" : "Auto-switch provider [ON]") - : (zh ? "自动切换提供商 [关]" : "Auto-switch provider [OFF]") + const autoLabel = `${t("autoSwitchOpt")} [${auto ? "ON" : "OFF"}]` dialog?.replace(() => ( { const next = !auto api.kv.set(`${KV_PREFIX}.balance.auto`, next) signals.setAutoBalance(next) - api.ui.toast({ message: next ? (zh ? ZH_T.autoSwitchOn : EN_T.autoSwitchOn) : (zh ? ZH_T.autoSwitchOff : EN_T.autoSwitchOff) }) + api.ui.toast({ message: next ? t("autoSwitchOn") : t("autoSwitchOff") }) dialog?.clear() } else { const provider = getBalanceProvider(opt.value) @@ -1861,7 +1722,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { // 未配置 key → 进入设置流程(对话框保持打开等待输入) promptBalanceKey(dialog, provider) } else { - api.ui.toast({ message: (zh ? ZH_T : EN_T).providerManual.replace("{p}", provider.name) }) + api.ui.toast({ message: t("providerManual", { p: provider.name }) }) dialog?.clear() } } @@ -1876,11 +1737,11 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { description: "Select a provider and set its API key for balance display", slash: { name: "cache-balance-key" }, onSelect: (dialog) => { - const zh = langZH() + const t = createT(() => langCode()) // 步骤 1:选择 provider dialog?.replace(() => ( ({ title: providerOptionTitle(p), value: p.id, @@ -1907,9 +1768,10 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { description: "Dump all tool parts found in the current session for skill detection debugging", slash: { name: "cache-debug-skills" }, onSelect: () => { + const t = createT(() => langCode()) const rt = api.route.current if (rt.name !== "session" || !rt.params) { - api.ui.toast({ message: (langZH() ? ZH_T : EN_T).runInSession, variant: "warning" }) + api.ui.toast({ message: t("runInSession"), variant: "warning" }) return } const sid = String(rt.params.sessionID) @@ -1987,7 +1849,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { if (unique.length > 0) { // ── 有子代理 → DialogSelect 列表选择 ── - const zh = langZH() + const t = createT(() => langCode()) const currentSid = signals.overrideSessionId() ?? api.kv.get(`${KV_PREFIX}.session`, "") const options = unique.map((c, i) => ({ title: `${i + 1}. ${c.title}`, @@ -1996,24 +1858,24 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { })) // 首尾各放一个"回到主会话",长列表时顶部底部均可直达 const backValue = "__main__" - const backTitle = `\u2500 ${zh ? "\u56DE\u5230\u4E3B\u4F1A\u8BDD" : "Back to Main"}` + const backTitle = `\u2500 ${t("backToMainTitle")}` options.unshift({ title: backTitle, value: backValue, description: "" }) options.push({ title: backTitle, value: backValue, description: "" }) const currentIdx = currentSid ? options.findIndex(o => o.value === currentSid) : -1 dialog?.replace(() => ( = 0 ? options[currentIdx].value : undefined} onSelect={(opt) => { if (opt.value === backValue) { signals.setOverrideSessionId(undefined) api.kv.set(`${KV_PREFIX}.session`, "") - api.ui.toast({ message: (zh ? ZH_T : EN_T).backToMain }) + api.ui.toast({ message: t("backToMain") }) } else { signals.setOverrideSessionId(opt.value) api.kv.set(`${KV_PREFIX}.session`, opt.value) - api.ui.toast({ message: (zh ? ZH_T : EN_T).subAgentSwitched.replace("{s}", opt.value.slice(0, 24) + "\u2026") }) + api.ui.toast({ message: t("subAgentSwitched", { s: opt.value.slice(0, 24) + "\u2026" }) }) } dialog?.clear() }} @@ -2021,11 +1883,11 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { )) } else { // ── 无子代理 → DialogPrompt 手动粘贴 ── - const zh = langZH() + const t = createT(() => langCode()) dialog?.replace(() => ( {zh ? "未找到子代理,请手动粘贴 Session ID" : "No sub-agents found. Paste a Session ID manually"}} + title={signals.overrideSessionId() ? t("subSwitchTitle") : t("subViewTitle")} + description={() => {t("subNoFound")}} placeholder="ses_..." value={signals.overrideSessionId() ?? api.kv.get(`${KV_PREFIX}.session`, "") ?? ""} onConfirm={(val) => { @@ -2033,7 +1895,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { if (sid) { signals.setOverrideSessionId(sid) api.kv.set(`${KV_PREFIX}.session`, sid) - api.ui.toast({ message: (langZH() ? ZH_T : EN_T).subAgentSwitched.replace("{s}", sid.slice(0, 24) + "\u2026") }) + api.ui.toast({ message: t("subAgentSwitched", { s: sid.slice(0, 24) + "\u2026" }) }) } dialog?.clear() }} @@ -2049,9 +1911,10 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { description: "Return to main session stats", slash: { name: "cache-session-back" }, onSelect: (dialog) => { + const t = createT(() => langCode()) signals.setOverrideSessionId(undefined) api.kv.set(`${KV_PREFIX}.session`, "") - api.ui.toast({ message: (langZH() ? ZH_T : EN_T).backToMain }) + api.ui.toast({ message: t("backToMain") }) dialog?.clear() }, }, From fef4595c0c942a8eb3081a3835cfa787c517bc76 Mon Sep 17 00:00:00 2001 From: Hotakus Date: Tue, 11 Aug 2026 04:03:40 +0800 Subject: [PATCH 11/13] =?UTF-8?q?feat(i18n):=20=E6=96=B0=E5=A2=9E=E6=97=A5?= =?UTF-8?q?=E8=AF=AD=E4=B8=8E=E9=9F=A9=E8=AF=AD=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/i18n.ts | 177 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 173 insertions(+), 4 deletions(-) diff --git a/src/i18n.ts b/src/i18n.ts index 2a7a270..1f40c8b 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -3,7 +3,7 @@ // satisfies `Translation`; the compiler enforces key completeness. // --------------------------------------------------------------------------- -export type LangCode = "zh" | "en" +export type LangCode = "zh" | "en" | "ja" | "ko" const ZH_T = { title: "缓存统计", @@ -172,12 +172,178 @@ const EN_T: Translation = { subNoFound: "No sub-agents found. Paste a Session ID manually", } -export const LANGS: Record = { zh: ZH_T, en: EN_T } +const JA_T: Translation = { + title: "キャッシュ統計", + hit: "ヒット率", + totalHit: "総ヒット:", + read: "キャッシュ読込:", + write: "キャッシュ書込:", + miss: "未ヒット:", + out: "出力:", + cost: "費用:", + saved: "累計節約:", + model: "モデル:", + provider: "プロバイダ:", + rate: "単価:", + hitFolded: "ヒット", + inputRate: "入力", + cacheRate: "キャッシュ", + writeRate: "書込", + noData: "キャッシュデータ待機中...", + tok: "tok", + distTitle: "Token 分布(推定)", + distSys: "システム:", + distUser: "ユーザー:", + distAgent: "Agent 指示:", + distTool: "Tool 呼び出し:", + distRes: "Tool 結果:", + distTotal: "合計:", + distOut: "出力:", + secDetail: "明細", + secModel: "モデル", + secSkills: "読み込み済みスキル", + balTotal: "総残高:", + balNoKey: "{p} の API Key 未設定", + balLoading: "取得中...", + balError: "取得失敗", + balErr401: "API Key 無効", + balErr403: "残高照会が拒否されました", + balErrEmpty:"残高データなし", + balErrTimeout: "タイムアウト", + balUnsupported: "このプロバイダは残高照会非対応", + barHit: "ヒット率", + barBal: "残高", + barTok: "Tokens", + // ── /cache-section パネル ── + secToggle: "セクション切替", + secBalance: "残高", + secBottom: "下部ステータスバー", + secBorder: "パネル枠線", + // ── toast ── + keySaved: "API Key 保存済み、残高を取得中...", + keyCleared: "API Key をクリアしました", + currencySet: "通貨: {v} ({s}), レート: {r}", + rateSet: "レートを {r} に設定しました", + panelConfigTitle: "パネル設定", + panelConfigMsg: "通貨: {c} | レート: {r} | 明細: {d} | モデル: {m} | 分布: {t} | スキル: {k} | 残高: {b} | 下部: {f}", + borderShown: "パネル枠線 表示", + borderHidden: "パネル枠線 非表示", + sectionShown: "{s} 表示", + sectionHidden: "{s} 非表示", + langSwitched: "日本語に切り替えました", + autoSwitchOn: "残高プロバイダ自動切替: オン", + autoSwitchOff: "残高プロバイダ自動切替: オフ", + providerManual: "残高プロバイダ: {p}(自動切替オフ)", + runInSession: "セッション内で実行してください", + backToMain: "メインセッションに戻りました", + subAgentSwitched: "サブエージェントへ切替: {s}", + // ── ダイアログ / メニュー ── + langTitle: "表示言語", + subPrefix: "サブ: ", + keyUser: "(ユーザー key)", + keyOpenCode: "(OpenCode)", + keyNotSet: "(未設定)", + balKeyPrompt: "{p} の API Key を入力して残高を表示(空欄でクリア)", + balProvTitle: "残高プロバイダ / 自動切替", + autoSwitchOpt: "自動切替プロバイダ", + balSelectTitle: "残高プロバイダ選択", + backToMainTitle: "メインに戻る", + subSelectTitle: "サブエージェント選択", + subSwitchTitle: "サブ切替", + subViewTitle: "サブキャッシュ表示", + subNoFound: "サブエージェントが見つかりません。Session ID を手動で貼り付けてください", +} + +const KO_T: Translation = { + title: "캐시 통계", + hit: "히트율", + totalHit: "총 히트:", + read: "캐시 읽기:", + write: "캐시 쓰기:", + miss: "미히트:", + out: "출력:", + cost: "비용:", + saved: "누적 절약:", + model: "모델:", + provider: "프로바이더:", + rate: "단가:", + hitFolded: "히트", + inputRate: "입력", + cacheRate: "캐시", + writeRate: "쓰기", + noData: "캐시 데이터 대기 중...", + tok: "tok", + distTitle: "Token 분포(추정)", + distSys: "시스템:", + distUser: "사용자:", + distAgent: "Agent 지시:", + distTool: "Tool 호출:", + distRes: "Tool 결과:", + distTotal: "합계:", + distOut: "출력:", + secDetail: "상세", + secModel: "모델", + secSkills: "로드된 스킬", + balTotal: "총 잔액:", + balNoKey: "{p} API Key 미설정", + balLoading: "조회 중...", + balError: "조회 실패", + balErr401: "API Key 무효", + balErr403: "잔액 조회가 거부되었습니다", + balErrEmpty:"잔액 데이터 없음", + balErrTimeout: "시간 초과", + balUnsupported: "이 프로바이더는 잔액 조회 미지원", + barHit: "히트율", + barBal: "잔액", + barTok: "Tokens", + // ── /cache-section 패널 ── + secToggle: "섹션 전환", + secBalance: "잔액", + secBottom: "하단 상태바", + secBorder: "패널 테두리", + // ── toast ── + keySaved: "API Key 저장됨, 잔액 조회 중...", + keyCleared: "API Key 삭제됨", + currencySet: "통화: {v} ({s}), 환율: {r}", + rateSet: "환율을 {r}(으)로 설정했습니다", + panelConfigTitle: "패널 설정", + panelConfigMsg: "통화: {c} | 환율: {r} | 상세: {d} | 모델: {m} | 분포: {t} | 스킬: {k} | 잔액: {b} | 하단: {f}", + borderShown: "패널 테두리 표시", + borderHidden: "패널 테두리 숨김", + sectionShown: "{s} 표시", + sectionHidden: "{s} 숨김", + langSwitched: "한국어로 전환했습니다", + autoSwitchOn: "잔액 프로바이더 자동 전환: 켜짐", + autoSwitchOff: "잔액 프로바이더 자동 전환: 꺼짐", + providerManual: "잔액 프로바이더: {p}(자동 전환 꺼짐)", + runInSession: "세션 내에서 실행해 주세요", + backToMain: "메인 세션으로 돌아갔습니다", + subAgentSwitched: "서브 에이전트로 전환: {s}", + // ── 다이얼로그 / 메뉴 ── + langTitle: "표시 언어", + subPrefix: "서브: ", + keyUser: "(사용자 key)", + keyOpenCode: "(OpenCode)", + keyNotSet: "(미설정)", + balKeyPrompt: "{p} API Key를 입력하여 잔액 표시(비우면 삭제)", + balProvTitle: "잔액 프로바이더 / 자동 전환", + autoSwitchOpt: "자동 전환 프로바이더", + balSelectTitle: "잔액 프로바이더 선택", + backToMainTitle: "메인으로 돌아가기", + subSelectTitle: "서브 에이전트 선택", + subSwitchTitle: "서브 전환", + subViewTitle: "서브 캐시 보기", + subNoFound: "서브 에이전트를 찾을 수 없습니다. Session ID를 직접 붙여넣으세요", +} + +export const LANGS: Record = { zh: ZH_T, en: EN_T, ja: JA_T, ko: KO_T } /** 语言元数据:/cache-lang 选项与自动检测共用。 */ export const LANG_META: { code: LangCode; label: string }[] = [ { code: "zh", label: "中文" }, { code: "en", label: "English" }, + { code: "ja", label: "日本語" }, + { code: "ko", label: "한국어" }, ] /** @@ -200,11 +366,14 @@ export function createT(getCode: () => LangCode) { applyParams(LANGS[getCode()][key], params) } -/** 按系统 locale 自动检测语言(zh 前缀 → 中文,其余 → 英文)。 */ +/** 按系统 locale 自动检测语言(zh → 中文,ja → 日语,ko → 韩语,其余 → 英文)。 */ export function detectLang(): LangCode { try { const loc = Intl.DateTimeFormat().resolvedOptions().locale.toLowerCase() - return loc.startsWith("zh") ? "zh" : "en" + if (loc.startsWith("zh")) return "zh" + if (loc.startsWith("ja")) return "ja" + if (loc.startsWith("ko")) return "ko" + return "en" } catch { return "en" } From 5531b57d0046bd03be624bca4939145bc28e85d9 Mon Sep 17 00:00:00 2001 From: Hotakus Date: Tue, 11 Aug 2026 04:11:24 +0800 Subject: [PATCH 12/13] =?UTF-8?q?fix(i18n):=20=E8=AF=AD=E8=A8=80=E5=81=8F?= =?UTF-8?q?=E5=A5=BD=E6=81=A2=E5=A4=8D=E4=BC=98=E5=85=88=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E5=B9=B6=E6=94=AF=E6=8C=81=E5=85=A8=E9=83=A8?= =?UTF-8?q?=E8=AF=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.tsx | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index de34513..079c0e5 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -76,10 +76,13 @@ function truncateVisual(s: string, maxCols: number): string { } // ── language ────────────────────────────────────────────────────── -// 语言初始化:环境变量 CACHE_TUI_LANG 覆盖 → 否则按系统 locale 自动检测 +// 语言初始化:环境变量 CACHE_TUI_LANG 覆盖 → 否则按系统 locale 自动检测。 +// 用户通过 /cache-lang 设置的偏好会在 KV 就绪后优先覆盖(见 tui() 内恢复逻辑)。 const DEBUG_LANG = typeof process !== "undefined" ? process.env?.CACHE_TUI_LANG : undefined -const INIT_LANG: LangCode = DEBUG_LANG === "zh" || DEBUG_LANG === "en" ? DEBUG_LANG : detectLang() +const INIT_LANG: LangCode = DEBUG_LANG !== undefined && LANG_META.some((m) => m.code === DEBUG_LANG) + ? (DEBUG_LANG as LangCode) + : detectLang() // ── color helpers ──────────────────────────────────────────────── @@ -440,7 +443,7 @@ function TokenCachePanel(props: { const { currencySymbol, setCurrencySymbol, exchangeRate, setExchangeRate, - langCode, setLangCode, + langCode, sectionDetail, setSectionDetail, sectionModel, setSectionModel, sectionDist, setSectionDist, @@ -750,11 +753,6 @@ function TokenCachePanel(props: { setSectionBalance(Boolean(props.api.kv.get(`${KV_PREFIX}.section.balance`, true))) const bv = props.api.kv.get(`${KV_PREFIX}.border`, true) setBorderVisible(bv !== false) - // Restore language preference - const savedLang = props.api.kv.get(`${KV_PREFIX}.lang`) - if (savedLang === "zh" || savedLang === "en") { - setLangCode(savedLang) - } // Restore distribution snapshot so the token distribution block // doesn't blank out while api.state.part() re-hydrates. const cachedDist = props.api.kv.get(`${KV_PREFIX}.dist_snapshot`) @@ -1412,6 +1410,22 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { // ── slash commands for runtime config ── const KV_PREFIX = "cache_panel" + // ── 语言偏好恢复:KV 就绪后优先用户设置(/cache-lang),覆盖自动识别 ── + const restoreLang = () => { + try { + const saved = api.kv.get(`${KV_PREFIX}.lang`) + if (saved && LANG_META.some((m) => m.code === saved)) setLangCode(saved as LangCode) + } catch {} + } + if (api.kv.ready) { + restoreLang() + } else { + const langTimer = setInterval(() => { + if (api.kv.ready) { clearInterval(langTimer); restoreLang() } + }, 10) + api.lifecycle.onDispose(() => clearInterval(langTimer)) + } + const pollBalance = async () => { const provider = getBalanceProvider(balanceProviderId()) // 手动配置的 key 优先;缺失时自动复用 OpenCode 已认证的 key(auth.json / config) From b843eb5157abb8a2e9918af328d6f5d4ecea4069 Mon Sep 17 00:00:00 2001 From: Hotakus Date: Tue, 11 Aug 2026 04:13:04 +0800 Subject: [PATCH 13/13] =?UTF-8?q?docs:=20=E8=A1=A5=E5=85=85=E5=A4=9A?= =?UTF-8?q?=E8=AF=AD=E8=A8=80=E6=94=AF=E6=8C=81=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +++--- README_EN.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 30c96f9..77f902a 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ - **颜色自适应**:命中率 ≥85% 绿 · ≥70% 橙 · <70% 红,颜色从主题色自动去饱和 - **Token 分布**:按角色(系统提示 / 用户 / Agent 指令 / Tool 调用 / Tool 结果)展示估算 Token 占比 - **折叠记忆**:折叠状态持久化,重启后保持 -- **语言适配**:自动检测系统语言,支持 `/cache-lang` 运行时切换中/英文,偏好持久化 +- **语言适配**:支持 中文 / English / 日本語 / 한국어,自动检测系统语言,`/cache-lang` 运行时切换,偏好持久化优先 - **多币种**:通过 `/cache-currency` 切换货币,费用和节省同步换算 - **余额查询**:查询多家 AI 提供商的账户余额,支持自动切换跟随当前会话提供商 - **斜杠命令**:`/cache-session` `/cache-session-back` `/cache-rate` `/cache-section` `/cache-config` `/cache-lang` 动态配置面板 @@ -213,11 +213,11 @@ rm -rf ~/.cache/opencode/packages/opencode-visual-cache@latest ### 6.1 运行时切换(推荐) -在 TUI 中输入 `/cache-lang`,从弹窗选择「中文」或「English」即可即时切换,无需重启。偏好会自动持久化,下次启动自动恢复。 +在 TUI 中输入 `/cache-lang`,从弹窗选择 中文 / English / 日本語 / 한국어 即可即时切换,无需重启。偏好会自动持久化,下次启动优先恢复用户选择。 ### 6.2 环境变量覆盖 -启动前设置 `CACHE_TUI_LANG` 环境变量可强制指定语言: +启动前设置 `CACHE_TUI_LANG` 环境变量可强制指定语言(`zh` / `en` / `ja` / `ko`): ```powershell # Windows PowerShell diff --git a/README_EN.md b/README_EN.md index c86a908..9871456 100644 --- a/README_EN.md +++ b/README_EN.md @@ -48,7 +48,7 @@ Interested in sub-agent monitoring? Check out [opencode-subagent-magazine](https - **Adaptive Colors**: ≥85% green · ≥70% orange · <70% red, auto-desaturated from current theme - **Token Distribution**: Per-role (system / user / agent instr / tool call / tool result) estimated token breakdown - **Persistent State**: Fold preferences and config remembered across restarts via api.kv -- **Language**: Auto-detects system locale, with `/cache-lang` for runtime switching between Chinese and English — preference persisted across restarts +- **Language**: Chinese / English / 日本語 / 한국어, auto-detects system locale, with `/cache-lang` for runtime switching — user preference takes priority over auto-detection - **Multi-currency**: Switch via `/cache-currency` — costs, savings, and per-million rates convert in real time - **Balance Query**: Query account balance across multiple AI providers, with auto-switch following the current session's provider - **Slash Commands**: `/cache-session` `/cache-session-back` `/cache-rate` `/cache-section` `/cache-config` `/cache-lang` for live panel configuration @@ -211,11 +211,11 @@ The plugin provides three ways to control the display language, listed by priori ### 6.1 Runtime Switching (recommended) -Type `/cache-lang` in the TUI and select **Chinese** or **English** from the dialog. The panel switches immediately without restarting, and your preference is persisted automatically for the next session. +Type `/cache-lang` in the TUI and select Chinese / English / 日本語 / 한국어 from the dialog. The panel switches immediately without restarting. Your preference is persisted and takes priority over auto-detection on the next launch. ### 6.2 Environment Variable Override -Set the `CACHE_TUI_LANG` environment variable before launching to force a specific language: +Set the `CACHE_TUI_LANG` environment variable before launching to force a specific language (`zh` / `en` / `ja` / `ko`): ```powershell # Windows PowerShell