opencode-visual-cache 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -50,7 +50,8 @@
50
50
  - **折叠记忆**:折叠状态持久化,重启后保持
51
51
  - **语言适配**:自动检测系统语言,支持 `/cache-lang` 运行时切换中/英文,偏好持久化
52
52
  - **多币种**:通过 `/cache-currency` 切换货币,费用和节省同步换算
53
- - **斜杠命令**:`/cache-rate` `/cache-section` `/cache-config` `/cache-lang` 动态配置面板
53
+ - **斜杠命令**:`/cache-session` `/cache-session-back` `/cache-rate` `/cache-section` `/cache-config` `/cache-lang` 动态配置面板
54
+ - **子代理缓存查看**:`/cache-session` 自动扫描并列出子代理,选择一个即可切换面板显示其缓存统计,支持 `/cache-session-back` 返回主会话
54
55
  - **已加载技能**:检测 session 中 LLM 调用 `skill` tool 的记录,展示已加载技能名及估算 Token 占用
55
56
 
56
57
  ---
@@ -100,6 +101,8 @@ npm install -g opencode-visual-cache@latest
100
101
 
101
102
  | 命令 | 功能 | 使用方式 |
102
103
  |------|------|---------|
104
+ | `/cache-session` | 查看子代理缓存统计 | 自动列出子代理供选择,或手动粘贴 Session ID 切换面板数据源 |
105
+ | `/cache-session-back` | 返回主会话统计 | 从子代理缓存视图切回主会话 |
103
106
  | `/cache-currency` | 切换货币单位 | 从列表选择货币(USD / CNY / EUR / JPY / GBP / KRW),自动填入默认汇率 |
104
107
  | `/cache-rate` | 调整汇率乘数 | 输入自定义汇率(如 `7.2`),用于费用换算 |
105
108
  | `/cache-section` | 开关区块与边框 | 独立控制 Token 明细 / 模型与定价 / 估算 Token 分布 / 已加载技能 / 面板边框的显隐 |
package/README_EN.md CHANGED
@@ -50,7 +50,8 @@ Interested in sub-agent monitoring? Check out [opencode-subagent-magazine](https
50
50
  - **Persistent State**: Fold preferences and config remembered across restarts via api.kv
51
51
  - **Language**: Auto-detects system locale, with `/cache-lang` for runtime switching between Chinese and English — preference persisted across restarts
52
52
  - **Multi-currency**: Switch via `/cache-currency` — costs, savings, and per-million rates convert in real time
53
- - **Slash Commands**: `/cache-rate` `/cache-section` `/cache-config` `/cache-lang` for live panel configuration
53
+ - **Slash Commands**: `/cache-session` `/cache-session-back` `/cache-rate` `/cache-section` `/cache-config` `/cache-lang` for live panel configuration
54
+ - **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
54
55
  - **Loaded Skills**: Detects `skill` tool calls in the session and displays loaded skill names with estimated token footprint
55
56
 
56
57
  ---
@@ -100,6 +101,8 @@ The plugin supports slash commands and command palette (`Ctrl + P`) for runtime
100
101
 
101
102
  | Command | Function | How to use |
102
103
  |---------|----------|------------|
104
+ | `/cache-session` | View sub-agent cache stats | Lists sub-agents automatically, or paste a Session ID to switch the panel data source |
105
+ | `/cache-session-back` | Return to main session | Switch back to main session from sub-agent cache view |
103
106
  | `/cache-currency` | Switch currency | Pick from a list (USD / CNY / EUR / JPY / GBP / KRW); default exchange rate auto-filled |
104
107
  | `/cache-rate` | Adjust exchange rate | Enter a custom rate (e.g. `7.2` for CNY) |
105
108
  | `/cache-section` | Toggle sections & border | Independently show/hide Detail, Model & Pricing, Token Distribution, Loaded Skills, or the panel border |
@@ -1 +1 @@
1
- export declare const PLUGIN_VERSION = "1.3.0";
1
+ export declare const PLUGIN_VERSION = "1.4.0";
package/dist/_version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // auto-generated
2
- export const PLUGIN_VERSION = "1.3.0";
2
+ export const PLUGIN_VERSION = "1.4.0";
package/dist/index.js CHANGED
@@ -94,6 +94,14 @@ const ZH_T = {
94
94
  secDetail: "明细",
95
95
  secModel: "模型",
96
96
  secSkills: "已加载技能",
97
+ balTotal: "总余额:",
98
+ balNoKey: "未配置 API Key",
99
+ balLoading: "查询中...",
100
+ balError: "查询失败",
101
+ balErr401: "API Key 无效",
102
+ balErr403: "余额查询被拒绝",
103
+ balErrEmpty: "未获取到余额数据",
104
+ balErrTimeout: "查询超时",
97
105
  };
98
106
  const EN_T = {
99
107
  title: "Token Cache",
@@ -125,6 +133,14 @@ const EN_T = {
125
133
  secDetail: "Detail",
126
134
  secModel: "Model",
127
135
  secSkills: "Loaded Skills",
136
+ balTotal: "Total:",
137
+ balNoKey: "No API Key set",
138
+ balLoading: "Fetching...",
139
+ balError: "Fetch failed",
140
+ balErr401: "Invalid API Key",
141
+ balErr403: "Balance request rejected",
142
+ balErrEmpty: "No balance data",
143
+ balErrTimeout: "Request timed out",
128
144
  };
129
145
  // ── color helpers ────────────────────────────────────────────────
130
146
  /** Extract { r, g, b } (0–255) from a hex string or RGBA-like object. */
@@ -299,6 +315,44 @@ function estimateTokens(text) {
299
315
  const asciiPerToken = jsonLike ? 3.5 : codeLike ? 3.5 : 4;
300
316
  return Math.max(1, Math.ceil(ascii / asciiPerToken + cjk / 1.0));
301
317
  }
318
+ const BALANCE_POLL_MS = 5 * 60 * 1000; // 5 minutes
319
+ async function fetchDeepSeekBalance(apiKey, signal) {
320
+ const res = await fetch("https://api.deepseek.com/user/balance", {
321
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
322
+ signal,
323
+ });
324
+ if (!res.ok) {
325
+ if (res.status === 401)
326
+ throw new Error("401");
327
+ if (res.status === 402 || res.status === 403)
328
+ throw new Error("403");
329
+ throw new Error(String(res.status));
330
+ }
331
+ const json = await res.json();
332
+ const infos = json.balance_infos ?? [];
333
+ if (infos.length === 0)
334
+ throw new Error("EMPTY");
335
+ return infos.map((info) => ({
336
+ currency: info.currency ?? "CNY",
337
+ total: info.total_balance ?? "0",
338
+ }));
339
+ }
340
+ /**
341
+ * 将余额从来源币种换算为目标币种。
342
+ * DEFAULT_RATES 以 USD=1 为基准:先折算为 USD,再换算到目标币种。
343
+ */
344
+ function convertBalance(target, targetRate, amount, from) {
345
+ if (from === target)
346
+ return amount;
347
+ const fromRate = DEFAULT_RATES[from] ?? 1;
348
+ const usd = from === "USD" ? amount : amount / fromRate;
349
+ return target === "USD" ? usd : usd * targetRate;
350
+ }
351
+ /** 货币符号:优先取 /cache-currency 内置映射,未知币种回退为代码。 */
352
+ function balanceSymbol(currency) {
353
+ const sym = CURRENCIES[currency];
354
+ return sym ?? currency + " ";
355
+ }
302
356
  const CURRENCIES = {
303
357
  USD: "$", CNY: "¥", EUR: "€", JPY: "JP¥", GBP: "£", KRW: "₩",
304
358
  };
@@ -325,7 +379,7 @@ function TokenCachePanel(props) {
325
379
  const [skillsOpen, setSkillsOpen] = createSignal(true);
326
380
  let boxEl;
327
381
  // ── shared signals (de-structured so internal code is unchanged) ──
328
- const { currencySymbol, setCurrencySymbol, exchangeRate, setExchangeRate, langZH, setLangZH, sectionDetail, setSectionDetail, sectionModel, setSectionModel, sectionDist, setSectionDist, sectionSkills, setSectionSkills, borderVisible, setBorderVisible, } = props.signals;
382
+ const { currencySymbol, setCurrencySymbol, exchangeRate, setExchangeRate, langZH, setLangZH, sectionDetail, setSectionDetail, sectionModel, setSectionModel, sectionDist, setSectionDist, sectionSkills, setSectionSkills, sectionBalance, setSectionBalance, balanceRefresh, balanceCurrency, setBalanceCurrency, borderVisible, setBorderVisible, } = props.signals;
329
383
  // ── reactive translation (follows langZH signal) ──
330
384
  const t = createMemo(() => langZH() ? ZH_T : EN_T);
331
385
  // ── scan session messages reactively ──
@@ -352,6 +406,52 @@ function TokenCachePanel(props) {
352
406
  hasSkills: false,
353
407
  });
354
408
  const [refreshTick, setRefreshTick] = createSignal(0);
409
+ // ── balance state + polling ──────────────────────────────────
410
+ const [balanceState, setBalanceState] = createSignal({
411
+ status: "idle", data: null, lastFetch: 0,
412
+ });
413
+ // 请求序号:防止定时轮询与手动刷新并发时,慢的旧请求覆盖新结果
414
+ let balanceSeq = 0;
415
+ const pollBalance = async () => {
416
+ const key = props.api.kv.get(`${KV_PREFIX}.ds_key`, "");
417
+ if (!key) {
418
+ setBalanceState({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined });
419
+ return;
420
+ }
421
+ const now = Date.now();
422
+ const prev = balanceState();
423
+ // key 已更换(重新输入)→ 强制重新查询,绕过缓存
424
+ if (prev.status === "ok" && prev.key === key && now - prev.lastFetch < BALANCE_POLL_MS)
425
+ return; // cache still fresh
426
+ const seq = ++balanceSeq;
427
+ setBalanceState({ ...prev, status: "loading", error: undefined, key });
428
+ const controller = new AbortController();
429
+ let timedOut = false;
430
+ const timer = setTimeout(() => { timedOut = true; controller.abort(); }, 10_000);
431
+ try {
432
+ const data = await fetchDeepSeekBalance(key, controller.signal);
433
+ clearTimeout(timer);
434
+ if (seq !== balanceSeq)
435
+ return; // 已被更新的请求取代,丢弃过期结果
436
+ setBalanceState({ status: "ok", data, lastFetch: Date.now(), error: undefined, key });
437
+ }
438
+ catch (err) {
439
+ clearTimeout(timer);
440
+ if (seq !== balanceSeq)
441
+ return;
442
+ const code = timedOut ? "TIMEOUT" : (err instanceof Error ? err.message : "");
443
+ // 失败时清空旧数据,避免显示过期余额
444
+ setBalanceState({ status: "error", data: null, lastFetch: 0, error: code, key });
445
+ }
446
+ };
447
+ // Re-fetch when the API key is (re)configured via /cache-balance-key.
448
+ // 注意:pollBalance 内部读写 balanceState 信号,若不做 untrack 包裹,
449
+ // effect 会追踪 balanceState 的变化并与 pollBalance 的 setBalanceState
450
+ // 形成无限循环(每次重跑都发起新的 fetch 请求)。
451
+ createEffect(() => {
452
+ void balanceRefresh();
453
+ untrack(() => { void pollBalance(); });
454
+ });
355
455
  // ── auto-clear override when the user navigates to a different main session ──
356
456
  let lastMainSid = props.sessionId;
357
457
  createEffect(() => {
@@ -602,10 +702,14 @@ function TokenCachePanel(props) {
602
702
  setCurrencySymbol(sym);
603
703
  if (typeof rate === "number" && rate > 0)
604
704
  setExchangeRate(rate);
705
+ const balCur = props.api.kv.get(`${KV_PREFIX}.balance_currency`);
706
+ if (typeof balCur === "string")
707
+ setBalanceCurrency(balCur);
605
708
  setSectionDetail(Boolean(props.api.kv.get(`${KV_PREFIX}.section.detail`, true)));
606
709
  setSectionModel(Boolean(props.api.kv.get(`${KV_PREFIX}.section.model`, true)));
607
710
  setSectionDist(Boolean(props.api.kv.get(`${KV_PREFIX}.section.dist`, true)));
608
711
  setSectionSkills(Boolean(props.api.kv.get(`${KV_PREFIX}.section.skills`, true)));
712
+ setSectionBalance(Boolean(props.api.kv.get(`${KV_PREFIX}.section.balance`, true)));
609
713
  const bv = props.api.kv.get(`${KV_PREFIX}.border`, true);
610
714
  setBorderVisible(bv !== false);
611
715
  // Restore language preference
@@ -663,7 +767,8 @@ function TokenCachePanel(props) {
663
767
  const unsubMsg = props.api.event.on("message.updated", () => { bumpPartVersion(); setRefreshTick(v => v + 1); });
664
768
  const unsubSession = props.api.event.on("session.updated", () => { setRefreshTick(v => v + 1); });
665
769
  setRefreshTick(v => v + 1);
666
- onCleanup(() => { clearTimeout(partTimer); unsubPart(); unsubMsg(); unsubSession(); });
770
+ const balanceTimer = setInterval(pollBalance, BALANCE_POLL_MS);
771
+ onCleanup(() => { clearTimeout(partTimer); clearInterval(balanceTimer); unsubPart(); unsubMsg(); unsubSession(); });
667
772
  });
668
773
  // ── colours ──
669
774
  // Pull from the current theme, auto-desaturate if too punchy,
@@ -732,7 +837,36 @@ function TokenCachePanel(props) {
732
837
  const maxLabel = Math.max(4, panelWidth() - gutter() - rightW - 1);
733
838
  const label = truncateVisual(sk.name, maxLabel);
734
839
  return (_jsx("text", { fg: pal().muted, children: justify(label, fmt(sk.tokens), t().tok) }));
735
- }) })] }) })] })] })] }));
840
+ }) })] }) }), _jsxs(Show, { when: sectionBalance(), children: [_jsx("text", { fg: pal().muted, children: sep() }), _jsx(Show, { when: balanceState().status === "idle", children: _jsxs("text", { fg: pal().muted, children: [_jsx("span", { style: { fg: pal().muted }, children: "> " }), _jsx("span", { children: t().balNoKey })] }) }), _jsx(Show, { when: balanceState().status === "loading", children: _jsxs("text", { fg: pal().muted, children: [_jsx("span", { style: { fg: pal().muted }, children: "> " }), _jsx("span", { children: t().balLoading })] }) }), _jsx(Show, { when: balanceState().status === "error", children: _jsxs("text", { fg: pal().error, children: [_jsx("span", { style: { fg: pal().muted }, children: "> " }), _jsx("span", { children: (() => {
841
+ const code = balanceState().error;
842
+ if (code === "401")
843
+ return t().balErr401;
844
+ if (code === "403")
845
+ return t().balErr403;
846
+ if (code === "EMPTY")
847
+ return t().balErrEmpty;
848
+ if (code === "TIMEOUT")
849
+ return t().balErrTimeout;
850
+ return t().balError + (code ? ` (${code})` : "");
851
+ })() })] }) }), _jsx(Show, { when: balanceState().status === "ok" && balanceState().data, children: (() => {
852
+ const list = balanceState().data;
853
+ const pref = balanceCurrency();
854
+ // 偏好币种是 DeepSeek 原生返回的(CNY/USD)→ 直接显示
855
+ const native = pref ? list.find(x => x.currency === pref) : undefined;
856
+ if (native) {
857
+ return (_jsx("text", { fg: pal().text, children: justify(t().balTotal, balanceSymbol(native.currency) + native.total) }));
858
+ }
859
+ // 非原生币种(EUR/JPY/GBP/KRW…)→ 取第一条余额按汇率换算
860
+ const base = list[0];
861
+ const baseAmt = parseFloat(base.total);
862
+ const converted = Number.isFinite(baseAmt)
863
+ ? convertBalance(pref || base.currency, exchangeRate(), baseAmt, base.currency)
864
+ : baseAmt;
865
+ const shown = pref && base.currency !== pref
866
+ ? converted.toLocaleString("en-US", { maximumFractionDigits: 2 })
867
+ : base.total;
868
+ return (_jsx("text", { fg: pal().text, children: justify(t().balTotal, balanceSymbol(pref || base.currency) + shown) }));
869
+ })() })] })] })] })] }));
736
870
  }
737
871
  // ---------------------------------------------------------------------------
738
872
  // Plugin entry
@@ -764,6 +898,9 @@ const tui = async (api) => {
764
898
  const [sectionModel, setSectionModel] = createSignal(true);
765
899
  const [sectionDist, setSectionDist] = createSignal(true);
766
900
  const [sectionSkills, setSectionSkills] = createSignal(true);
901
+ const [sectionBalance, setSectionBalance] = createSignal(true);
902
+ const [balanceRefresh, setBalanceRefresh] = createSignal(0);
903
+ const [balanceCurrency, setBalanceCurrency] = createSignal("");
767
904
  const [borderVisible, setBorderVisible] = createSignal(true);
768
905
  const [langZH, setLangZH] = createSignal(LANG_ZH);
769
906
  const [overrideSessionId, setOverrideSessionId] = createSignal(undefined);
@@ -775,6 +912,9 @@ const tui = async (api) => {
775
912
  sectionModel, setSectionModel,
776
913
  sectionDist, setSectionDist,
777
914
  sectionSkills, setSectionSkills,
915
+ sectionBalance, setSectionBalance,
916
+ balanceRefresh, setBalanceRefresh,
917
+ balanceCurrency, setBalanceCurrency,
778
918
  borderVisible, setBorderVisible,
779
919
  overrideSessionId, setOverrideSessionId,
780
920
  };
@@ -796,6 +936,9 @@ const tui = async (api) => {
796
936
  const defRate = DEFAULT_RATES[opt.value] ?? 1;
797
937
  api.kv.set(`${KV_PREFIX}.currency`, sym);
798
938
  api.kv.set(`${KV_PREFIX}.rate`, defRate);
939
+ // 同步余额显示币种偏好:CNY/USD 原生直显,其余币种按汇率换算
940
+ api.kv.set(`${KV_PREFIX}.balance_currency`, opt.value);
941
+ signals.setBalanceCurrency(opt.value);
799
942
  signals.setCurrencySymbol(sym);
800
943
  signals.setExchangeRate(defRate);
801
944
  api.ui.toast({ message: `Currency: ${opt.value} (${sym}), rate: ${defRate}` });
@@ -830,12 +973,14 @@ const tui = async (api) => {
830
973
  const modelOn = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true));
831
974
  const distOn = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true));
832
975
  const skillsOn = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true));
976
+ const balanceOn = Boolean(api.kv.get(`${KV_PREFIX}.section.balance`, true));
833
977
  const borderOn = Boolean(api.kv.get(`${KV_PREFIX}.border`, true));
834
978
  dialog?.replace(() => (_jsx(api.ui.DialogSelect, { title: "Toggle Section", options: [
835
979
  { title: `Token Detail [${detailOn ? "ON" : "OFF"}]`, value: "detail" },
836
980
  { title: `Model & Pricing [${modelOn ? "ON" : "OFF"}]`, value: "model" },
837
981
  { title: `Token Dist. [${distOn ? "ON" : "OFF"}]`, value: "dist" },
838
982
  { title: `Loaded Skills [${skillsOn ? "ON" : "OFF"}]`, value: "skills" },
983
+ { title: `DS Balance [${balanceOn ? "ON" : "OFF"}]`, value: "balance" },
839
984
  { title: `Panel Border [${borderOn ? "ON" : "OFF"}]`, value: "border" },
840
985
  ], onSelect: (opt) => {
841
986
  if (opt.value === "border") {
@@ -856,6 +1001,8 @@ const tui = async (api) => {
856
1001
  signals.setSectionDist(!cur);
857
1002
  if (opt.value === "skills")
858
1003
  signals.setSectionSkills(!cur);
1004
+ if (opt.value === "balance")
1005
+ signals.setSectionBalance(!cur);
859
1006
  api.ui.toast({ message: `${opt.value} section ${!cur ? "shown" : "hidden"}` });
860
1007
  }
861
1008
  dialog?.clear();
@@ -874,9 +1021,10 @@ const tui = async (api) => {
874
1021
  const model = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true));
875
1022
  const dist = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true));
876
1023
  const skills = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true));
1024
+ const balance = Boolean(api.kv.get(`${KV_PREFIX}.section.balance`, true));
877
1025
  api.ui.toast({
878
1026
  title: "Cache Panel Config",
879
- message: `Currency: ${sym} | Rate: ${rate} | Detail: ${detail ? "ON" : "OFF"} | Model: ${model ? "ON" : "OFF"} | Dist: ${dist ? "ON" : "OFF"} | Skills: ${skills ? "ON" : "OFF"}`,
1027
+ 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"}`,
880
1028
  duration: 8000,
881
1029
  });
882
1030
  dialog?.clear();
@@ -901,6 +1049,50 @@ const tui = async (api) => {
901
1049
  } })));
902
1050
  },
903
1051
  },
1052
+ {
1053
+ title: "Cache: Set DeepSeek API Key",
1054
+ value: "cache.balance.key",
1055
+ description: "Set or update the DeepSeek API key for balance display",
1056
+ slash: { name: "cache-balance-key" },
1057
+ onSelect: (dialog) => {
1058
+ const zh = langZH();
1059
+ const current = api.kv.get(`${KV_PREFIX}.ds_key`, "");
1060
+ // 已保存的 key 以脱敏形式预填:保留 "sk-" 前缀 + 头 5 尾 5 字符,中间用 * 填充
1061
+ const maskKey = (k) => {
1062
+ if (!k)
1063
+ return "";
1064
+ const prefix = k.startsWith("sk-") ? "sk-" : "";
1065
+ const body = prefix ? k.slice(3) : k;
1066
+ if (body.length <= 10)
1067
+ return prefix + body.slice(0, 5) + "*".repeat(Math.max(3, body.length - 5));
1068
+ return prefix + body.slice(0, 5) + "*".repeat(Math.max(3, body.length - 10)) + body.slice(-5);
1069
+ };
1070
+ const masked = maskKey(current);
1071
+ dialog?.replace(() => (_jsx(api.ui.DialogPrompt, { title: zh ? "DeepSeek API Key" : "DeepSeek API Key", description: () => _jsx("text", { children: zh ? "输入 DeepSeek API Key 以显示账户余额(留空清除)" : "Enter your DeepSeek API key to show account balance (leave empty to clear)" }), placeholder: "sk-...", value: masked, onConfirm: (val) => {
1072
+ const input = val.trim();
1073
+ // 空 → 清除;含 * (脱敏占位符残留)→ 视为未修改,保留原 key;否则为新 key
1074
+ let key;
1075
+ if (input === "") {
1076
+ key = "";
1077
+ }
1078
+ else if (input.includes("*")) {
1079
+ key = current;
1080
+ }
1081
+ else {
1082
+ key = input;
1083
+ }
1084
+ api.kv.set(`${KV_PREFIX}.ds_key`, key);
1085
+ setBalanceRefresh(v => v + 1);
1086
+ if (key) {
1087
+ api.ui.toast({ message: zh ? "API Key 已保存,正在查询余额..." : "API Key saved, fetching balance..." });
1088
+ }
1089
+ else {
1090
+ api.ui.toast({ message: zh ? "API Key 已清除" : "API Key cleared" });
1091
+ }
1092
+ dialog?.clear();
1093
+ }, onCancel: () => dialog?.clear() })));
1094
+ },
1095
+ },
904
1096
  {
905
1097
  title: "Cache: Debug Skills Detection",
906
1098
  value: "cache.debug-skills",