opencode-visual-cache 1.6.1 → 1.7.0-beta.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.
Files changed (62) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +247 -247
  3. package/README_EN.md +247 -247
  4. package/dist/_version.d.ts +1 -1
  5. package/dist/_version.js +1 -1
  6. package/dist/core/color.d.ts +37 -0
  7. package/dist/core/color.js +108 -0
  8. package/dist/core/currency.d.ts +17 -0
  9. package/dist/core/currency.js +43 -0
  10. package/dist/core/estimate.d.ts +1 -0
  11. package/dist/core/estimate.js +40 -0
  12. package/dist/core/format.d.ts +15 -0
  13. package/dist/core/format.js +90 -0
  14. package/dist/core/index.d.ts +5 -0
  15. package/dist/core/index.js +5 -0
  16. package/dist/core/types.d.ts +17 -0
  17. package/dist/core/types.js +1 -0
  18. package/dist/index.js +159 -815
  19. package/dist/panel/TokenCachePanel.d.ts +10 -0
  20. package/dist/panel/TokenCachePanel.js +549 -0
  21. package/dist/panel/panel-api.d.ts +119 -0
  22. package/dist/panel/panel-api.js +1 -0
  23. package/dist/tui.js +349 -264
  24. package/dist/v2/commands.d.ts +10 -0
  25. package/dist/v2/commands.js +490 -0
  26. package/dist/v2/data.d.ts +27 -0
  27. package/dist/v2/data.js +103 -0
  28. package/dist/v2/index.d.ts +4 -0
  29. package/dist/v2/index.js +163 -0
  30. package/dist/v2/sidebar.d.ts +6 -0
  31. package/dist/v2/sidebar.js +36 -0
  32. package/dist/v2/status.d.ts +14 -0
  33. package/dist/v2/status.js +83 -0
  34. package/dist/v2/theme.d.ts +8 -0
  35. package/dist/v2/theme.js +16 -0
  36. package/dist/v2/types.d.ts +219 -0
  37. package/dist/v2/types.js +6 -0
  38. package/dist/v2/v2-panel-api.d.ts +8 -0
  39. package/dist/v2/v2-panel-api.js +176 -0
  40. package/dist/v2.js +2941 -0
  41. package/package.json +70 -67
  42. package/src/_version.ts +1 -1
  43. package/src/balance-providers.ts +153 -153
  44. package/src/core/color.ts +108 -0
  45. package/src/core/currency.ts +46 -0
  46. package/src/core/estimate.ts +36 -0
  47. package/src/core/format.ts +81 -0
  48. package/src/core/index.ts +5 -0
  49. package/src/core/types.ts +17 -0
  50. package/src/i18n.ts +380 -380
  51. package/src/index.tsx +1035 -1980
  52. package/src/panel/TokenCachePanel.tsx +776 -0
  53. package/src/panel/panel-api.ts +104 -0
  54. package/src/server.ts +10 -10
  55. package/src/v2/commands.ts +471 -0
  56. package/src/v2/data.ts +112 -0
  57. package/src/v2/index.tsx +184 -0
  58. package/src/v2/sidebar.tsx +69 -0
  59. package/src/v2/status.tsx +96 -0
  60. package/src/v2/theme.ts +19 -0
  61. package/src/v2/types.ts +159 -0
  62. package/src/v2/v2-panel-api.ts +177 -0
@@ -0,0 +1,10 @@
1
+ import type { Context, KeymapCommand } from "./types";
2
+ import type { PanelApi, PanelSignals } from "../panel/panel-api";
3
+ import { type BalanceProvider } from "../balance-providers";
4
+ /** V2 版 findOpencodeKey:从 V2 provider list 找 OpenCode 已认证的 key(对齐 V1 api.state.provider)。
5
+ * 导出供 index.tsx 的余额轮询复用。 */
6
+ export declare function findOpencodeKeyV2(context: Context, provider: BalanceProvider): string;
7
+ /** V2 斜杠命令(对齐 V1 10 个命令的完整行为)。
8
+ * keymap.layer 必须在组件渲染上下文调用,因此本函数只构建命令数组,
9
+ * 由渲染组件内 layer(() => ({ commands })) 注册。 */
10
+ export declare function makeCommands(context: Context, api: PanelApi, signals: PanelSignals): KeymapCommand[];
@@ -0,0 +1,490 @@
1
+ import { CURRENCIES, DEFAULT_RATES, visualPadEnd } from "../core";
2
+ import { balanceProviders, getBalanceProvider } from "../balance-providers";
3
+ import { LANG_META, createT } from "../i18n";
4
+ const KV_PREFIX = "cache_panel";
5
+ // ---------------------------------------------------------------------------
6
+ // V2 helpers(对齐 V1 index.tsx 内嵌辅助;API 形态从 V1 组件式切换为 V2 Promise 式)
7
+ // ---------------------------------------------------------------------------
8
+ /** V2 无 api.state.part API——从消息 content 数组提取 tool parts(V2 Tool part 形状)。 */
9
+ function extractToolParts(msg) {
10
+ if (!Array.isArray(msg?.content))
11
+ return [];
12
+ return msg.content.filter((p) => p?.type === "tool");
13
+ }
14
+ /** V2 版 findOpencodeKey:从 V2 provider list 找 OpenCode 已认证的 key(对齐 V1 api.state.provider)。
15
+ * 导出供 index.tsx 的余额轮询复用。 */
16
+ export function findOpencodeKeyV2(context, provider) {
17
+ try {
18
+ const provs = context.data.location.provider.list();
19
+ const id = provider.id.toLowerCase();
20
+ const hit = provs.find((p) => String(p.id ?? "").toLowerCase() === id)
21
+ ?? provs.find((p) => String(p.id ?? "").toLowerCase().startsWith(id));
22
+ if (!hit)
23
+ return "";
24
+ const k = typeof hit.key === "string" ? hit.key : "";
25
+ if (k)
26
+ return k;
27
+ return typeof hit.options?.apiKey === "string" ? hit.options.apiKey : "";
28
+ }
29
+ catch {
30
+ return "";
31
+ }
32
+ }
33
+ /** 当前路由 sessionID(V2 ui.router.current();Route = { type: "session", sessionID })。 */
34
+ function currentSessionID(context) {
35
+ try {
36
+ const rt = context.ui.router.current();
37
+ if (rt?.type === "session" && rt.sessionID)
38
+ return String(rt.sessionID);
39
+ }
40
+ catch { }
41
+ return "";
42
+ }
43
+ /** V2 斜杠命令(对齐 V1 10 个命令的完整行为)。
44
+ * keymap.layer 必须在组件渲染上下文调用,因此本函数只构建命令数组,
45
+ * 由渲染组件内 layer(() => ({ commands })) 注册。 */
46
+ export function makeCommands(context, api, signals) {
47
+ const t = () => createT(() => signals.langCode());
48
+ /** 菜单中 provider 选项标题:标注 key 来源(手动配置 / OpenCode 自动复用 / 未配置)。 */
49
+ const providerOptionTitle = (p, current) => {
50
+ const hasManual = !!api.kv.get(`${KV_PREFIX}.balance.${p.id}.key`, "");
51
+ const hasAuto = !hasManual && !!findOpencodeKeyV2(context, p);
52
+ const mark = hasManual
53
+ ? t()("keyUser")
54
+ : hasAuto
55
+ ? t()("keyOpenCode")
56
+ : t()("keyNotSet");
57
+ return p.name + mark + (current && p.id === current ? " *" : "");
58
+ };
59
+ /** 弹出指定 provider 的 API Key 输入框(空清除 / 含 * 保留原 key / 新 key 实时刷新)。 */
60
+ const promptBalanceKey = async (provider) => {
61
+ const current = api.kv.get(`${KV_PREFIX}.balance.${provider.id}.key`, "") ?? "";
62
+ const val = await context.ui.dialog.prompt({
63
+ title: provider.name,
64
+ message: t()("balKeyPrompt", { p: provider.name }),
65
+ placeholder: provider.keyPlaceholder ?? "sk-...",
66
+ });
67
+ if (val === undefined)
68
+ return; // 取消
69
+ const input = val.trim();
70
+ let key;
71
+ if (input === "") {
72
+ key = "";
73
+ }
74
+ else if (input.includes("*")) {
75
+ key = current;
76
+ }
77
+ else {
78
+ key = input;
79
+ }
80
+ await api.kv.set(`${KV_PREFIX}.balance.${provider.id}.key`, key);
81
+ signals.setBalanceRefresh(signals.balanceRefresh() + 1);
82
+ context.ui.toast.show({ message: key ? t()("keySaved") : t()("keyCleared") });
83
+ };
84
+ return [
85
+ // ── /cache-currency 切换货币 ──
86
+ {
87
+ id: "opencode-visual-cache.cache.currency",
88
+ title: "Cache: Set Currency",
89
+ description: "Change the currency unit for cost display",
90
+ group: "Cache",
91
+ palette: true,
92
+ slash: { name: "cache-currency" },
93
+ run: async () => {
94
+ const opt = await context.ui.dialog.select({
95
+ title: "Select Currency",
96
+ options: Object.entries(CURRENCIES).map(([code, sym]) => ({
97
+ title: `${code} (${sym})`,
98
+ value: code,
99
+ })),
100
+ });
101
+ if (!opt)
102
+ return;
103
+ const sym = CURRENCIES[opt] ?? "$";
104
+ const defRate = DEFAULT_RATES[opt] ?? 1;
105
+ await api.kv.set(`${KV_PREFIX}.currency`, sym);
106
+ await api.kv.set(`${KV_PREFIX}.rate`, defRate);
107
+ // 同步余额显示币种偏好:CNY/USD 原生直显,其余币种按汇率换算
108
+ await api.kv.set(`${KV_PREFIX}.balance_currency`, opt);
109
+ signals.setBalanceCurrency(opt);
110
+ signals.setCurrencySymbol(sym);
111
+ signals.setExchangeRate(defRate);
112
+ context.ui.toast.show({ message: t()("currencySet", { v: opt, s: sym, r: defRate }) });
113
+ },
114
+ },
115
+ // ── /cache-rate 调整汇率 ──
116
+ {
117
+ id: "opencode-visual-cache.cache.rate",
118
+ title: "Cache: Set Exchange Rate",
119
+ description: "Set the exchange rate multiplier for the selected currency",
120
+ group: "Cache",
121
+ palette: true,
122
+ slash: { name: "cache-rate" },
123
+ run: async () => {
124
+ const val = await context.ui.dialog.prompt({
125
+ title: "Exchange Rate",
126
+ message: "Enter the exchange rate from USD to your currency (e.g. 7.2 for CNY)",
127
+ placeholder: "1.0",
128
+ });
129
+ if (val === undefined)
130
+ return;
131
+ const n = parseFloat(val);
132
+ if (n > 0) {
133
+ await api.kv.set(`${KV_PREFIX}.rate`, n);
134
+ signals.setExchangeRate(n);
135
+ context.ui.toast.show({ message: t()("rateSet", { r: n }) });
136
+ }
137
+ },
138
+ },
139
+ // ── /cache-section 开关区块与边框 ──
140
+ {
141
+ id: "opencode-visual-cache.cache.section",
142
+ title: "Cache: Toggle Section",
143
+ description: "Show or hide a sidebar section",
144
+ group: "Cache",
145
+ palette: true,
146
+ slash: { name: "cache-section" },
147
+ run: async () => {
148
+ const detailOn = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true));
149
+ const modelOn = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true));
150
+ const distOn = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true));
151
+ const skillsOn = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true));
152
+ const balanceOn = Boolean(api.kv.get(`${KV_PREFIX}.section.balance`, true));
153
+ const bottomOn = Boolean(api.kv.get(`${KV_PREFIX}.section.bottom`, true));
154
+ const borderOn = Boolean(api.kv.get(`${KV_PREFIX}.border`, true));
155
+ const labels = {
156
+ detail: t()("secDetail"),
157
+ model: t()("secModel"),
158
+ dist: t()("distTitle"),
159
+ skills: t()("secSkills"),
160
+ balance: t()("secBalance"),
161
+ bottom: t()("secBottom"),
162
+ border: t()("secBorder"),
163
+ };
164
+ const optTitle = (label, on) => `${visualPadEnd(label, 15)}[${on ? "ON" : "OFF"}]`;
165
+ const opt = await context.ui.dialog.select({
166
+ title: t()("secToggle"),
167
+ options: [
168
+ { title: optTitle(labels.detail, detailOn), value: "detail" },
169
+ { title: optTitle(labels.model, modelOn), value: "model" },
170
+ { title: optTitle(labels.dist, distOn), value: "dist" },
171
+ { title: optTitle(labels.skills, skillsOn), value: "skills" },
172
+ { title: optTitle(labels.balance, balanceOn), value: "balance" },
173
+ { title: optTitle(labels.bottom, bottomOn), value: "bottom" },
174
+ { title: optTitle(labels.border, borderOn), value: "border" },
175
+ ],
176
+ });
177
+ if (!opt)
178
+ return;
179
+ if (opt === "border") {
180
+ const cur = Boolean(api.kv.get(`${KV_PREFIX}.border`, true));
181
+ await api.kv.set(`${KV_PREFIX}.border`, !cur);
182
+ signals.setBorderVisible(!cur);
183
+ context.ui.toast.show({ message: !cur ? t()("borderShown") : t()("borderHidden") });
184
+ }
185
+ else {
186
+ const key = `${KV_PREFIX}.section.${opt}`;
187
+ const cur = Boolean(api.kv.get(key, true));
188
+ await api.kv.set(key, !cur);
189
+ if (opt === "detail")
190
+ signals.setSectionDetail(!cur);
191
+ if (opt === "model")
192
+ signals.setSectionModel(!cur);
193
+ if (opt === "dist")
194
+ signals.setSectionDist(!cur);
195
+ if (opt === "skills")
196
+ signals.setSectionSkills(!cur);
197
+ if (opt === "balance")
198
+ signals.setSectionBalance(!cur);
199
+ if (opt === "bottom")
200
+ signals.setSectionBottom(!cur);
201
+ const name = labels[opt] ?? opt;
202
+ context.ui.toast.show({ message: t()(!cur ? "sectionShown" : "sectionHidden", { s: name }) });
203
+ }
204
+ },
205
+ },
206
+ // ── /cache-config 查看当前配置 ──
207
+ {
208
+ id: "opencode-visual-cache.cache.config",
209
+ title: "Cache: Show Config",
210
+ description: "Display the current plugin configuration",
211
+ group: "Cache",
212
+ palette: true,
213
+ slash: { name: "cache-config" },
214
+ run: () => {
215
+ const sym = api.kv.get(`${KV_PREFIX}.currency`) ?? "$";
216
+ const rate = api.kv.get(`${KV_PREFIX}.rate`) ?? 1;
217
+ const detail = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true));
218
+ const model = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true));
219
+ const dist = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true));
220
+ const skills = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true));
221
+ const balance = Boolean(api.kv.get(`${KV_PREFIX}.section.balance`, true));
222
+ const bottom = Boolean(api.kv.get(`${KV_PREFIX}.section.bottom`, true));
223
+ const on = (v) => v ? "ON" : "OFF";
224
+ context.ui.toast.show({
225
+ title: t()("panelConfigTitle"),
226
+ message: t()("panelConfigMsg", {
227
+ c: sym, r: rate,
228
+ d: on(detail), m: on(model),
229
+ t: on(dist), k: on(skills),
230
+ b: on(balance), f: on(bottom),
231
+ }),
232
+ });
233
+ },
234
+ },
235
+ // ── /cache-lang 切换显示语言 ──
236
+ {
237
+ id: "opencode-visual-cache.cache.lang",
238
+ title: "Cache: Switch Language",
239
+ description: "Switch display language (Chinese / English / 日本語 / 한국어)",
240
+ group: "Cache",
241
+ palette: true,
242
+ slash: { name: "cache-lang" },
243
+ run: async () => {
244
+ const cur = signals.langCode();
245
+ const opt = await context.ui.dialog.select({
246
+ title: t()("langTitle"),
247
+ options: LANG_META.map((m) => ({
248
+ title: `${visualPadEnd(m.label, 9)}${cur === m.code ? "\u2713" : ""}`,
249
+ value: m.code,
250
+ })),
251
+ });
252
+ if (!opt)
253
+ return;
254
+ await api.kv.set(`${KV_PREFIX}.lang`, opt);
255
+ signals.setLangCode(opt);
256
+ context.ui.toast.show({ message: t()("langSwitched") });
257
+ },
258
+ },
259
+ // ── /cache-balance 切换余额提供商 / 自动切换 ──
260
+ {
261
+ id: "opencode-visual-cache.cache.balance",
262
+ title: "Cache: Switch Balance Provider",
263
+ description: "切换余额提供商 / 自动切换当前会话提供商 | Switch balance provider / auto-switch session provider",
264
+ group: "Cache",
265
+ palette: true,
266
+ slash: { name: "cache-balance" },
267
+ run: async () => {
268
+ const current = signals.balanceProviderId();
269
+ const auto = signals.autoBalance();
270
+ const autoLabel = `${t()("autoSwitchOpt")} [${auto ? "ON" : "OFF"}]`;
271
+ const opt = await context.ui.dialog.select({
272
+ title: t()("balProvTitle"),
273
+ options: [
274
+ { title: autoLabel, value: "__auto__" },
275
+ ...balanceProviders.map((p) => ({
276
+ title: providerOptionTitle(p, current),
277
+ value: p.id,
278
+ })),
279
+ ],
280
+ });
281
+ if (!opt)
282
+ return;
283
+ if (opt === "__auto__") {
284
+ const next = !auto;
285
+ await api.kv.set(`${KV_PREFIX}.balance.auto`, next);
286
+ signals.setAutoBalance(next);
287
+ context.ui.toast.show({ message: next ? t()("autoSwitchOn") : t()("autoSwitchOff") });
288
+ }
289
+ else {
290
+ const provider = getBalanceProvider(opt);
291
+ // 手动切换会关闭自动切换
292
+ await api.kv.set(`${KV_PREFIX}.balance.provider`, provider.id);
293
+ await api.kv.set(`${KV_PREFIX}.balance.auto`, false);
294
+ signals.setBalanceProviderId(provider.id);
295
+ signals.setAutoBalance(false);
296
+ signals.setBalanceUnsupported(false);
297
+ // 切换后立即按新 provider 刷新显示(无 key 时显示 idle,避免残留上一 provider 余额)
298
+ signals.setBalanceRefresh(signals.balanceRefresh() + 1);
299
+ const hasKey = !!api.kv.get(`${KV_PREFIX}.balance.${provider.id}.key`, "");
300
+ if (!hasKey) {
301
+ // 未配置 key → 进入设置流程(对话框保持打开等待输入)
302
+ await promptBalanceKey(provider);
303
+ }
304
+ else {
305
+ context.ui.toast.show({ message: t()("providerManual", { p: provider.name }) });
306
+ }
307
+ }
308
+ },
309
+ },
310
+ // ── /cache-balance-key 设置余额 API Key(两步:选 provider → 输 key)──
311
+ {
312
+ id: "opencode-visual-cache.cache.balance.key",
313
+ title: "Cache: Set Balance API Key",
314
+ description: "Select a provider and set its API key for balance display",
315
+ group: "Cache",
316
+ palette: true,
317
+ slash: { name: "cache-balance-key" },
318
+ run: async () => {
319
+ // 步骤 1:选择 provider
320
+ const opt = await context.ui.dialog.select({
321
+ title: t()("balSelectTitle"),
322
+ options: balanceProviders.map((p) => ({
323
+ title: providerOptionTitle(p),
324
+ value: p.id,
325
+ })),
326
+ });
327
+ if (!opt)
328
+ return;
329
+ const provider = getBalanceProvider(opt);
330
+ // 手动指定 provider 会关闭自动切换
331
+ await api.kv.set(`${KV_PREFIX}.balance.provider`, provider.id);
332
+ await api.kv.set(`${KV_PREFIX}.balance.auto`, false);
333
+ signals.setBalanceProviderId(provider.id);
334
+ signals.setAutoBalance(false);
335
+ // 切换后立即刷新显示(防止取消输入时残留上一 provider 的余额)
336
+ signals.setBalanceRefresh(signals.balanceRefresh() + 1);
337
+ // 步骤 2:输入 key
338
+ await promptBalanceKey(provider);
339
+ },
340
+ },
341
+ // ── /cache-debug-skills 技能检测调试 ──
342
+ {
343
+ id: "opencode-visual-cache.cache.debug-skills",
344
+ title: "Cache: Debug Skills Detection",
345
+ description: "Dump all tool parts found in the current session for skill detection debugging",
346
+ group: "Cache",
347
+ palette: true,
348
+ slash: { name: "cache-debug-skills" },
349
+ run: () => {
350
+ const sid = currentSessionID(context);
351
+ if (!sid) {
352
+ context.ui.toast.show({ message: t()("runInSession"), variant: "warning" });
353
+ return;
354
+ }
355
+ const msgs = api.state.session.messages(sid);
356
+ const byTool = {};
357
+ const skillParts = [];
358
+ for (const msg of msgs) {
359
+ if (msg.role !== "assistant")
360
+ continue;
361
+ for (const p of extractToolParts(msg)) {
362
+ const tool = String(p.tool ?? "?");
363
+ byTool[tool] = (byTool[tool] ?? 0) + 1;
364
+ if (tool === "skill") {
365
+ const meta = p.state?.metadata;
366
+ const rootMeta = p.metadata;
367
+ skillParts.push(`state.metadata=${JSON.stringify(meta)} | root.metadata=${JSON.stringify(rootMeta)} | state.title="${p.state?.title}" | state.output[:80]="${String(p.state?.output ?? "").slice(0, 80)}"`);
368
+ }
369
+ }
370
+ }
371
+ const summary = Object.entries(byTool).map(([k, v]) => `${k}: ${v}`).join(" | ");
372
+ const extra = skillParts.length > 0
373
+ ? "\n\nSkill parts:\n" + skillParts.join("\n")
374
+ : "\n\n⚠ No skill tool parts found — AI may be reading SKILL.md instead. Try: 'Use the skill tool to load karpathy-guidelines'";
375
+ context.ui.toast.show({
376
+ title: `Tool Summary (${Object.keys(byTool).length} types)`,
377
+ message: summary + extra,
378
+ });
379
+ },
380
+ },
381
+ // ── /cache-session 子代理缓存统计 ──
382
+ {
383
+ id: "opencode-visual-cache.cache.session",
384
+ title: "Cache: Sub-Agent Stats",
385
+ description: "View token cache statistics for a sub-agent by session ID",
386
+ group: "Cache",
387
+ palette: true,
388
+ slash: { name: "cache-session" },
389
+ run: async () => {
390
+ // ── 扫描当前主 session 的子代理 session ID 列表 ──
391
+ const parentSid = currentSessionID(context);
392
+ const SUBAGENT_TOOLS = new Set(["task", "delegate", "call_omo_agent"]);
393
+ const children = [];
394
+ if (parentSid) {
395
+ try {
396
+ const msgs = api.state.session.messages(parentSid);
397
+ for (const msg of msgs) {
398
+ if (msg.role !== "assistant")
399
+ continue;
400
+ for (const p of extractToolParts(msg)) {
401
+ const tool = String(p.tool ?? "");
402
+ if (!SUBAGENT_TOOLS.has(tool))
403
+ continue;
404
+ const st = p.state;
405
+ const stMeta = st?.metadata;
406
+ const subSid = stMeta?.session_id ?? stMeta?.sessionId;
407
+ if (!subSid)
408
+ continue;
409
+ const sidStr = String(subSid);
410
+ const input = st?.input;
411
+ const agent = String(p.subagent_type ?? input?.subagent_type ?? input?.category ?? tool);
412
+ const prompt = String(input?.prompt ?? "");
413
+ const desc = input?.description ? String(input.description) : "";
414
+ const title = desc || prompt.replace(/\n/g, " ").replace(/\s+/g, " ").trim().slice(0, 40) || agent;
415
+ children.push({ title, value: sidStr, description: `${agent} · ${sidStr.slice(0, 24)}…` });
416
+ }
417
+ }
418
+ }
419
+ catch { }
420
+ }
421
+ // 去重
422
+ const seen = new Set();
423
+ const unique = children.filter(c => { if (seen.has(c.value))
424
+ return false; seen.add(c.value); return true; });
425
+ if (unique.length > 0) {
426
+ // ── 有子代理 → DialogSelect 列表选择 ──
427
+ const currentSid = signals.overrideSessionId() ?? api.kv.get(`${KV_PREFIX}.session`, "");
428
+ const options = unique.map((c, i) => ({
429
+ title: `${i + 1}. ${c.title}`,
430
+ value: c.value,
431
+ description: c.description,
432
+ }));
433
+ // 首尾各放一个"回到主会话",长列表时顶部底部均可直达
434
+ const backValue = "__main__";
435
+ const backTitle = `\u2500 ${t()("backToMainTitle")}`;
436
+ options.unshift({ title: backTitle, value: backValue, description: "" });
437
+ options.push({ title: backTitle, value: backValue, description: "" });
438
+ const currentIdx = currentSid ? options.findIndex(o => o.value === currentSid) : -1;
439
+ const opt = await context.ui.dialog.select({
440
+ title: t()("subSelectTitle"),
441
+ options,
442
+ current: currentIdx >= 0 ? options[currentIdx].value : undefined,
443
+ });
444
+ if (!opt)
445
+ return;
446
+ if (opt === backValue) {
447
+ signals.setOverrideSessionId(undefined);
448
+ await api.kv.set(`${KV_PREFIX}.session`, "");
449
+ context.ui.toast.show({ message: t()("backToMain") });
450
+ }
451
+ else {
452
+ signals.setOverrideSessionId(opt);
453
+ await api.kv.set(`${KV_PREFIX}.session`, opt);
454
+ context.ui.toast.show({ message: t()("subAgentSwitched", { s: opt.slice(0, 24) + "\u2026" }) });
455
+ }
456
+ }
457
+ else {
458
+ // ── 无子代理 → DialogPrompt 手动粘贴 ──
459
+ const val = await context.ui.dialog.prompt({
460
+ title: signals.overrideSessionId() ? t()("subSwitchTitle") : t()("subViewTitle"),
461
+ message: t()("subNoFound"),
462
+ placeholder: "ses_...",
463
+ });
464
+ if (val === undefined)
465
+ return;
466
+ const sid = val.trim();
467
+ if (sid) {
468
+ signals.setOverrideSessionId(sid);
469
+ await api.kv.set(`${KV_PREFIX}.session`, sid);
470
+ context.ui.toast.show({ message: t()("subAgentSwitched", { s: sid.slice(0, 24) + "\u2026" }) });
471
+ }
472
+ }
473
+ },
474
+ },
475
+ // ── /cache-session-back 返回主会话 ──
476
+ {
477
+ id: "opencode-visual-cache.cache.session.back",
478
+ title: "Cache: Back to Main",
479
+ description: "Return to main session stats",
480
+ group: "Cache",
481
+ palette: true,
482
+ slash: { name: "cache-session-back" },
483
+ run: async () => {
484
+ signals.setOverrideSessionId(undefined);
485
+ await api.kv.set(`${KV_PREFIX}.session`, "");
486
+ context.ui.toast.show({ message: t()("backToMain") });
487
+ },
488
+ },
489
+ ];
490
+ }
@@ -0,0 +1,27 @@
1
+ import type { Context, MessageInfo } from "./types";
2
+ /** 汇总一个会话的 token 用量与费用(V2 adapter → 同构模型)。 */
3
+ export interface SessionStats {
4
+ input: number;
5
+ output: number;
6
+ cacheRead: number;
7
+ cacheWrite: number;
8
+ reasoning: number;
9
+ cost: number;
10
+ hasData: boolean;
11
+ }
12
+ /** 从 messages 汇总 token:统计全部 assistant 消息的 API 精确值。 */
13
+ export declare function collectSessionStats(context: Context, sessionID: string): SessionStats;
14
+ /** 缓存命中率(0–100):缓存读 /(新鲜输入 + 缓存读 + 缓存写),与 V1 口径一致。 */
15
+ export declare function hitRate(stats: SessionStats): number;
16
+ /** 输入侧总量(底部栏口径,不含输出)。 */
17
+ export declare function inputTotal(stats: SessionStats): number;
18
+ /** 单条命中率(V1 底部栏口径):最后一条有 token 的 assistant 消息,
19
+ * 分母含缓存写 read/(input+read+write);prevHitRate 为上一条(趋势用)。 */
20
+ export declare function collectLastHitRate(context: Context, sessionID: string): {
21
+ hitRate: number;
22
+ prevHitRate: number;
23
+ input: number;
24
+ read: number;
25
+ write: number;
26
+ };
27
+ export type { MessageInfo };
@@ -0,0 +1,103 @@
1
+ import { num } from "../core";
2
+ /** 从 messages 汇总 token:统计全部 assistant 消息的 API 精确值。 */
3
+ export function collectSessionStats(context, sessionID) {
4
+ const msgs = context.data.session.message.list(sessionID) ?? [];
5
+ let input = 0;
6
+ let output = 0;
7
+ let cacheRead = 0;
8
+ let cacheWrite = 0;
9
+ let reasoning = 0;
10
+ let cost = 0;
11
+ let hasData = false;
12
+ for (const m of msgs) {
13
+ if (m.type !== "assistant")
14
+ continue;
15
+ const t = m.tokens;
16
+ if (!t)
17
+ continue;
18
+ input += num(t.input);
19
+ output += num(t.output);
20
+ reasoning += num(t.reasoning);
21
+ cacheRead += num(t.cache?.read);
22
+ cacheWrite += num(t.cache?.write);
23
+ hasData = true;
24
+ }
25
+ // 会话级费用优先(V2 提供 cost()),消息级 cost 兜底(结构未知,宽松读取)
26
+ const sessionCost = context.data.session.cost(sessionID);
27
+ if (Number.isFinite(sessionCost) && sessionCost > 0)
28
+ cost = sessionCost;
29
+ else {
30
+ for (const m of msgs) {
31
+ if (m.type !== "assistant" || m.cost == null)
32
+ continue;
33
+ const c = m.cost;
34
+ if (typeof c === "number" && Number.isFinite(c))
35
+ cost += c;
36
+ else {
37
+ const amt = c.amount;
38
+ if (typeof amt === "number" && Number.isFinite(amt))
39
+ cost += amt;
40
+ else if (typeof amt === "string") {
41
+ const n = parseFloat(amt);
42
+ if (Number.isFinite(n))
43
+ cost += n;
44
+ }
45
+ }
46
+ }
47
+ }
48
+ return { input, output, cacheRead, cacheWrite, reasoning, cost, hasData };
49
+ }
50
+ /** 缓存命中率(0–100):缓存读 /(新鲜输入 + 缓存读 + 缓存写),与 V1 口径一致。 */
51
+ export function hitRate(stats) {
52
+ const denom = stats.input + stats.cacheRead + stats.cacheWrite;
53
+ if (denom <= 0)
54
+ return 0;
55
+ return (stats.cacheRead / denom) * 100;
56
+ }
57
+ /** 输入侧总量(底部栏口径,不含输出)。 */
58
+ export function inputTotal(stats) {
59
+ return stats.input + stats.cacheRead + stats.cacheWrite;
60
+ }
61
+ /** 单条命中率(V1 底部栏口径):最后一条有 token 的 assistant 消息,
62
+ * 分母含缓存写 read/(input+read+write);prevHitRate 为上一条(趋势用)。 */
63
+ export function collectLastHitRate(context, sessionID) {
64
+ const msgs = context.data.session.message.list(sessionID) ?? [];
65
+ const session = context.data.session.get(sessionID);
66
+ let input = num(session?.tokens?.input);
67
+ let read = num(session?.tokens?.cache?.read);
68
+ let write = num(session?.tokens?.cache?.write);
69
+ // 无 session 聚合字段 → 遍历消息累加(与 V1 fallback 一致)
70
+ if (session?.tokens == null) {
71
+ for (const m of msgs) {
72
+ if (m.type !== "assistant")
73
+ continue;
74
+ const t = m.tokens;
75
+ if (!t)
76
+ continue;
77
+ input += num(t.input);
78
+ read += num(t.cache?.read);
79
+ write += num(t.cache?.write);
80
+ }
81
+ }
82
+ let hitRate = -1, prevHitRate = -1;
83
+ for (let i = msgs.length - 1; i >= 0; i--) {
84
+ const m = msgs[i];
85
+ if (m.type !== "assistant")
86
+ continue;
87
+ const tk = m.tokens;
88
+ if (!tk)
89
+ continue;
90
+ const mit = num(tk.input) + num(tk.cache?.read) + num(tk.cache?.write);
91
+ const mrt = num(tk.cache?.read);
92
+ if (mit <= 0)
93
+ continue;
94
+ const rate = (mrt / mit) * 100;
95
+ if (hitRate < 0) {
96
+ hitRate = rate;
97
+ continue;
98
+ }
99
+ prevHitRate = rate;
100
+ break;
101
+ }
102
+ return { hitRate, prevHitRate, input, read, write };
103
+ }
@@ -0,0 +1,4 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import type { PluginModule } from "./types";
3
+ declare const mod: PluginModule;
4
+ export default mod;