opencode-visual-cache 1.6.2 → 1.7.0-beta.1

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 +12 -818
  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 +223 -209
  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 +72 -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 -2120
  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,104 @@
1
+ import type { Message, Part, Session } from "@opencode-ai/sdk"
2
+ import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
3
+ import type { BalanceEntry } from "../balance-providers"
4
+ import type { LangCode } from "../i18n"
5
+
6
+ /** 会话信息(面板消费的字段;SDK Session 类型过严,用宽松接口) */
7
+ export interface PanelSession {
8
+ id: string
9
+ title?: string
10
+ agent?: string
11
+ model?: { providerID?: string; id?: string }
12
+ tokens?: { input?: number; output?: number; reasoning?: number; cache?: { read?: number; write?: number } }
13
+ cost?: number
14
+ [key: string]: unknown
15
+ }
16
+
17
+ /**
18
+ * Panel API 契约:TokenCachePanel 消费的宿主 API 子集。
19
+ * V1(TuiPluginApi)天然满足;V2(opencode2 context)由 v2-panel-api 适配实现。
20
+ */
21
+ export interface PanelApi {
22
+ kv: {
23
+ ready: boolean
24
+ get<T>(key: string, fallback?: T): T | undefined
25
+ set(key: string, value: unknown): void | Promise<void>
26
+ }
27
+ state: {
28
+ session: {
29
+ get(id: string): PanelSession | undefined
30
+ /** V1 返回 sdk/v2 的 Message、V2 返回 SessionMessageInfo——统一放宽 */
31
+ messages(id: string): readonly any[]
32
+ }
33
+ provider: readonly Record<string, any>[]
34
+ config: any
35
+ part(messageID: string): readonly any[]
36
+ path: { directory: string }
37
+ }
38
+ event: {
39
+ on(type: string, handler: (event: unknown) => void): () => void
40
+ }
41
+ renderer: { terminalWidth: number }
42
+ keys: { formatBindings(binding: unknown): string | undefined }
43
+ tuiConfig: { keybinds: { get(command: string): unknown } }
44
+ }
45
+
46
+ export interface BalanceState {
47
+ status: "idle" | "loading" | "ok" | "error"
48
+ data: BalanceEntry[] | null
49
+ lastFetch: number
50
+ error?: string
51
+ key?: string // 上次成功/尝试查询所用的 key,用于检测 key 是否更换
52
+ }
53
+
54
+ /** Signals shared between the TUI component and slash commands.
55
+ * Created in the `tui` function scope so they do not survive module reload —
56
+ * the component re-creates them on mount and restores user config from kv. */
57
+ export interface PanelSignals {
58
+ currencySymbol: () => string
59
+ setCurrencySymbol: (v: string) => void
60
+ exchangeRate: () => number
61
+ setExchangeRate: (v: number) => void
62
+ langCode: () => LangCode
63
+ setLangCode: (v: LangCode) => void
64
+ sectionDetail: () => boolean
65
+ setSectionDetail: (v: boolean) => void
66
+ sectionModel: () => boolean
67
+ setSectionModel: (v: boolean) => void
68
+ sectionDist: () => boolean
69
+ setSectionDist: (v: boolean) => void
70
+ sectionSkills: () => boolean
71
+ setSectionSkills: (v: boolean) => void
72
+ sectionBalance: () => boolean
73
+ setSectionBalance: (v: boolean) => void
74
+ /** Bottom status bar (prompt hint line) visibility. */
75
+ sectionBottom: () => boolean
76
+ setSectionBottom: (v: boolean) => void
77
+ /** Increment to force a balance re-fetch. */
78
+ balanceRefresh: () => number
79
+ setBalanceRefresh: (v: number) => void
80
+ /** Currently selected balance provider id (e.g. "deepseek"). */
81
+ balanceProviderId: () => string
82
+ setBalanceProviderId: (v: string) => void
83
+ /** Auto-switch to the session's provider for balance display. Manual switch disables it. */
84
+ autoBalance: () => boolean
85
+ setAutoBalance: (v: boolean) => void
86
+ /** True when the session's provider has no balance adapter (auto mode). Suppresses balance polling. */
87
+ balanceUnsupported: () => boolean
88
+ setBalanceUnsupported: (v: boolean) => void
89
+ /** Shared balance query state — single source of truth for sidebar and bottom bar. */
90
+ balanceState: () => BalanceState
91
+ /** Preferred currency code for balance display (CNY / USD / …). Empty = first entry. */
92
+ balanceCurrency: () => string
93
+ setBalanceCurrency: (v: string) => void
94
+ borderVisible: () => boolean
95
+ setBorderVisible: (v: boolean) => void
96
+ /** When set, the panel renders stats for this session instead of the main one. */
97
+ overrideSessionId: () => string | undefined
98
+ setOverrideSessionId: (v: string | undefined) => void
99
+ /** True while our sidebar panel is mounted — host sidebar is visible (occupies 42 cols). */
100
+ sidebarVisible: () => boolean
101
+ setSidebarVisible: (v: boolean) => void
102
+ }
103
+
104
+ export type { TuiThemeCurrent }
package/src/server.ts CHANGED
@@ -1,10 +1,10 @@
1
- import type { Plugin, PluginModule } from "@opencode-ai/plugin"
2
-
3
- const server: Plugin = async () => ({})
4
-
5
- const mod: PluginModule = {
6
- id: "opencode-visual-cache",
7
- server,
8
- }
9
-
10
- export default mod
1
+ import type { Plugin, PluginModule } from "@opencode-ai/plugin"
2
+
3
+ const server: Plugin = async () => ({})
4
+
5
+ const mod: PluginModule = {
6
+ id: "opencode-visual-cache",
7
+ server,
8
+ }
9
+
10
+ export default mod
@@ -0,0 +1,471 @@
1
+ import type { Context, KeymapCommand } from "./types"
2
+ import type { PanelApi, PanelSignals } from "../panel/panel-api"
3
+ import { CURRENCIES, DEFAULT_RATES, visualPadEnd } from "../core"
4
+ import { balanceProviders, getBalanceProvider, maskKey, type BalanceProvider } from "../balance-providers"
5
+ import { LANG_META, createT, type LangCode } from "../i18n"
6
+
7
+ const KV_PREFIX = "cache_panel"
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // V2 helpers(对齐 V1 index.tsx 内嵌辅助;API 形态从 V1 组件式切换为 V2 Promise 式)
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /** V2 无 api.state.part API——从消息 content 数组提取 tool parts(V2 Tool part 形状)。 */
14
+ function extractToolParts(msg: Record<string, any>): Array<Record<string, any>> {
15
+ if (!Array.isArray(msg?.content)) return []
16
+ return msg.content.filter((p: Record<string, any>) => p?.type === "tool")
17
+ }
18
+
19
+ /** V2 版 findOpencodeKey:从 V2 provider list 找 OpenCode 已认证的 key(对齐 V1 api.state.provider)。
20
+ * 导出供 index.tsx 的余额轮询复用。 */
21
+ export function findOpencodeKeyV2(context: Context, provider: BalanceProvider): string {
22
+ try {
23
+ const provs = context.data.location.provider.list() as Array<{ id?: string; key?: string; options?: { apiKey?: string } }>
24
+ const id = provider.id.toLowerCase()
25
+ const hit = provs.find((p) => String(p.id ?? "").toLowerCase() === id)
26
+ ?? provs.find((p) => String(p.id ?? "").toLowerCase().startsWith(id))
27
+ if (!hit) return ""
28
+ const k = typeof hit.key === "string" ? hit.key : ""
29
+ if (k) return k
30
+ return typeof hit.options?.apiKey === "string" ? hit.options.apiKey : ""
31
+ } catch {
32
+ return ""
33
+ }
34
+ }
35
+
36
+ /** 当前路由 sessionID(V2 ui.router.current();Route = { type: "session", sessionID })。 */
37
+ function currentSessionID(context: Context): string {
38
+ try {
39
+ const rt = context.ui.router.current()
40
+ if (rt?.type === "session" && rt.sessionID) return String(rt.sessionID)
41
+ } catch {}
42
+ return ""
43
+ }
44
+
45
+ /** V2 斜杠命令(对齐 V1 10 个命令的完整行为)。
46
+ * keymap.layer 必须在组件渲染上下文调用,因此本函数只构建命令数组,
47
+ * 由渲染组件内 layer(() => ({ commands })) 注册。 */
48
+ export function makeCommands(context: Context, api: PanelApi, signals: PanelSignals): KeymapCommand[] {
49
+ const t = () => createT(() => signals.langCode())
50
+
51
+ /** 菜单中 provider 选项标题:标注 key 来源(手动配置 / OpenCode 自动复用 / 未配置)。 */
52
+ const providerOptionTitle = (p: BalanceProvider, current?: string) => {
53
+ const hasManual = !!api.kv.get<string>(`${KV_PREFIX}.balance.${p.id}.key`, "")
54
+ const hasAuto = !hasManual && !!findOpencodeKeyV2(context, p)
55
+ const mark = hasManual
56
+ ? t()("keyUser")
57
+ : hasAuto
58
+ ? t()("keyOpenCode")
59
+ : t()("keyNotSet")
60
+ return p.name + mark + (current && p.id === current ? " *" : "")
61
+ }
62
+
63
+ /** 弹出指定 provider 的 API Key 输入框(空清除 / 含 * 保留原 key / 新 key 实时刷新)。 */
64
+ const promptBalanceKey = async (provider: BalanceProvider): Promise<void> => {
65
+ const current = api.kv.get<string>(`${KV_PREFIX}.balance.${provider.id}.key`, "") ?? ""
66
+ const val = await context.ui.dialog.prompt({
67
+ title: provider.name,
68
+ message: t()("balKeyPrompt", { p: provider.name }),
69
+ placeholder: provider.keyPlaceholder ?? "sk-...",
70
+ })
71
+ if (val === undefined) return // 取消
72
+ const input = val.trim()
73
+ let key: string
74
+ if (input === "") {
75
+ key = ""
76
+ } else if (input.includes("*")) {
77
+ key = current
78
+ } else {
79
+ key = input
80
+ }
81
+ await api.kv.set(`${KV_PREFIX}.balance.${provider.id}.key`, key)
82
+ signals.setBalanceRefresh(signals.balanceRefresh() + 1)
83
+ context.ui.toast.show({ message: key ? t()("keySaved") : t()("keyCleared") })
84
+ }
85
+
86
+ return [
87
+ // ── /cache-currency 切换货币 ──
88
+ {
89
+ id: "opencode-visual-cache.cache.currency",
90
+ title: "Cache: Set Currency",
91
+ description: "Change the currency unit for cost display",
92
+ group: "Cache",
93
+ palette: true,
94
+ slash: { name: "cache-currency" },
95
+ run: async () => {
96
+ const opt = await context.ui.dialog.select<string>({
97
+ title: "Select Currency",
98
+ options: Object.entries(CURRENCIES).map(([code, sym]) => ({
99
+ title: `${code} (${sym})`,
100
+ value: code,
101
+ })),
102
+ })
103
+ if (!opt) return
104
+ const sym = CURRENCIES[opt] ?? "$"
105
+ const defRate = DEFAULT_RATES[opt] ?? 1
106
+ await api.kv.set(`${KV_PREFIX}.currency`, sym)
107
+ await api.kv.set(`${KV_PREFIX}.rate`, defRate)
108
+ // 同步余额显示币种偏好:CNY/USD 原生直显,其余币种按汇率换算
109
+ await api.kv.set(`${KV_PREFIX}.balance_currency`, opt)
110
+ signals.setBalanceCurrency(opt)
111
+ signals.setCurrencySymbol(sym)
112
+ signals.setExchangeRate(defRate)
113
+ context.ui.toast.show({ message: t()("currencySet", { v: opt, s: sym, r: defRate }) })
114
+ },
115
+ },
116
+ // ── /cache-rate 调整汇率 ──
117
+ {
118
+ id: "opencode-visual-cache.cache.rate",
119
+ title: "Cache: Set Exchange Rate",
120
+ description: "Set the exchange rate multiplier for the selected currency",
121
+ group: "Cache",
122
+ palette: true,
123
+ slash: { name: "cache-rate" },
124
+ run: async () => {
125
+ const val = await context.ui.dialog.prompt({
126
+ title: "Exchange Rate",
127
+ message: "Enter the exchange rate from USD to your currency (e.g. 7.2 for CNY)",
128
+ placeholder: "1.0",
129
+ })
130
+ if (val === undefined) 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: Record<string, string> = {
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: string, on: boolean) => `${visualPadEnd(label, 15)}[${on ? "ON" : "OFF"}]`
165
+ const opt = await context.ui.dialog.select<string>({
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) return
178
+ if (opt === "border") {
179
+ const cur = Boolean(api.kv.get(`${KV_PREFIX}.border`, true))
180
+ await api.kv.set(`${KV_PREFIX}.border`, !cur)
181
+ signals.setBorderVisible(!cur)
182
+ context.ui.toast.show({ message: !cur ? t()("borderShown") : t()("borderHidden") })
183
+ } else {
184
+ const key = `${KV_PREFIX}.section.${opt}`
185
+ const cur = Boolean(api.kv.get(key, true))
186
+ await api.kv.set(key, !cur)
187
+ if (opt === "detail") signals.setSectionDetail(!cur)
188
+ if (opt === "model") signals.setSectionModel(!cur)
189
+ if (opt === "dist") signals.setSectionDist(!cur)
190
+ if (opt === "skills") signals.setSectionSkills(!cur)
191
+ if (opt === "balance") signals.setSectionBalance(!cur)
192
+ if (opt === "bottom") signals.setSectionBottom(!cur)
193
+ const name = labels[opt] ?? opt
194
+ context.ui.toast.show({ message: t()(!cur ? "sectionShown" : "sectionHidden", { s: name }) })
195
+ }
196
+ },
197
+ },
198
+ // ── /cache-config 查看当前配置 ──
199
+ {
200
+ id: "opencode-visual-cache.cache.config",
201
+ title: "Cache: Show Config",
202
+ description: "Display the current plugin configuration",
203
+ group: "Cache",
204
+ palette: true,
205
+ slash: { name: "cache-config" },
206
+ run: () => {
207
+ const sym = api.kv.get<string>(`${KV_PREFIX}.currency`) ?? "$"
208
+ const rate = api.kv.get<number>(`${KV_PREFIX}.rate`) ?? 1
209
+ const detail = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true))
210
+ const model = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true))
211
+ const dist = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true))
212
+ const skills = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true))
213
+ const balance = Boolean(api.kv.get(`${KV_PREFIX}.section.balance`, true))
214
+ const bottom = Boolean(api.kv.get(`${KV_PREFIX}.section.bottom`, true))
215
+ const on = (v: boolean) => v ? "ON" : "OFF"
216
+ context.ui.toast.show({
217
+ title: t()("panelConfigTitle"),
218
+ message: t()("panelConfigMsg", {
219
+ c: sym, r: rate,
220
+ d: on(detail), m: on(model),
221
+ t: on(dist), k: on(skills),
222
+ b: on(balance), f: on(bottom),
223
+ }),
224
+ })
225
+ },
226
+ },
227
+ // ── /cache-lang 切换显示语言 ──
228
+ {
229
+ id: "opencode-visual-cache.cache.lang",
230
+ title: "Cache: Switch Language",
231
+ description: "Switch display language (Chinese / English / 日本語 / 한국어)",
232
+ group: "Cache",
233
+ palette: true,
234
+ slash: { name: "cache-lang" },
235
+ run: async () => {
236
+ const cur = signals.langCode()
237
+ const opt = await context.ui.dialog.select<LangCode>({
238
+ title: t()("langTitle"),
239
+ options: LANG_META.map((m) => ({
240
+ title: `${visualPadEnd(m.label, 9)}${cur === m.code ? "\u2713" : ""}`,
241
+ value: m.code,
242
+ })),
243
+ })
244
+ if (!opt) return
245
+ await api.kv.set(`${KV_PREFIX}.lang`, opt)
246
+ signals.setLangCode(opt)
247
+ context.ui.toast.show({ message: t()("langSwitched") })
248
+ },
249
+ },
250
+ // ── /cache-balance 切换余额提供商 / 自动切换 ──
251
+ {
252
+ id: "opencode-visual-cache.cache.balance",
253
+ title: "Cache: Switch Balance Provider",
254
+ description: "切换余额提供商 / 自动切换当前会话提供商 | Switch balance provider / auto-switch session provider",
255
+ group: "Cache",
256
+ palette: true,
257
+ slash: { name: "cache-balance" },
258
+ run: async () => {
259
+ const current = signals.balanceProviderId()
260
+ const auto = signals.autoBalance()
261
+ const autoLabel = `${t()("autoSwitchOpt")} [${auto ? "ON" : "OFF"}]`
262
+ const opt = await context.ui.dialog.select<string>({
263
+ title: t()("balProvTitle"),
264
+ options: [
265
+ { title: autoLabel, value: "__auto__" },
266
+ ...balanceProviders.map((p) => ({
267
+ title: providerOptionTitle(p, current),
268
+ value: p.id,
269
+ })),
270
+ ],
271
+ })
272
+ if (!opt) return
273
+ if (opt === "__auto__") {
274
+ const next = !auto
275
+ await api.kv.set(`${KV_PREFIX}.balance.auto`, next)
276
+ signals.setAutoBalance(next)
277
+ context.ui.toast.show({ message: next ? t()("autoSwitchOn") : t()("autoSwitchOff") })
278
+ } else {
279
+ const provider = getBalanceProvider(opt)
280
+ // 手动切换会关闭自动切换
281
+ await api.kv.set(`${KV_PREFIX}.balance.provider`, provider.id)
282
+ await api.kv.set(`${KV_PREFIX}.balance.auto`, false)
283
+ signals.setBalanceProviderId(provider.id)
284
+ signals.setAutoBalance(false)
285
+ signals.setBalanceUnsupported(false)
286
+ // 切换后立即按新 provider 刷新显示(无 key 时显示 idle,避免残留上一 provider 余额)
287
+ signals.setBalanceRefresh(signals.balanceRefresh() + 1)
288
+ const hasKey = !!api.kv.get<string>(`${KV_PREFIX}.balance.${provider.id}.key`, "")
289
+ if (!hasKey) {
290
+ // 未配置 key → 进入设置流程(对话框保持打开等待输入)
291
+ await promptBalanceKey(provider)
292
+ } else {
293
+ context.ui.toast.show({ message: t()("providerManual", { p: provider.name }) })
294
+ }
295
+ }
296
+ },
297
+ },
298
+ // ── /cache-balance-key 设置余额 API Key(两步:选 provider → 输 key)──
299
+ {
300
+ id: "opencode-visual-cache.cache.balance.key",
301
+ title: "Cache: Set Balance API Key",
302
+ description: "Select a provider and set its API key for balance display",
303
+ group: "Cache",
304
+ palette: true,
305
+ slash: { name: "cache-balance-key" },
306
+ run: async () => {
307
+ // 步骤 1:选择 provider
308
+ const opt = await context.ui.dialog.select<string>({
309
+ title: t()("balSelectTitle"),
310
+ options: balanceProviders.map((p) => ({
311
+ title: providerOptionTitle(p),
312
+ value: p.id,
313
+ })),
314
+ })
315
+ if (!opt) return
316
+ const provider = getBalanceProvider(opt)
317
+ // 手动指定 provider 会关闭自动切换
318
+ await api.kv.set(`${KV_PREFIX}.balance.provider`, provider.id)
319
+ await api.kv.set(`${KV_PREFIX}.balance.auto`, false)
320
+ signals.setBalanceProviderId(provider.id)
321
+ signals.setAutoBalance(false)
322
+ // 切换后立即刷新显示(防止取消输入时残留上一 provider 的余额)
323
+ signals.setBalanceRefresh(signals.balanceRefresh() + 1)
324
+ // 步骤 2:输入 key
325
+ await promptBalanceKey(provider)
326
+ },
327
+ },
328
+ // ── /cache-debug-skills 技能检测调试 ──
329
+ {
330
+ id: "opencode-visual-cache.cache.debug-skills",
331
+ title: "Cache: Debug Skills Detection",
332
+ description: "Dump all tool parts found in the current session for skill detection debugging",
333
+ group: "Cache",
334
+ palette: true,
335
+ slash: { name: "cache-debug-skills" },
336
+ run: () => {
337
+ const sid = currentSessionID(context)
338
+ if (!sid) {
339
+ context.ui.toast.show({ message: t()("runInSession"), variant: "warning" })
340
+ return
341
+ }
342
+ const msgs = api.state.session.messages(sid)
343
+ const byTool: Record<string, number> = {}
344
+ const skillParts: string[] = []
345
+ for (const msg of msgs) {
346
+ if (msg.role !== "assistant") continue
347
+ for (const p of extractToolParts(msg)) {
348
+ const tool = String(p.tool ?? "?")
349
+ byTool[tool] = (byTool[tool] ?? 0) + 1
350
+ if (tool === "skill") {
351
+ const meta = p.state?.metadata
352
+ const rootMeta = p.metadata
353
+ 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)}"`)
354
+ }
355
+ }
356
+ }
357
+ const summary = Object.entries(byTool).map(([k, v]) => `${k}: ${v}`).join(" | ")
358
+ const extra = skillParts.length > 0
359
+ ? "\n\nSkill parts:\n" + skillParts.join("\n")
360
+ : "\n\n⚠ No skill tool parts found — AI may be reading SKILL.md instead. Try: 'Use the skill tool to load karpathy-guidelines'"
361
+ context.ui.toast.show({
362
+ title: `Tool Summary (${Object.keys(byTool).length} types)`,
363
+ message: summary + extra,
364
+ })
365
+ },
366
+ },
367
+ // ── /cache-session 子代理缓存统计 ──
368
+ {
369
+ id: "opencode-visual-cache.cache.session",
370
+ title: "Cache: Sub-Agent Stats",
371
+ description: "View token cache statistics for a sub-agent by session ID",
372
+ group: "Cache",
373
+ palette: true,
374
+ slash: { name: "cache-session" },
375
+ run: async () => {
376
+ // ── 扫描当前主 session 的子代理 session ID 列表 ──
377
+ const parentSid = currentSessionID(context)
378
+ const SUBAGENT_TOOLS = new Set(["task", "delegate", "call_omo_agent"])
379
+
380
+ interface ChildEntry { title: string; value: string; description: string }
381
+ const children: ChildEntry[] = []
382
+ if (parentSid) {
383
+ try {
384
+ const msgs = api.state.session.messages(parentSid)
385
+ for (const msg of msgs) {
386
+ if (msg.role !== "assistant") continue
387
+ for (const p of extractToolParts(msg)) {
388
+ const tool = String(p.tool ?? "")
389
+ if (!SUBAGENT_TOOLS.has(tool)) continue
390
+ const st = p.state as Record<string, unknown> | undefined
391
+ const stMeta = st?.metadata as Record<string, unknown> | undefined
392
+ const subSid = stMeta?.session_id ?? stMeta?.sessionId
393
+ if (!subSid) continue
394
+ const sidStr = String(subSid)
395
+ const input = st?.input as Record<string, unknown> | undefined
396
+ const agent = String(p.subagent_type ?? input?.subagent_type ?? input?.category ?? tool)
397
+ const prompt = String(input?.prompt ?? "")
398
+ const desc = input?.description ? String(input.description) : ""
399
+ const title = desc || prompt.replace(/\n/g, " ").replace(/\s+/g, " ").trim().slice(0, 40) || agent
400
+ children.push({ title, value: sidStr, description: `${agent} · ${sidStr.slice(0, 24)}…` })
401
+ }
402
+ }
403
+ } catch {}
404
+ }
405
+
406
+ // 去重
407
+ const seen = new Set<string>()
408
+ const unique = children.filter(c => { if (seen.has(c.value)) return false; seen.add(c.value); return true })
409
+
410
+ if (unique.length > 0) {
411
+ // ── 有子代理 → DialogSelect 列表选择 ──
412
+ const currentSid = signals.overrideSessionId() ?? api.kv.get<string>(`${KV_PREFIX}.session`, "")
413
+ const options = unique.map((c, i) => ({
414
+ title: `${i + 1}. ${c.title}`,
415
+ value: c.value,
416
+ description: c.description,
417
+ }))
418
+ // 首尾各放一个"回到主会话",长列表时顶部底部均可直达
419
+ const backValue = "__main__"
420
+ const backTitle = `\u2500 ${t()("backToMainTitle")}`
421
+ options.unshift({ title: backTitle, value: backValue, description: "" })
422
+ options.push({ title: backTitle, value: backValue, description: "" })
423
+ const currentIdx = currentSid ? options.findIndex(o => o.value === currentSid) : -1
424
+ const opt = await context.ui.dialog.select<string>({
425
+ title: t()("subSelectTitle"),
426
+ options,
427
+ current: currentIdx >= 0 ? options[currentIdx].value : undefined,
428
+ })
429
+ if (!opt) return
430
+ if (opt === backValue) {
431
+ signals.setOverrideSessionId(undefined)
432
+ await api.kv.set(`${KV_PREFIX}.session`, "")
433
+ context.ui.toast.show({ message: t()("backToMain") })
434
+ } else {
435
+ signals.setOverrideSessionId(opt)
436
+ await api.kv.set(`${KV_PREFIX}.session`, opt)
437
+ context.ui.toast.show({ message: t()("subAgentSwitched", { s: opt.slice(0, 24) + "\u2026" }) })
438
+ }
439
+ } else {
440
+ // ── 无子代理 → DialogPrompt 手动粘贴 ──
441
+ const val = await context.ui.dialog.prompt({
442
+ title: signals.overrideSessionId() ? t()("subSwitchTitle") : t()("subViewTitle"),
443
+ message: t()("subNoFound"),
444
+ placeholder: "ses_...",
445
+ })
446
+ if (val === undefined) return
447
+ const sid = val.trim()
448
+ if (sid) {
449
+ signals.setOverrideSessionId(sid)
450
+ await api.kv.set(`${KV_PREFIX}.session`, sid)
451
+ context.ui.toast.show({ message: t()("subAgentSwitched", { s: sid.slice(0, 24) + "\u2026" }) })
452
+ }
453
+ }
454
+ },
455
+ },
456
+ // ── /cache-session-back 返回主会话 ──
457
+ {
458
+ id: "opencode-visual-cache.cache.session.back",
459
+ title: "Cache: Back to Main",
460
+ description: "Return to main session stats",
461
+ group: "Cache",
462
+ palette: true,
463
+ slash: { name: "cache-session-back" },
464
+ run: async () => {
465
+ signals.setOverrideSessionId(undefined)
466
+ await api.kv.set(`${KV_PREFIX}.session`, "")
467
+ context.ui.toast.show({ message: t()("backToMain") })
468
+ },
469
+ },
470
+ ]
471
+ }