opencode-visual-cache 1.6.2 → 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 +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 +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 -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,776 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+
3
+ import type { JSX } from "@opentui/solid"
4
+ import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
5
+ import type { UserMessage, AssistantMessage, Message } from "@opencode-ai/sdk"
6
+ import type { Part, TextPart, ToolPart, FilePart, ReasoningPart } from "@opencode-ai/sdk/v2"
7
+ import { createMemo, createSignal, createEffect, onMount, onCleanup, Show, For, untrack } from "solid-js"
8
+ import { balanceProviders, getBalanceProvider, maskKey, matchBalanceProvider, type BalanceEntry, type BalanceProvider } from "../balance-providers"
9
+ import { createT, type LangCode } from "../i18n"
10
+ import { MAX_SAT, FALLBACK, desaturateTo, dimColor, fmt, fmtCost, num, estimateTokens, progressBar, visualWidth, visualPadEnd, truncateVisual, formatBalanceText, type TokenDist } from "../core"
11
+ import { PLUGIN_VERSION } from "../_version"
12
+ import type { PanelApi, PanelSignals } from "./panel-api"
13
+
14
+ const MIN_PANEL_WIDTH = 20
15
+ const DEFAULT_PANEL_WIDTH = 26
16
+
17
+ /** ── layout measurement constants (visual columns) ── */
18
+ const LABEL_GAP = 1 // label(如 "Hit")后面的空格
19
+ const BAR_BRACKETS = 2 // "[" + "]" 包围进度条
20
+ const BAR_GAP = 1 // "]" 后面的空格
21
+ const PCT_FIXED_WIDTH = 5 // "XX.X%" 固定 5 字符宽度
22
+ const HEADER_PREFIX = 2 // 折叠态标题行:▼/▶ 图标 + 图标后空格
23
+ const UNIT_GAP = 1 // 数值与单位前的空格(如 " tok")
24
+
25
+ export function TokenCachePanel(props: {
26
+ theme: TuiThemeCurrent
27
+ api: PanelApi
28
+ sessionId: string
29
+ signals: PanelSignals
30
+ }): JSX.Element {
31
+ const [panelWidth, setPanelWidth] = createSignal(DEFAULT_PANEL_WIDTH)
32
+ const [open, setOpen] = createSignal(true)
33
+ const [detailOpen, setDetailOpen] = createSignal(true)
34
+ const [modelOpen, setModelOpen] = createSignal(true)
35
+ const [distOpen, setDistOpen] = createSignal(false)
36
+ const [skillsOpen, setSkillsOpen] = createSignal(true)
37
+ let boxEl: any
38
+
39
+ // 侧边栏可见性通知:本面板挂载 ⇒ 宿主侧边栏可见(固定占用 42 列输入框宽度)
40
+ createEffect(() => {
41
+ props.signals.setSidebarVisible(true)
42
+ onCleanup(() => props.signals.setSidebarVisible(false))
43
+ })
44
+
45
+ // ── shared signals (de-structured so internal code is unchanged) ──
46
+ const {
47
+ currencySymbol, setCurrencySymbol,
48
+ exchangeRate, setExchangeRate,
49
+ langCode,
50
+ sectionDetail, setSectionDetail,
51
+ sectionModel, setSectionModel,
52
+ sectionDist, setSectionDist,
53
+ sectionSkills, setSectionSkills,
54
+ sectionBalance, setSectionBalance,
55
+ balanceRefresh,
56
+ balanceProviderId, setBalanceProviderId,
57
+ autoBalance, setAutoBalance,
58
+ balanceUnsupported, setBalanceUnsupported,
59
+ balanceState,
60
+ balanceCurrency, setBalanceCurrency,
61
+ borderVisible, setBorderVisible,
62
+ } = props.signals
63
+
64
+ // ── reactive translation (follows langCode signal) ──
65
+ const t = createT(() => langCode())
66
+
67
+ // ── scan session messages reactively ──
68
+ // SolidJS createMemo re-evaluates whenever the underlying
69
+ // api.state.session state changes — no event listener needed.
70
+
71
+ // ── distribution cache ────────────────────────────────────────
72
+ // When data() re-computes before api.state.part() is warm (e.g. after
73
+ // a view switch), hasDistData flips to false and the distribution
74
+ // block disappears. Keep the last valid snapshot so the UI stays
75
+ // stable until the next successful computation arrives.
76
+ const [lastDist, setLastDist] = createSignal<TokenDist>({
77
+ system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0,
78
+ output: 0, reasoning: 0, apiOutput: 0, apiInput: 0, stepCost: 0, stepCount: 0,
79
+ })
80
+ const [lastHasDist, setLastHasDist] = createSignal(false)
81
+
82
+ const [dataSignal, setDataSignal] = createSignal<any>({
83
+ hitRate: 0, read: 0, write: 0, freshInput: 0, output: 0,
84
+ cost: 0, saved: 0, model: "", inputRate: 0, cacheReadRate: 0, cacheWriteRate: 0,
85
+ hasPricing: false, hasData: false, trend: 0, hasTrendData: false,
86
+ providerName: "", sessionHitRate: 0,
87
+ dist: { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, reasoning: 0, apiOutput: 0, apiInput: 0, stepCost: 0, stepCount: 0 },
88
+ hasDistData: false,
89
+ skills: [] as { name: string; tokens: number }[],
90
+ hasSkills: false,
91
+ })
92
+ const [refreshTick, setRefreshTick] = createSignal(0)
93
+
94
+ // 当前 provider 显示名(余额查询状态为共享信号,见 PanelSignals.balanceState)
95
+ const providerName = createMemo(() => getBalanceProvider(balanceProviderId()).name)
96
+
97
+ // 自动切换当前会话的 provider(前缀匹配)。手动切换会关闭此行为。
98
+ // 直接追踪 messages 取最后一条 assistant 消息的 providerID——
99
+ // 不依赖 session.model 的响应式更新(模型切换时该链路可能不触发重算)。
100
+ createEffect(() => {
101
+ if (!autoBalance()) return
102
+ const sid = props.signals.overrideSessionId() ?? props.sessionId
103
+ const msgs = props.api.state.session.messages(sid) as Message[]
104
+ let pid = ""
105
+ for (let i = msgs.length - 1; i >= 0; i--) {
106
+ const m = msgs[i]
107
+ if (m.role === "assistant" && (m as AssistantMessage).providerID) {
108
+ pid = (m as AssistantMessage).providerID
109
+ break
110
+ }
111
+ }
112
+ // 会话尚无 assistant 消息(新会话 / 刚切换模型未对话 / 消息未加载)
113
+ // → 回退到会话级模型元数据,反映当前正在使用的 provider
114
+ if (!pid) {
115
+ try {
116
+ const session = props.api.state.session.get(sid)
117
+ pid = session?.model?.providerID ?? ""
118
+ } catch { /* ignore */ }
119
+ }
120
+ if (!pid) return
121
+ const hit = matchBalanceProvider(pid)
122
+ if (hit) {
123
+ setBalanceUnsupported(false)
124
+ if (hit.id !== balanceProviderId()) {
125
+ setBalanceProviderId(hit.id)
126
+ props.signals.setBalanceRefresh(props.signals.balanceRefresh() + 1)
127
+ }
128
+ } else {
129
+ // 当前提供商没有余额适配器 → 标记不支持,余额显示 N/A 并停止轮询
130
+ setBalanceUnsupported(true)
131
+ }
132
+ })
133
+
134
+ // ── auto-clear override when the user navigates to a different main session ──
135
+ let lastMainSid = props.sessionId
136
+ createEffect(() => {
137
+ const sid = props.sessionId
138
+ if (sid !== lastMainSid) {
139
+ lastMainSid = sid
140
+ if (props.signals.overrideSessionId()) {
141
+ props.signals.setOverrideSessionId(undefined)
142
+ props.api.kv.set(`${KV_PREFIX}.session`, "")
143
+ }
144
+ }
145
+ })
146
+
147
+ createEffect(() => {
148
+ const sid = props.signals.overrideSessionId() ?? props.sessionId
149
+ void refreshTick()
150
+ void partVersion()
151
+
152
+ // 自然追踪 messages 和 provider(SDK 数据就绪时自动重新执行)
153
+ const msgs = props.api.state.session.messages(sid) as Message[]
154
+ const session = typeof props.api.state.session.get === "function"
155
+ ? props.api.state.session.get(sid)
156
+ : undefined
157
+
158
+ // 累计值优先使用 Session 聚合字段(数据库级,不受 sync 层 limit:100 截断)
159
+ // 若字段不存在(旧版本 SDK),降级到消息遍历累加
160
+ let input = session?.tokens?.input ?? 0
161
+ let read = session?.tokens?.cache?.read ?? 0
162
+ let write = session?.tokens?.cache?.write ?? 0
163
+ let output = session?.tokens?.output ?? 0
164
+ let cost = session?.cost ?? 0
165
+ let pid = session?.model?.providerID ?? ""
166
+ let mid = session?.model?.id ?? ""
167
+
168
+ const fallbackTokens = session?.tokens == null
169
+ const fallbackCost = session?.cost == null
170
+ const fallbackModel = !pid || !mid
171
+
172
+ let prevMsgHitRate = -1, lastMsgHitRate = -1
173
+ for (const msg of msgs) {
174
+ if (msg.role !== "assistant") continue
175
+ const tok = (msg as AssistantMessage).tokens; if (!tok) continue
176
+ const mit = num(tok.input) + num(tok.cache?.read) + num(tok.cache?.write), mrt = num(tok.cache?.read)
177
+ if (mit > 0) { prevMsgHitRate = lastMsgHitRate; lastMsgHitRate = (mrt / mit) * 100 }
178
+ if (fallbackTokens) {
179
+ input += num(tok.input); read += num(tok.cache?.read); write += num(tok.cache?.write); output += num(tok.output)
180
+ }
181
+ if (fallbackCost) {
182
+ cost += num((msg as AssistantMessage).cost)
183
+ }
184
+ if (fallbackModel && (msg as AssistantMessage).providerID && (msg as AssistantMessage).modelID) {
185
+ pid = (msg as AssistantMessage).providerID; mid = (msg as AssistantMessage).modelID
186
+ }
187
+ }
188
+ let saved = 0, inputRate = 0, cacheReadRate = 0, cacheWriteRate = 0
189
+ if (read > 0 && pid && mid && Array.isArray(props.api.state.provider)) for (const provider of props.api.state.provider) {
190
+ if (provider.id !== pid) continue
191
+ const model = provider.models[mid]; if (!model?.cost) continue
192
+ inputRate = num(model.cost.input); cacheReadRate = num(model.cost.cache?.read); cacheWriteRate = num(model.cost.cache?.write)
193
+ if (inputRate > cacheReadRate) saved = (read * (inputRate - cacheReadRate)) / 1_000_000
194
+ break
195
+ }
196
+ const hitRate = lastMsgHitRate >= 0 ? lastMsgHitRate : 0
197
+ // 总命中率分母含缓存写(业界口径:read / (input+read+write))
198
+ const freshTotal = input + read + write, sessionHitRate = freshTotal > 0 ? (read / freshTotal) * 100 : 0
199
+ const model = mid.split("/").pop() ?? mid, hasPricing = inputRate > 0 || cacheReadRate > 0 || cacheWriteRate > 0
200
+ const hasTrendData = prevMsgHitRate >= 0 && lastMsgHitRate >= 0
201
+ const trend = hasTrendData ? lastMsgHitRate - prevMsgHitRate : 0, providerName = pid || ""
202
+
203
+ // untrack 只包裹已知触发死锁的 API
204
+ const distData = untrack(() => {
205
+ let dist: TokenDist = { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, reasoning: 0, apiOutput: 0, apiInput: 0, stepCost: 0, stepCount: 0 }
206
+ let hasDistData = false
207
+ const loadedSkills = new Map<string, { name: string; tokens: number }>()
208
+ try {
209
+ const cfg = props.api.state.config as Record<string, unknown>
210
+ const agentName = String(session?.agent ?? (cfg as any)?.default_agent ?? "build")
211
+ const agents = cfg?.agent as Record<string, unknown> | undefined
212
+ const agentCfg = agents?.[agentName] as Record<string, unknown> | undefined
213
+ const sysPrompt = typeof agentCfg?.prompt === "string" ? agentCfg.prompt : ""
214
+ if (sysPrompt) dist.system = estimateTokens(sysPrompt)
215
+ let lastAssMsg: AssistantMessage | undefined
216
+ for (const msg of msgs) {
217
+ if (msg.role === "user") {
218
+ const um = msg as UserMessage; if (um.system) dist.system += estimateTokens(um.system)
219
+ let parts: readonly Part[] = []; try { parts = props.api.state.part(msg.id) } catch {}
220
+ for (const p of parts) {
221
+ if (p.type === "text" && !(p as any).synthetic && !(p as any).ignored) dist.user += estimateTokens((p as any).text)
222
+ else if (p.type === "file") { const fp = p as any; if (fp.source?.text?.value) dist.user += estimateTokens(fp.source.text.value) }
223
+ }
224
+ } else if (msg.role === "assistant") {
225
+ const am = msg as AssistantMessage
226
+ dist.output += num(am.tokens?.output)
227
+ dist.reasoning += num(am.tokens?.reasoning)
228
+ let parts: readonly Part[] = []; try { parts = props.api.state.part(msg.id) } catch {}
229
+ for (const p of parts) {
230
+ if (p.type === "tool") {
231
+ const tp = p as any; let rawInput = ""
232
+ try { rawInput = tp.state.raw ?? (tp.state.input != null ? JSON.stringify(tp.state.input) : "") } catch {}
233
+ if (rawInput) dist.toolCall += estimateTokens(rawInput)
234
+ // 子代理委托(task 工具):任务描述计入子代理指令(1.15.x 无 subtask part)
235
+ if (tp.tool === "task" && tp.state?.input) {
236
+ const ti = tp.state.input
237
+ const prompt = typeof ti.prompt === "string" ? ti.prompt : ""
238
+ const desc = typeof ti.description === "string" ? ti.description : ""
239
+ dist.agent += estimateTokens(prompt || desc)
240
+ }
241
+ if (tp.state.status === "completed") { const c = tp.state; if (c.output) dist.toolResult += estimateTokens(c.output) }
242
+ else if (tp.state.status === "error") { const e = tp.state; if (e.error) dist.toolResult += estimateTokens(e.error) }
243
+ if (tp.tool === "skill" && tp.state.status === "completed") {
244
+ // TUI SDK strips tool metadata — extract skill name from well-known output format.
245
+ // Cross-validated against api.client.app.skills() when available.
246
+ let name: string | undefined = tp.state.metadata?.name
247
+ if (typeof name !== "string") {
248
+ const m = typeof tp.state.output === "string"
249
+ ? tp.state.output.match(/^#{1,2}\s*Skill:\s*(.+)/m)
250
+ : null
251
+ if (m) name = m[1].trim()
252
+ }
253
+ if (typeof name === "string") {
254
+ const tokens = typeof tp.state.output === "string" ? estimateTokens(tp.state.output) : 0
255
+ const existing = loadedSkills.get(name)
256
+ if (!existing || existing.tokens < tokens) {
257
+ loadedSkills.set(name, { name, tokens })
258
+ }
259
+ }
260
+ }
261
+ } else if (p.type === "subtask") { const sub = p as any; dist.agent += estimateTokens(sub.prompt || sub.description || "") }
262
+ }
263
+ }
264
+ }
265
+ // 从后往前找最后一条有 token 数据的 assistant 消息(避免取到 streaming 中未填充的消息)
266
+ for (let i = msgs.length - 1; i >= 0; i--) {
267
+ if (msgs[i].role !== "assistant") continue
268
+ const tok = (msgs[i] as AssistantMessage).tokens
269
+ if (tok && ((tok.input ?? 0) > 0 || (tok.cache?.read ?? 0) > 0 || (tok.cache?.write ?? 0) > 0)) { lastAssMsg = msgs[i] as AssistantMessage; break }
270
+ }
271
+ // 取最后一条有数据消息的总输入(含缓存读/写)作为当前 context 大小
272
+ dist.apiInput = num(lastAssMsg?.tokens?.input) + num(lastAssMsg?.tokens?.cache?.read) + num(lastAssMsg?.tokens?.cache?.write)
273
+ dist.apiOutput = num(lastAssMsg?.tokens?.output)
274
+ // 本回合(最后一条有数据消息所在的 parentID 链)的 API 调用次数与末次成本。
275
+ // opencode 将回合内每次工具调用循环拆为独立 assistant 消息(各含 1 个 step-finish),
276
+ // 故按 parentID 链聚合统计,而非单条消息。
277
+ if (lastAssMsg) {
278
+ const roundParent = (lastAssMsg as AssistantMessage).parentID
279
+ let lastCost: number | undefined
280
+ for (let i = msgs.length - 1; i >= 0; i--) {
281
+ const m = msgs[i]
282
+ if (m.role !== "assistant") continue
283
+ if ((m as AssistantMessage).parentID !== roundParent) break
284
+ let parts: readonly Part[] = []; try { parts = props.api.state.part(m.id) } catch {}
285
+ for (const p of parts) {
286
+ if (p.type !== "step-finish") continue
287
+ dist.stepCount++
288
+ const sc = (p as { cost?: unknown }).cost
289
+ if (lastCost === undefined && typeof sc === "number" && Number.isFinite(sc)) lastCost = sc
290
+ }
291
+ }
292
+ if (lastCost !== undefined) dist.stepCost = lastCost
293
+ }
294
+ hasDistData = dist.system + dist.user + dist.agent + dist.toolCall + dist.toolResult > 0 || dist.apiOutput > 0 || dist.apiInput > 0 || dist.reasoning > 0
295
+ } catch {}
296
+ const finalDist = hasDistData ? dist : lastDist(), finalHasDist = hasDistData || lastHasDist()
297
+ const skills = [...loadedSkills.values()]
298
+ return { finalDist, finalHasDist, skills }
299
+ })
300
+
301
+ setDataSignal({
302
+ hitRate, read, write, freshInput: input, output, cost, saved, model,
303
+ inputRate, cacheReadRate, cacheWriteRate, hasPricing,
304
+ hasData: read > 0 || write > 0 || input > 0 || output > 0 || cost > 0,
305
+ trend, hasTrendData, providerName, sessionHitRate,
306
+ dist: distData.finalDist, hasDistData: distData.finalHasDist,
307
+ skills: distData.skills, hasSkills: distData.skills.length > 0,
308
+ })
309
+ })
310
+
311
+ const data = createMemo(() => {
312
+ return dataSignal()
313
+ })
314
+
315
+ // Persist the last valid distribution so that data() can fall back
316
+ // to it while api.state.part() is re-hydrating after a view switch.
317
+ createEffect(() => {
318
+ const d = data()
319
+ if (d.hasDistData) {
320
+ setLastDist({ ...d.dist })
321
+ setLastHasDist(true)
322
+ // Also persist across component remounts (view switches)
323
+ try { props.api.kv.set(`${KV_PREFIX}.dist_snapshot`, { ...d.dist }) } catch {}
324
+ }
325
+ })
326
+
327
+ // ── token distribution (in-process via api.state.part) ──
328
+ const [partVersion, setPartVersion] = createSignal(0)
329
+
330
+ // Persist fold state to api.kv
331
+ const KV_PREFIX = "cache_panel"
332
+ const persistFold = (key: string, val: boolean) => {
333
+ try { props.api.kv.set(`${KV_PREFIX}.${key}`, val) } catch {}
334
+ }
335
+
336
+ onMount(() => {
337
+ // Reset panelWidth on (re)mount so the layout uses a clean
338
+ // default until onSizeChange measures the live box dimensions.
339
+ setPanelWidth(DEFAULT_PANEL_WIDTH)
340
+
341
+ // Restore fold state from persisted storage (non-critical — fire and forget)
342
+ try {
343
+ setOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.open`, false)))
344
+ setDetailOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.detail`, true)))
345
+ setModelOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.model`, true)))
346
+ setDistOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.dist`, false)))
347
+ setSkillsOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.skills`, true)))
348
+ } catch {}
349
+
350
+ // Restore user config (currency, rate, section visibility).
351
+ // Try synchronously first (kv is usually ready on mount), fall back to
352
+ // polling if the module was reloaded and kv hasn't initialised yet.
353
+ const doRestore = () => {
354
+ try {
355
+ const sym = props.api.kv.get<string>(`${KV_PREFIX}.currency`)
356
+ const rate = props.api.kv.get<number>(`${KV_PREFIX}.rate`)
357
+ if (typeof sym === "string") setCurrencySymbol(sym)
358
+ if (typeof rate === "number" && rate > 0) setExchangeRate(rate)
359
+ const balCur = props.api.kv.get<string>(`${KV_PREFIX}.balance_currency`)
360
+ if (typeof balCur === "string") setBalanceCurrency(balCur)
361
+ // Restore balance provider (fall back to default when unknown)
362
+ const savedProvider = props.api.kv.get<string>(`${KV_PREFIX}.balance.provider`)
363
+ if (typeof savedProvider === "string" && balanceProviders.some((p) => p.id === savedProvider)) {
364
+ setBalanceProviderId(savedProvider)
365
+ setBalanceUnsupported(false)
366
+ }
367
+ // Restore auto-switch (default on)
368
+ const savedAuto = props.api.kv.get<boolean>(`${KV_PREFIX}.balance.auto`)
369
+ if (typeof savedAuto === "boolean") setAutoBalance(savedAuto)
370
+ // Migrate legacy DeepSeek key (cache_panel.ds_key → cache_panel.balance.deepseek.key)
371
+ const legacyKey = props.api.kv.get<string>(`${KV_PREFIX}.ds_key`, "")
372
+ if (legacyKey) {
373
+ const dsKey = props.api.kv.get<string>(`${KV_PREFIX}.balance.deepseek.key`, "")
374
+ if (!dsKey) props.api.kv.set(`${KV_PREFIX}.balance.deepseek.key`, legacyKey)
375
+ props.api.kv.set(`${KV_PREFIX}.ds_key`, "")
376
+ }
377
+ // 恢复的 provider 可能与默认值不同,强制重新查询
378
+ props.signals.setBalanceRefresh(props.signals.balanceRefresh() + 1)
379
+ setSectionDetail(Boolean(props.api.kv.get(`${KV_PREFIX}.section.detail`, true)))
380
+ setSectionModel(Boolean(props.api.kv.get(`${KV_PREFIX}.section.model`, true)))
381
+ setSectionDist(Boolean(props.api.kv.get(`${KV_PREFIX}.section.dist`, true)))
382
+ setSectionSkills(Boolean(props.api.kv.get(`${KV_PREFIX}.section.skills`, true)))
383
+ setSectionBalance(Boolean(props.api.kv.get(`${KV_PREFIX}.section.balance`, true)))
384
+ const bv = props.api.kv.get<boolean>(`${KV_PREFIX}.border`, true)
385
+ setBorderVisible(bv !== false)
386
+ // Restore distribution snapshot so the token distribution block
387
+ // doesn't blank out while api.state.part() re-hydrates.
388
+ const cachedDist = props.api.kv.get<TokenDist>(`${KV_PREFIX}.dist_snapshot`)
389
+ if (cachedDist) {
390
+ setLastDist(cachedDist)
391
+ setLastHasDist(true)
392
+ }
393
+ } catch {
394
+ // kv read failed — signals stay at defaults
395
+ }
396
+ // Re-measure panel width after config signals have settled
397
+ if (boxEl && typeof boxEl.width === "number" && boxEl.width > 0) {
398
+ setPanelWidth(Math.max(MIN_PANEL_WIDTH, boxEl.width))
399
+ }
400
+ }
401
+
402
+ if (props.api.kv.ready) {
403
+ doRestore()
404
+ } else {
405
+ // Poll kv.ready with a 1-second timeout to avoid infinite busy-wait
406
+ // on platforms where kv initialisation may be delayed (Linux single-thread
407
+ // mode, session switch storms, etc.).
408
+ const MAX_POLL = 100
409
+ let tries = 0
410
+ const pollRestore = () => {
411
+ if (!props.api.kv.ready) {
412
+ if (++tries > MAX_POLL) { doRestore(); return }
413
+ setTimeout(pollRestore, 10)
414
+ return
415
+ }
416
+ doRestore()
417
+ }
418
+ pollRestore()
419
+ }
420
+
421
+ // Debounce partVersion updates so that event bursts during session
422
+ // switching / streaming don't cause data() to re-compute on every
423
+ // single event (up to hundreds per second on Linux single-thread).
424
+ let partTimer: ReturnType<typeof setTimeout> | undefined
425
+ const bumpPartVersion = () => {
426
+ clearTimeout(partTimer)
427
+ partTimer = setTimeout(() => setPartVersion((v) => v + 1), 100)
428
+ }
429
+ const unsubPart = props.api.event.on("message.part.updated", () => { bumpPartVersion(); setRefreshTick(v => v + 1) })
430
+ const unsubMsg = props.api.event.on("message.updated", () => { bumpPartVersion(); setRefreshTick(v => v + 1) })
431
+ const unsubSession = props.api.event.on("session.updated", () => { setRefreshTick(v => v + 1) })
432
+ setRefreshTick(v => v + 1)
433
+ onCleanup(() => { clearTimeout(partTimer); unsubPart(); unsubMsg(); unsubSession() })
434
+ })
435
+
436
+ // ── colours ──
437
+ // Pull from the current theme, auto-desaturate if too punchy,
438
+ // fall back to Morandi when a key is missing from the theme.
439
+ const pal = createMemo(() => {
440
+ const t = props.theme as Record<string, unknown>
441
+ const sat = (k: string, fb: string) => desaturateTo(t[k], MAX_SAT, fb)
442
+ return {
443
+ primary: sat("primary", FALLBACK.primary),
444
+ text: sat("text", FALLBACK.text),
445
+ muted: sat("textMuted", FALLBACK.muted),
446
+ success: sat("success", FALLBACK.success),
447
+ warning: sat("warning", FALLBACK.warning),
448
+ error: sat("error", FALLBACK.error),
449
+ border: sat("border", FALLBACK.border),
450
+ }
451
+ })
452
+
453
+ const hitColor = createMemo(() => {
454
+ const r = data().hitRate
455
+ if (r >= 85) return pal().success
456
+ if (r >= 70) return pal().warning
457
+ return pal().error
458
+ })
459
+
460
+ /** Horizontal space eaten by border (1+1 when visible) + padding (2+2 when visible). */
461
+ const gutter = createMemo(() => borderVisible() ? 6 : 0)
462
+
463
+ const sep = createMemo(() => "\u2500".repeat(Math.max(1, panelWidth() - gutter())))
464
+ function trendLabel(t: number): string {
465
+ // |t| < 0.05 视为无变化:避免显示 "↑0.0%" 的矛盾(箭头存在但数值截断为零)
466
+ if (Math.abs(t) < 0.05) return "-"
467
+ return (t > 0 ? "\u2191" : "\u2193") + Math.abs(t).toFixed(1) + "%"
468
+ }
469
+
470
+ const barW = createMemo(() => {
471
+ const trendSpace = data().hasTrendData ? LABEL_GAP + visualWidth(trendLabel(data().trend)) : 0
472
+ const overhead = visualWidth(t("hit")) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + gutter()
473
+ return Math.max(3, panelWidth() - overhead)
474
+ })
475
+ const bar = createMemo(() => progressBar(data().hitRate, barW()))
476
+ const pct = createMemo(() => (Math.floor(data().hitRate * 10) / 10).toFixed(1) + "%")
477
+
478
+ // When border visibility changes the box dimensions shift, which
479
+ // may not reliably trigger onSizeChange across (re)mount cycles.
480
+ // Force panelWidth to resync with the live box after every change.
481
+ createEffect(() => {
482
+ borderVisible()
483
+ if (boxEl && typeof boxEl.width === "number" && boxEl.width > 0) {
484
+ const w = Math.max(MIN_PANEL_WIDTH, boxEl.width)
485
+ setPanelWidth((prev) => (prev === w ? prev : w))
486
+ }
487
+ })
488
+
489
+ // left-align label, right-align value — auto-fill space between
490
+ const justify = (label: string, value: string, unit = ""): string => {
491
+ const gauge = panelWidth() - gutter()
492
+ const used = visualWidth(label) + visualWidth(value) + (unit ? visualWidth(unit) + UNIT_GAP : 0)
493
+ const gap = Math.max(1, gauge - used)
494
+ return label + " ".repeat(gap) + value + (unit ? " " + unit : "")
495
+ }
496
+
497
+ return (
498
+ <box
499
+ border={borderVisible()}
500
+ {...(borderVisible() ? { borderColor: pal().border } : {})}
501
+ paddingTop={0}
502
+ paddingBottom={0}
503
+ paddingLeft={borderVisible() ? 2 : 0}
504
+ paddingRight={borderVisible() ? 2 : 0}
505
+ flexDirection="column"
506
+ gap={0}
507
+ ref={boxEl}
508
+ onSizeChange={() => {
509
+ // boxEl.width may be undefined before the first measurement — guard with 0
510
+ const w = boxEl ? Math.max(MIN_PANEL_WIDTH, boxEl.width ?? 0) : DEFAULT_PANEL_WIDTH
511
+ setPanelWidth((prev) => (prev === w ? prev : w))
512
+ }}
513
+ >
514
+ {/* collapsible header */}
515
+ <text onMouseUp={() => setOpen((o) => { const n = !o; persistFold("open", n); return n })}>
516
+ <span style={{ fg: pal().muted }}>{open() ? "\u25bc " : "\u25b6 "}</span>
517
+ <span style={{ fg: pal().primary }}>
518
+ <b>{t("title")}</b>
519
+ <Show when={open()}>
520
+ <span style={{ fg: dimColor(pal().muted, 0.75) }}> v{PLUGIN_VERSION}</span>
521
+ </Show>
522
+ </span>
523
+ <Show when={!open() && data().hasData}>
524
+ <Show when={data().hasTrendData}>
525
+ <span>
526
+ {" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(t("title")) - visualWidth(pct() + " " + t("hitFolded") + " " + trendLabel(data().trend))))}
527
+ </span>
528
+ <span style={{ fg: hitColor() }}>{pct()} {t("hitFolded")}</span>
529
+ <span style={{ fg: Math.abs(data().trend) >= 0.05 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }}>
530
+ {" "}{trendLabel(data().trend)}
531
+ </span>
532
+ </Show>
533
+ <Show when={!data().hasTrendData}>
534
+ <span>
535
+ {" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(t("title")) - visualWidth(pct() + " " + t("hitFolded"))))}
536
+ </span>
537
+ <span style={{ fg: hitColor() }}>{pct()} {t("hitFolded")}</span>
538
+ </Show>
539
+ </Show>
540
+ </text>
541
+
542
+ <Show when={open()}>
543
+ <Show when={props.signals.overrideSessionId()}>
544
+ {(() => {
545
+ const prefix = " \u21b3 " + t("subPrefix")
546
+ const maxSidW = Math.max(6, panelWidth() - visualWidth(prefix))
547
+ return (
548
+ <text>
549
+ <span style={{ fg: pal().muted }}>{prefix}</span>
550
+ <span style={{ fg: pal().text }}>{truncateVisual(props.signals.overrideSessionId()!, maxSidW)}</span>
551
+ </text>
552
+ )
553
+ })()}
554
+ </Show>
555
+ <Show when={data().hasData} fallback={
556
+ <>
557
+ <text fg={pal().muted}>{sep()}</text>
558
+ <text>
559
+ <span style={{ fg: pal().muted }}>{"> "}</span>
560
+ <span style={{ fg: pal().muted }}>{t("noData")}</span>
561
+ </text>
562
+ </>
563
+ }>
564
+ <text fg={pal().muted}>{sep()}</text>
565
+
566
+ {/* hit rate + bar — inline to avoid box spacing */}
567
+ <text>
568
+ <span style={{ fg: pal().text }}>{t("hit")} </span>
569
+ <span style={{ fg: hitColor() }}>[{bar()}] </span>
570
+ <span style={{ fg: pal().text }}>{pct()}</span>
571
+ <Show when={data().hasTrendData}>
572
+ <span style={{ fg: Math.abs(data().trend) >= 0.05 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }}>
573
+ {" "}{trendLabel(data().trend)}
574
+ </span>
575
+ </Show>
576
+ </text>
577
+
578
+ {/* session cumulative hit rate */}
579
+ <text fg={pal().muted}>
580
+ {justify(t("totalHit"), (Math.floor(data().sessionHitRate * 10) / 10).toFixed(1) + "%")}
581
+ </text>
582
+
583
+ {/* ── detail section (collapsible, default open) ── */}
584
+ <Show when={sectionDetail()}>
585
+ <text onMouseUp={() => setDetailOpen((o) => { const n = !o; persistFold("detail", n); return n })}>
586
+ <span style={{ fg: pal().muted }}>{detailOpen() ? "\u25bc " : "\u25b6 "}</span>
587
+ <span style={{ fg: pal().primary }}><b>{t("secDetail")}</b></span>
588
+ <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((detailOpen() ? "\u25bc " : "\u25b6 ") + t("secDetail")))}</span>
589
+ </text>
590
+
591
+ <Show when={detailOpen()}>
592
+ <Show when={data().read > 0}>
593
+ <text fg={pal().muted}>
594
+ {justify(t("read"), fmt(data().read), t("tok"))}
595
+ </text>
596
+ </Show>
597
+ <Show when={data().write > 0}>
598
+ <text fg={pal().muted}>
599
+ {justify(t("write"), fmt(data().write), t("tok"))}
600
+ </text>
601
+ </Show>
602
+ {/* 未命中 = 新鲜输入 + 缓存写(两者都未从缓存命中) */}
603
+ <text fg={pal().muted}>
604
+ {justify(t("miss"), fmt(data().freshInput + data().write), t("tok"))}
605
+ </text>
606
+ <text fg={pal().muted}>
607
+ {justify(t("out"), fmt(data().output), t("tok"))}
608
+ </text>
609
+ {/* 本回合多次 API 调用时才显示调用次数与末次成本(单次调用不占行) */}
610
+ <Show when={data().dist.stepCount >= 2}>
611
+ <text fg={pal().muted}>
612
+ {justify(t("stepsCount", { n: data().dist.stepCount }), fmtCost(data().dist.stepCost, currencySymbol(), exchangeRate()))}
613
+ </text>
614
+ </Show>
615
+ <Show when={data().saved > 0}>
616
+ <text>
617
+ <span style={{ fg: pal().muted }}>{t("saved")}</span>
618
+ <span>{" ".repeat(Math.max(1, panelWidth() - gutter() - visualWidth(t("saved")) - visualWidth("~" + fmtCost(data().saved, currencySymbol(), exchangeRate()))))}</span>
619
+ <span style={{ fg: pal().success }}>~{fmtCost(data().saved, currencySymbol(), exchangeRate())}</span>
620
+ </text>
621
+ </Show>
622
+ </Show>
623
+ </Show>
624
+
625
+ {/* ── model section (collapsible, default open) ── */}
626
+ <Show when={sectionModel()}>
627
+ {<text onMouseUp={() => setModelOpen((o) => { const n = !o; persistFold("model", n); return n })}>
628
+ <span style={{ fg: pal().muted }}>{modelOpen() ? "\u25bc " : "\u25b6 "}</span>
629
+ <span style={{ fg: pal().primary }}><b>{t("secModel")}</b></span>
630
+ <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((modelOpen() ? "\u25bc " : "\u25b6 ") + t("secModel")))}</span>
631
+ </text>}
632
+
633
+ <Show when={modelOpen()}>
634
+ <text fg={pal().text}>
635
+ {justify(t("cost"), fmtCost(data().cost, currencySymbol(), exchangeRate()))}
636
+ </text>
637
+ <Show when={data().providerName}>
638
+ <text fg={pal().muted}>
639
+ {justify(t("provider"), data().providerName)}
640
+ </text>
641
+ </Show>
642
+ <text fg={pal().muted}>
643
+ {justify(t("model"), data().model)}
644
+ </text>
645
+ <Show when={data().hasPricing}>
646
+ <text fg={pal().muted}>
647
+ {justify(t("rate"), currencySymbol() + (data().inputRate * exchangeRate()).toFixed(2) + "/M " + t("inputRate"))}
648
+ </text>
649
+ <Show when={data().cacheReadRate > 0}>
650
+ <text fg={pal().muted}>
651
+ {justify("", currencySymbol() + (data().cacheReadRate * exchangeRate()).toFixed(2) + "/M " + t("cacheRate"))}
652
+ </text>
653
+ </Show>
654
+ <Show when={data().cacheWriteRate > 0}>
655
+ <text fg={pal().muted}>
656
+ {justify("", currencySymbol() + (data().cacheWriteRate * exchangeRate()).toFixed(2) + "/M " + t("writeRate"))}
657
+ </text>
658
+ </Show>
659
+ </Show>
660
+ </Show>
661
+ </Show>
662
+
663
+ {/* ── token distribution (collapsible, default closed) ── */}
664
+ <Show when={sectionDist()}>
665
+ <Show when={data().hasDistData}>
666
+ {<text onMouseUp={() => setDistOpen((o) => { const n = !o; persistFold("dist", n); return n })}>
667
+ <span style={{ fg: pal().muted }}>{distOpen() ? "\u25bc " : "\u25b6 "}</span>
668
+ <span style={{ fg: pal().primary }}><b>{t("distTitle")}</b></span>
669
+ <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((distOpen() ? "\u25bc " : "\u25b6 ") + t("distTitle")))}</span>
670
+ </text>}
671
+ <Show when={distOpen()}>
672
+ <Show when={data().dist.system > 0}>
673
+ <text fg={pal().muted}>
674
+ {justify(t("distSys"), fmt(data().dist.system), t("tok"))}
675
+ </text>
676
+ </Show>
677
+ <Show when={data().dist.user > 0}>
678
+ <text fg={pal().muted}>
679
+ {justify(t("distUser"), fmt(data().dist.user), t("tok"))}
680
+ </text>
681
+ </Show>
682
+ <Show when={data().dist.agent > 0}>
683
+ <text fg={pal().muted}>
684
+ {justify(t("distAgent"), fmt(data().dist.agent), t("tok"))}
685
+ </text>
686
+ </Show>
687
+ <Show when={data().dist.toolCall > 0}>
688
+ <text fg={pal().muted}>
689
+ {justify(t("distTool"), fmt(data().dist.toolCall), t("tok"))}
690
+ </text>
691
+ </Show>
692
+ <Show when={data().dist.toolResult > 0}>
693
+ <text fg={pal().muted}>
694
+ {justify(t("distRes"), fmt(data().dist.toolResult), t("tok"))}
695
+ </text>
696
+ </Show>
697
+ <Show when={data().dist.reasoning > 0}>
698
+ <text fg={pal().muted}>
699
+ {justify(t("distReason"), fmt(data().dist.reasoning), t("tok"))}
700
+ </text>
701
+ </Show>
702
+ </Show>
703
+ </Show>
704
+ </Show>
705
+
706
+ {/* ── loaded skills (collapsible, default open) ── */}
707
+ <Show when={sectionSkills()}>
708
+ <Show when={data().hasSkills}>
709
+ {<text onMouseUp={() => setSkillsOpen((o) => { const n = !o; persistFold("skills", n); return n })}>
710
+ <span style={{ fg: pal().muted }}>{skillsOpen() ? "\u25bc " : "\u25b6 "}</span>
711
+ <span style={{ fg: pal().primary }}><b>{t("secSkills")}</b></span>
712
+ <span style={{ fg: pal().muted }}> ({data().skills.length})</span>
713
+ <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((skillsOpen() ? "\u25bc " : "\u25b6 ") + t("secSkills") + ` (${data().skills.length})`))}</span>
714
+ </text>}
715
+ <Show when={skillsOpen()}>
716
+ {data().skills.map((sk: { name: string; tokens: number }) => {
717
+ const rightW = visualWidth(fmt(sk.tokens)) + UNIT_GAP + visualWidth(t("tok"))
718
+ const maxLabel = Math.max(4, panelWidth() - gutter() - rightW - 1)
719
+ const label = truncateVisual(sk.name, maxLabel)
720
+ return (
721
+ <text fg={pal().muted}>
722
+ {justify(label, fmt(sk.tokens), t("tok"))}
723
+ </text>
724
+ )
725
+ })}
726
+ </Show>
727
+ </Show>
728
+ </Show>
729
+
730
+ {/* ── provider balance (single line) ── */}
731
+ <Show when={sectionBalance()}>
732
+ <text fg={pal().muted}>{sep()}</text>
733
+ <Show when={balanceUnsupported()}>
734
+ <text fg={pal().muted}>
735
+ <span style={{ fg: pal().muted }}>{"> "}</span>
736
+ <span>{t("balUnsupported")}</span>
737
+ </text>
738
+ </Show>
739
+ <Show when={!balanceUnsupported()}>
740
+ <Show when={balanceState().status === "idle"}>
741
+ <text fg={pal().muted}>
742
+ <span style={{ fg: pal().muted }}>{"> "}</span>
743
+ <span>{t("balNoKey", { p: providerName() })}</span>
744
+ </text>
745
+ </Show>
746
+ <Show when={balanceState().status === "loading"}>
747
+ <text fg={pal().muted}>
748
+ <span style={{ fg: pal().muted }}>{"> "}</span>
749
+ <span>{t("balLoading")}</span>
750
+ </text>
751
+ </Show>
752
+ <Show when={balanceState().status === "error"}>
753
+ <text fg={pal().error}>
754
+ <span style={{ fg: pal().muted }}>{"> "}</span>
755
+ <span>{(() => {
756
+ const code = balanceState().error
757
+ if (code === "401") return t("balErr401")
758
+ if (code === "403") return t("balErr403")
759
+ if (code === "EMPTY") return t("balErrEmpty")
760
+ if (code === "TIMEOUT") return t("balErrTimeout")
761
+ return t("balError") + (code ? ` (${code})` : "")
762
+ })()}</span>
763
+ </text>
764
+ </Show>
765
+ <Show when={balanceState().status === "ok" && balanceState().data}>
766
+ <text fg={pal().text}>
767
+ {justify(t("balTotal"), formatBalanceText(balanceState().data!, balanceCurrency(), exchangeRate()))}
768
+ </text>
769
+ </Show>
770
+ </Show>
771
+ </Show>
772
+ </Show>
773
+ </Show>
774
+ </box>
775
+ )
776
+ }