diff --git a/README.md b/README.md index f8dd350..77f902a 100644 --- a/README.md +++ b/README.md @@ -48,12 +48,13 @@ - **颜色自适应**:命中率 ≥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` 动态配置面板 - **子代理缓存查看**:`/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 口径。 +> +> **余额显示**:侧边栏与底部栏共享同一份余额数据,两处显示一致。当前提供商不支持余额查询时,侧边栏显示提示、底部栏隐藏余额项。 --- @@ -207,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 d339cb4..9871456 100644 --- a/README_EN.md +++ b/README_EN.md @@ -48,12 +48,13 @@ 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 - **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. --- @@ -205,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 diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 0000000..1f40c8b --- /dev/null +++ b/src/i18n.ts @@ -0,0 +1,380 @@ +// --------------------------------------------------------------------------- +// i18n — centralized translations. Add a language by appending a table that +// satisfies `Translation`; the compiler enforces key completeness. +// --------------------------------------------------------------------------- + +export type LangCode = "zh" | "en" | "ja" | "ko" + +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", +} + +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: "한국어" }, +] + +/** + * 模板参数替换:`{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 → 中文,ja → 日语,ko → 韩语,其余 → 英文)。 */ +export function detectLang(): LangCode { + try { + const loc = Intl.DateTimeFormat().resolvedOptions().locale.toLowerCase() + if (loc.startsWith("zh")) return "zh" + if (loc.startsWith("ja")) return "ja" + if (loc.startsWith("ko")) return "ko" + return "en" + } catch { + return "en" + } +} diff --git a/src/index.tsx b/src/index.tsx index f235f2c..079c0e5 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 { @@ -21,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 @@ -73,97 +75,14 @@ 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 自动检测。 +// 用户通过 /cache-lang 设置的偏好会在 KV 就绪后优先覆盖(见 tui() 内恢复逻辑)。 -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: "查询超时", -} 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", -} as const +const DEBUG_LANG = typeof process !== "undefined" ? process.env?.CACHE_TUI_LANG : undefined +const INIT_LANG: LangCode = DEBUG_LANG !== undefined && LANG_META.some((m) => m.code === DEBUG_LANG) + ? (DEBUG_LANG as LangCode) + : detectLang() // ── color helpers ──────────────────────────────────────────────── @@ -224,7 +143,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++) { @@ -345,7 +264,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 } @@ -401,6 +320,39 @@ 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)) +} + +/** 余额数值格式化:≥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", { minimumFractionDigits: 2, 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) + formatBalanceAmount(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 }) + : formatBalanceAmount(base.total) + return balanceSymbol(pref || base.currency) + shown +} + // --------------------------------------------------------------------------- // Sidebar component // --------------------------------------------------------------------------- @@ -413,8 +365,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 @@ -425,6 +377,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 @@ -434,6 +389,11 @@ 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 + /** 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 @@ -483,7 +443,7 @@ function TokenCachePanel(props: { const { currencySymbol, setCurrencySymbol, exchangeRate, setExchangeRate, - langZH, setLangZH, + langCode, sectionDetail, setSectionDetail, sectionModel, setSectionModel, sectionDist, setSectionDist, @@ -492,12 +452,14 @@ function TokenCachePanel(props: { balanceRefresh, balanceProviderId, setBalanceProviderId, autoBalance, setAutoBalance, + balanceUnsupported, setBalanceUnsupported, + balanceState, balanceCurrency, setBalanceCurrency, 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 @@ -526,54 +488,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 (!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 的响应式更新(模型切换时该链路可能不触发重算)。 @@ -599,9 +516,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) } }) @@ -646,11 +569,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), 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) @@ -668,7 +591,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 || "" @@ -731,11 +655,11 @@ 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 || (t.cache?.read ?? 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) + // 取最后一条有数据消息的总输入(含缓存读/写)作为当前 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 {} @@ -808,6 +732,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`) @@ -828,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") { - setLangZH(savedLang === "zh") - } // 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`) @@ -880,8 +800,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 ── @@ -913,12 +832,14 @@ 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(() => { 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())) @@ -964,7 +885,7 @@ function TokenCachePanel(props: { setOpen((o) => { const n = !o; persistFold("open", n); return n })}> {open() ? "\u25bc " : "\u25b6 "} - {t().title} + {t("title")} v{PLUGIN_VERSION} @@ -972,18 +893,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} - 0 ? pal().success : pal().error) : pal().text }}> + {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")} @@ -991,7 +912,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 ( @@ -1006,7 +927,7 @@ function TokenCachePanel(props: { {sep()} {"> "} - {t().noData} + {t("noData")} }> @@ -1014,11 +935,11 @@ function TokenCachePanel(props: { {/* hit rate + bar — inline to avoid box spacing */} - {t().hit} + {t("hit")} [{bar()}] {pct()} - 0 ? pal().success : pal().error) : pal().text }}> + = 0.05 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }}> {" "}{trendLabel(data().trend)} @@ -1026,38 +947,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), 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())} @@ -1068,34 +990,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"))} @@ -1107,37 +1029,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"))} @@ -1148,18 +1070,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"))} ) })} @@ -1167,62 +1089,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})` : "") - })()} - - - - {(() => { - 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)} - - ) - })()} + + + + {"> "} + {t("balNoKey", { 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()))} + + @@ -1235,6 +1141,166 @@ 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 = createT(() => props.signals.langCode()) + + const sid = props.sessionId + + // ── 命中率(单条口径:最后一条有 token 的 assistant 消息)+ token 汇总 ── + 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 write = session?.tokens?.cache?.write ?? 0 + // 旧 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) + 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) + num(tk.cache?.write) + const mrt = num(tk.cache?.read) + if (mit <= 0) continue + const rate = (mrt / mit) * 100 + if (hitRate < 0) { hitRate = rate; continue } + prevHitRate = rate + break + } + return { hitRate, prevHitRate, input, read, write } + }) + + // 余额查询状态为共享信号(PanelSignals.balanceState),由 tui() 统一轮询 + + // 自动切换 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) { + 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) + } + }) + + // ── 主题色(与侧边栏同口径)── + 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 + }) + + // 命中率趋势:最后一条与上一条的差值;|Δ| < 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 = 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" + return "-" + }) + + // 路径显示(替换宿主默认 hint 左侧的 cwd 文本) + const directory = createMemo(() => { + 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()} + + + {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()!.write) : "--"} + + + {" \u00b7 " + t("barBal") + " "} + {balanceText()} + + + {" \u00b7 "} + + + + ) +} + function createSidebarSlot(api: TuiPluginApi, signals: PanelSignals): TuiSlotPlugin { let lastSlotSid = "" return { @@ -1271,26 +1337,39 @@ 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) + 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) + // ── 余额查询状态(共享):侧边栏与底部栏读同一份数据, + // 避免重复请求导致两处余额不一致 ── + const [balanceState, setBalanceState] = createSignal({ + status: "idle", data: null, lastFetch: 0, + }) + // 请求序号:防止定时轮询与手动刷新并发时,慢的旧请求覆盖新结果 + let balanceSeq = 0 + const signals: PanelSignals = { currencySymbol, setCurrencySymbol, exchangeRate, setExchangeRate, - langZH, setLangZH, + langCode, setLangCode, sectionDetail, setSectionDetail, sectionModel, setSectionModel, sectionDist, setSectionDist, sectionSkills, setSectionSkills, sectionBalance, setSectionBalance, + sectionBottom, setSectionBottom, balanceRefresh, setBalanceRefresh, balanceProviderId, setBalanceProviderId, autoBalance, setAutoBalance, + balanceUnsupported, setBalanceUnsupported, + balanceState, balanceCurrency, setBalanceCurrency, borderVisible, setBorderVisible, overrideSessionId, setOverrideSessionId, @@ -1298,31 +1377,120 @@ 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" + // ── 语言偏好恢复: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) + 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() + 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 = 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) => { @@ -1338,9 +1506,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() }} @@ -1364,6 +1532,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) @@ -1373,7 +1542,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: t("currencySet", { v: opt.value, s: sym, r: defRate }) }) dialog?.clear() }} /> @@ -1393,11 +1562,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: `Exchange rate set to ${n}` }) + api.ui.toast({ message: t("rateSet", { r: n }) }) } dialog?.clear() }} @@ -1411,29 +1581,42 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { description: "Show or hide a sidebar section", slash: { name: "cache-section" }, onSelect: (dialog) => { + 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)) 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)) + 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)) @@ -1443,7 +1626,9 @@ 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) - api.ui.toast({ message: `${opt.value} section ${!cur ? "shown" : "hidden"}` }) + if (opt.value === "bottom") signals.setSectionBottom(!cur) + const name = labels[opt.value] ?? opt.value + api.ui.toast({ message: t(!cur ? "sectionShown" : "sectionHidden", { s: name }) }) } dialog?.clear() }} @@ -1457,6 +1642,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { description: "Display the current plugin configuration", slash: { name: "cache-config" }, onSelect: (dialog) => { + 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)) @@ -1464,9 +1650,16 @@ 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)) + 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"}`, + 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() @@ -1478,19 +1671,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 ? "语言已切换为中文" : "Switched to English" }) + const code = opt.value as LangCode + api.kv.set(`${KV_PREFIX}.lang`, code) + setLangCode(code) + api.ui.toast({ message: t("langSwitched") }) dialog?.clear() }} /> @@ -1503,15 +1697,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: zh ? `自动切换余额提供商: ${next ? "开" : "关"}` : `Auto-switch balance provider: ${next ? "ON" : "OFF"}` }) + api.ui.toast({ message: next ? t("autoSwitchOn") : t("autoSwitchOff") }) dialog?.clear() } else { const provider = getBalanceProvider(opt.value) @@ -1536,6 +1728,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`, "") @@ -1543,7 +1736,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: t("providerManual", { p: provider.name }) }) dialog?.clear() } } @@ -1558,11 +1751,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, @@ -1589,9 +1782,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: "Please run this command inside a session", variant: "warning" }) + api.ui.toast({ message: t("runInSession"), variant: "warning" }) return } const sid = String(rt.params.sessionID) @@ -1669,7 +1863,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}`, @@ -1678,24 +1872,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 ? "已切回主会话" : "Switched to main session" }) + api.ui.toast({ message: 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: t("subAgentSwitched", { s: opt.value.slice(0, 24) + "\u2026" }) }) } dialog?.clear() }} @@ -1703,11 +1897,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) => { @@ -1715,7 +1909,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: t("subAgentSwitched", { s: sid.slice(0, 24) + "\u2026" }) }) } dialog?.clear() }} @@ -1731,9 +1925,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() ? "已切回主会话" : "Switched to main session" }) + api.ui.toast({ message: t("backToMain") }) dialog?.clear() }, },